Skip to content

Instantly share code, notes, and snippets.

@lxchurbakov
Last active March 4, 2025 05:46
Show Gist options
  • Save lxchurbakov/a2de4df1b7304bb36e3088fb8a12cf66 to your computer and use it in GitHub Desktop.
Save lxchurbakov/a2de4df1b7304bb36e3088fb8a12cf66 to your computer and use it in GitHub Desktop.
hard.js / описание проекта

hard.js

Описание

Сайт с задачами для программистов. Но не обычными, а очень сложными, решить которые очень трудно или даже невозможно. Чтобы попробовать решить задачу нужно заплатить (скажем 100 рублей), и пользователь получает возможность в течение 24 часов отправлять своё решение (код) на проверку автоматическими тестами.

Если решить задачу не получилось, 100 рублей откладываются в "фонд задачи". Если получилось, пользователь забирает все деньги из фонда (всё то, что заплатили пользователи, пытавшиеся решить задачу до него). Часть денег с каждой попытки также получает автор задачи. Модератор проверяет, что все участники процесса играют по правилам.

Стэк

Используется JS, React и styled-components. На бэке node.js и mongodb.

Фичи

Авторизация

Можно авторизоваться через гитхаб oauth, пользователи с полученной от гитхаба инфой складываются в монгу. Каждому пользователю ассайнится роль user (роли могут быть admin, moderator или user).

КРУД задач

На главной странице можно посмотреть пагинированный список задач, у каждой задачи есть название (строка), описание (маркдаун), стоимость попытки, фонд и статус (не видимый для пользователя). Пользователи могут просматривать любые задачи в статусе PUBLISHED, а также создавать свои собственные.

Для того, чтобы создать задачу, пользователь заполняет специальную форму на отдельной странице. Созданная через форму задача переводится в статус DRAFT. Список созданных пользователем задач находится под списком всех задач. В любой момент времени автор задачи может открыть собственную задачу, посмотреть, отредактировать или удалить её.

Тесты

Через специальное поле (группу полей) в форме редактирования задачи для неё создаются тесты. Каждый тест это JS код, который вместе с кодом задачи будет запущен в отдельном процессе. Каждый тест возвращает промис, который, если реджектнут, говорит о том, что тест провалился. Переданное с аргументом в реджекте сообщение показывается пользователю.

Публикация

В форме создания задачи есть кнопка "опубликовать". Опубликованная задача переводится в статус ON_REVIEW. Эту задачу видят пользователи с ролью moderator или admin на специальной странице (об этом позднее). Переведенная в статус ON_REVIEW задача больше не может быть отредактировано автором в форме.

Админка

Есть отдельная страница, доступная только админам и модераторам. Админ видит на ней таблицу пользователей, и задач, модератор видит только таблицу задач. Каждая задача или пользователь могут быть открыты в отдельном окне. Вся инфа о задаче или пользователе может быть изменена. Также пользователь или задача могут быть удалены.

Попытка

на экране задачи у пользователя (не автора задачи) будет возможность попробовать решить задачу. Для этого пользователь создаёт сущность Попытка. Попытка имеет статус, изначально он UNPAID. Пока что (для удобства тестирования) попытка сразу будет переводиться в статус PAID. Каждая попытка имеет отдельную страницу, содержимое которой зависит от статуса попытки.

Попытка в статусе PAID отображает сообщение "Оплата прошла успешно. Готовы начать?". Когда пользователь соглашается, попытка переводится в статус ON_AIR. Страница попытки в статусе ON_AIR содержит описание задачи и окно с редактором кода. При нажатии "отправить" код отправляется на сервер и прогоняется по тестам. Сообщение из первого упавшего теста возвращается пользователю.

Если все тесты прошли успешно, попытка и задача переводятся в статус SOLVED. Больше создавать попыток к этой задаче нельзя. Экран попытки содержит сообщение "Попытка успешна", а также пояснения о том, как вывести деньги (пока что этого не будет).

Попытки отображаются отдельной таблицей в админке. Доступна эта таблица модераторам и админам. Попытку можно перевести в статус verified, а затем в статус paid. Все попытки пользователя также доступны у него на главной странице в виде таблицы.

Если пользователь не справился с решением задачи за 24 часа, попытка автоматически переводится в статус FAILED.

//
//
//
//
// Клиентская часть
//
// const CLIENT_ID = ...
// const CALLBACK_URL = ...
export const useAuth = () => {
const [token, setToken] = useLocalStorage('jwt', null);
const api = useApi(token);
const [user, setUser] = React.useState(null as any);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setUser(null);
setLoading(true);
api.session.get()
.then(setUser)
.catch(setError)
.then((() => setLoading(false)));
}, [api]);
const login = React.useMemo(() => ({
github: {
start: () => {
const params = Object.entries({
client_id: CLIENT_ID,
scope: 'user,user:email',
callback_url: CALLBACK_URL,
}).map(([key, value]) => `${key}=${value}`).join('&');
window.open(`https://github.com/login/oauth/authorize?${params}`, '_blank');
},
check: async (code) => {
setToken(await api.session.post(code));
},
},
}), [api, setToken]);
const logout = React.useCallback(() => {
setToken(null);
}, [setToken]);
return React.useMemo(() => ({ loading, user, login, logout, error }), [
loading, user, login, logout, error
]);
};
//
// Использование в контейнере
//
export default () => {
const auth = useAuth();
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
const code = new URLSearchParams(location.search).get('code');
if (code && !auth.user) {
;(async () => {
setLoading(true);
await auth.login.github.check(code).catch((e) => { /* оповещение */ });
history.pushState({}, '', window.location.pathname);
setLoading(false);
})();
}
}, []);
const proceed = React.useCallback(() => {
auth.login.github.start();
}, [auth]);
return (
<Container>
{(loading || auth.loading) && (
<Text>Loading...</Text>
)}
{!auth.user && !loading && (
<Clickable p="8px 16px" radius="8px" background="orange" onClick={proceed}>
<Text color="white" weight="600">Proceed</Text>
</Clickable>
)}
{auth.user && (
<Base>
<Text>Welcome, {auth.user.name}</Text>
<Clickable p="8px 16px" radius="8px" background="orange" onClick={auth.logout}>
<Text color="white" weight="600">Logout</Text>
</Clickable>
</Base>
)}
</Container>
);
};
//
// Серверная часть
//
// const CLIENT_ID = ...
// const CLIENT_SECRET = ...
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
const getUserData = async (accessToken) => {
const user = await fetch('https://api.github.com/user', { headers: { ...headers, 'Authorization': `Bearer ${accessToken}` } });
const emails = await fetch('https://api.github.com/user/emails', { headers: { ...headers, 'Authorization': `Bearer ${accessToken}` } });
const primaryEmail = emails.find(($) => $.primary);
if (!primaryEmail?.email || !user?.name) {
throw new Error('insufficient_user_data');
}
return {
name: user.name,
email: primaryEmail.email,
avatar: user.avatar_url,
};
};
router
.get('/api/v1/session', route(async (req) => {
const token = req.get('Authorization');
const row = await readJWT(token).catch(() => null);
if (!row) {
throw new HttpError(400, 'invalid_token');
}
return row;
}))
.post('/api/v1/session', route (async (req) => {
const { code } = req.body;
const params = qs({ client_id: CLIENT_ID, code, client_secret: CLIENT_SECRET });
const data = await fetch(`https://github.com/login/oauth/access_token?${params}`, { method: 'POST', headers })
.then((r) => r.json())
.catch((e) => e);
if (!data.access_token) {
throw new HttpError(400, 'invalid_server_response');
}
const user = await getUserData(data.access_token);
let row = await db.select('*').from('users').where({ email: user.email }).first();
if (!row) {
row = await db('users').insert(user).returning('*').first();
}
const token = await createJWT(row);
return token;
}));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment