diff --git a/.eslintrc.json b/.eslintrc.json index d18bdc3c77..845dc1c5e4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,13 @@ "simple-import-sort" ], "rules": { + "@typescript-eslint/array-type": [ + "error", + { + "default": "generic", + "readonly": "generic" + } + ], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/explicit-function-return-type": 0, diff --git a/backend/matching/src/index.ts b/backend/matching/src/index.ts index 390e16e9d7..e28043f2b5 100644 --- a/backend/matching/src/index.ts +++ b/backend/matching/src/index.ts @@ -5,7 +5,7 @@ import { logger } from '@/lib/utils'; import server, { io } from '@/server'; import { initWorker } from '@/workers'; -const workers: ChildProcess[] = []; +const workers: Array = []; const port = Number.parseInt(EXPRESS_PORT || '8001'); diff --git a/backend/matching/src/services/get-match-items.ts b/backend/matching/src/services/get-match-items.ts index 4697438e37..f5b206635e 100644 --- a/backend/matching/src/services/get-match-items.ts +++ b/backend/matching/src/services/get-match-items.ts @@ -3,7 +3,6 @@ import type { IMatchItemsResponse, IMatchType } from '@/types'; import { createRoom } from './collab'; import { getRandomQuestion } from './question'; -import { fetchAttemptedQuestions } from './user'; export async function getMatchItems( searchIdentifier: IMatchType, @@ -17,26 +16,13 @@ export async function getMatchItems( throw new Error('Both user IDs are required'); } - let allAttemptedQuestions: number[] = []; - - try { - const [attemptedQuestions1, attemptedQuestions2] = await Promise.all([ - fetchAttemptedQuestions(userId1), - fetchAttemptedQuestions(userId2), - ]); - allAttemptedQuestions = [...new Set([...attemptedQuestions1, ...attemptedQuestions2])]; - } catch (error) { - logger.error('Error in getMatchItems: Failed to fetch attempted questions', error); - } - const topics = topic?.split('|') ?? []; const payload = { - attemptedQuestions: allAttemptedQuestions, + userId1, + userId2, ...(searchIdentifier === 'difficulty' && difficulty ? { difficulty } : {}), - ...(searchIdentifier === 'topic' && topic ? { topic: topics } : {}), - ...(searchIdentifier === 'exact match' && topic && difficulty - ? { topic: topics, difficulty } - : {}), + ...(searchIdentifier === 'topic' && topic ? { topics } : {}), + ...(searchIdentifier === 'exact match' && topic && difficulty ? { topics, difficulty } : {}), }; // Get a random question @@ -55,7 +41,8 @@ export async function getMatchItems( questionId: question.id, }; } catch (error) { - logger.error('Error in getMatchItems:', error); + const { name, message, stack, cause } = error as Error; + logger.error(`Error in getMatchItems: ${JSON.stringify({ name, message, stack, cause })}`); return undefined; } } diff --git a/backend/matching/src/services/question.ts b/backend/matching/src/services/question.ts index 97fbebfc78..e6ea8bedfb 100644 --- a/backend/matching/src/services/question.ts +++ b/backend/matching/src/services/question.ts @@ -1,16 +1,16 @@ -import type { IGetRandomQuestionPayload, IQuestion, IServiceResponse } from '@/types'; +import type { IGetRandomQuestionPayload, IQuestion } from '@/types'; import { questionServiceClient, routes } from './_hosts'; export async function getRandomQuestion(payload: IGetRandomQuestionPayload): Promise { - const response = await questionServiceClient.post>( + const response = await questionServiceClient.post( routes.QUESTION_SERVICE.GET_RANDOM_QN.path, payload ); - if (response.status !== 200 || !response.data.data) { - throw new Error(response.data.error?.message || 'Failed to get a random question'); + if (response.status !== 200 || !response.data) { + throw new Error(response.statusText || 'Failed to get a random question'); } - return response?.data?.data?.question ?? undefined; + return response?.data ?? undefined; } diff --git a/backend/matching/src/services/user.ts b/backend/matching/src/services/user.ts index f3123d7e5d..7aa84b46f4 100644 --- a/backend/matching/src/services/user.ts +++ b/backend/matching/src/services/user.ts @@ -1,7 +1,7 @@ import { routes, userServiceClient } from './_hosts'; -export async function fetchAttemptedQuestions(userId: string): Promise { - const response = await userServiceClient.post( +export async function fetchAttemptedQuestions(userId: string): Promise> { + const response = await userServiceClient.post>( routes.USER_SERVICE.ATTEMPTED_QNS.GET.path, { userId, diff --git a/backend/matching/src/types/index.ts b/backend/matching/src/types/index.ts index 97aba603ad..64ab520787 100644 --- a/backend/matching/src/types/index.ts +++ b/backend/matching/src/types/index.ts @@ -10,7 +10,7 @@ export type IRequestMatchRESTPayload = { }; export type IRequestMatchWSPayload = { - topic: string | string[]; + topic: string | Array; difficulty: string; }; @@ -61,9 +61,10 @@ export interface IQuestion { } export interface IGetRandomQuestionPayload { - attemptedQuestions: number[]; + userId1: string; + userId2: string; difficulty?: string; - topic?: Array; + topics?: Array; } export interface IMatchItemsResponse { diff --git a/backend/matching/src/workers/matcher.ts b/backend/matching/src/workers/matcher.ts index 62efe6bad4..e77171f707 100644 --- a/backend/matching/src/workers/matcher.ts +++ b/backend/matching/src/workers/matcher.ts @@ -73,7 +73,7 @@ async function processMatch( ]); // Notify both sockets - const { ...matchItems } = await getMatchItems( + const matchItems = await getMatchItems( searchIdentifier, topic, difficulty, diff --git a/backend/question/drizzle/0001_attempt_history.sql b/backend/question/drizzle/0001_attempt_history.sql new file mode 100644 index 0000000000..ec5e9ec641 --- /dev/null +++ b/backend/question/drizzle/0001_attempt_history.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS "question_attempts" ( + "attempt_id" serial PRIMARY KEY NOT NULL, + "question_id" integer NOT NULL, + "user_id_1" uuid NOT NULL, + "user_id_2" uuid, + "code" text NOT NULL, + "timestamp" timestamp (6) with time zone DEFAULT now(), + "language" varchar(50) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "unique_users_attempt" ON "question_attempts" USING btree ("question_id","user_id_1","user_id_2"); \ No newline at end of file diff --git a/backend/question/drizzle/meta/0001_snapshot.json b/backend/question/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..18e41ae448 --- /dev/null +++ b/backend/question/drizzle/meta/0001_snapshot.json @@ -0,0 +1,190 @@ +{ + "id": "afa9ccaa-137c-47d3-acb0-ab1e2208038e", + "prevId": "84b2ca8d-3021-496f-8769-bbc4dada6468", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin": { + "name": "admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "action": { + "name": "action", + "type": "action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.question_attempts": { + "name": "question_attempts", + "schema": "", + "columns": { + "attempt_id": { + "name": "attempt_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id_1": { + "name": "user_id_1", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id_2": { + "name": "user_id_2", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "language": { + "name": "language", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "unique_users_attempt": { + "name": "unique_users_attempt", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id_1", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id_2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "varchar(255)[]", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.action": { + "name": "action", + "schema": "public", + "values": [ + "SEED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/question/drizzle/meta/_journal.json b/backend/question/drizzle/meta/_journal.json index e0f7e3dbf3..d9fe8b9aa5 100644 --- a/backend/question/drizzle/meta/_journal.json +++ b/backend/question/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1728143550719, "tag": "0000_initial_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1730553826248, + "tag": "0001_attempt_history", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/question/package.json b/backend/question/package.json index 8dd3739cac..7bccc133b9 100644 --- a/backend/question/package.json +++ b/backend/question/package.json @@ -31,7 +31,8 @@ "http-status-codes": "^2.3.0", "pino": "^9.4.0", "pino-http": "^10.3.0", - "postgres": "^3.4.4" + "postgres": "^3.4.4", + "uuid": "^11.0.2" }, "devDependencies": { "@swc/core": "^1.7.26", diff --git a/backend/question/src/controller/attempted-controller.ts b/backend/question/src/controller/attempted-controller.ts new file mode 100644 index 0000000000..dfd685e718 --- /dev/null +++ b/backend/question/src/controller/attempted-controller.ts @@ -0,0 +1,77 @@ +import { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +import { logger } from '@/lib/utils'; +import { isValidUUID } from '@/lib/uuid'; +import { getQuestionAttempts } from '@/services/get/get-attempts'; +import { addAttempt } from '@/services/post/addAttempt'; + +// Define the expected request body structure +interface AddAttemptRequestBody { + questionId: number; + userId1: string; + userId2?: string; // Optional if userId2 is not always required + code: string; + language: string; +} + +// Controller function to handle creating an attempt +export const createAttempt = async ( + req: Request, + res: Response +) => { + const { questionId, userId1, userId2, code, language } = req.body; + + // Basic validation for required fields + if (!questionId || !userId1 || !code || !language) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + try { + // Call the service function to add the attempt + const result = await addAttempt({ + questionId, + userId1, + userId2, + code, + language, + }); + + // Respond with success + res.status(StatusCodes.OK).json({ message: 'Attempt added successfully', result }); + } catch (err) { + const { name, message, stack, cause } = err as Error; + logger.error({ name, message, stack, cause }, 'Error adding attempt'); + + // Enhanced error response with error details + res.status(500).json({ + error: 'Error adding attempt', + details: err instanceof Error ? err.message : 'Unknown error', + }); + } +}; + +export const getAttempts = async ( + req: Request[0]>, unknown>, + res: Response +) => { + const { questionId, userId, limit, offset } = req.body; + + if (!questionId || isNaN(questionId) || !userId || !isValidUUID(userId)) { + return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json('Malformed Request'); + } + + try { + const result = await getQuestionAttempts({ questionId, userId, limit, offset }); + return res.status(StatusCodes.OK).json(result); + } catch (err) { + const { name, message, stack, cause } = err as Error; + logger.error({ name, message, stack, cause }, 'Error retrieving attempts'); + + // Enhanced error response with error details + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: 'Error retrieving attempts', + details: err instanceof Error ? err.message : 'Unknown error', + }); + } +}; diff --git a/backend/question/src/controller/question-controller.ts b/backend/question/src/controller/question-controller.ts index 36f35e053c..6e59ad4d27 100644 --- a/backend/question/src/controller/question-controller.ts +++ b/backend/question/src/controller/question-controller.ts @@ -5,15 +5,10 @@ import { getDifficultiesService, getQuestionDetailsService, getQuestionsService, - getRandomQuestionService, getTopicsService, searchQuestionsByTitleService, } from '@/services/get/index'; -import type { - IGetQuestionPayload, - IGetQuestionsPayload, - IGetRandomQuestionPayload, -} from '@/services/get/types'; +import type { IGetQuestionPayload, IGetQuestionsPayload } from '@/services/get/types'; import { createQuestionService, deleteQuestionService, @@ -26,13 +21,14 @@ import type { } from '@/services/post/types'; export const getQuestions = async (req: Request, res: Response): Promise => { - const { questionName, difficulty, topic, pageNum, recordsPerPage } = req.query; + const { questionName, difficulty, topic, pageNum, recordsPerPage, userId } = req.query; const payload: IGetQuestionsPayload = { questionName: questionName as string, difficulty: difficulty as string, - topic: topic as string[], + topic: topic as Array, pageNum: parseInt(pageNum as string) || 0, recordsPerPage: parseInt(recordsPerPage as string) || 20, + userId: userId as string, }; try { @@ -74,25 +70,6 @@ export const getQuestionDetails = async (req: Request, res: Response): Promise => { - const payload: IGetRandomQuestionPayload = { - attemptedQuestions: req.body.attemptedQuestions, - difficulty: req.body.difficulty, - topic: req.body.topic, - }; - - try { - const result = await getRandomQuestionService(payload); - return res.status(result.code).json(result); - } catch (error) { - console.log('error', error); - - return res - .status(StatusCodes.INTERNAL_SERVER_ERROR) - .json({ success: false, message: 'An error occurred', error }); - } -}; - export const searchQuestionsByTitle = async (req: Request, res: Response): Promise => { const { title } = req.query; const page = parseInt(req.query.page as string) || 1; diff --git a/backend/question/src/controller/unattempted-controller.ts b/backend/question/src/controller/unattempted-controller.ts new file mode 100644 index 0000000000..47743ef9e8 --- /dev/null +++ b/backend/question/src/controller/unattempted-controller.ts @@ -0,0 +1,60 @@ +// src/controllers/questionsController.ts +// src/controllers/unattempted-controller.ts +import { Request, Response } from 'express'; + +import { isValidUUID } from '@/lib/uuid'; + +import { getRandomQuestion } from '../services/get/get-random-question'; + +// Define types for query parameters +interface UnattemptedQuestionQuery { + userId1: string; + userId2: string; + topics?: string | Array; + difficulty?: string; +} + +export const fetchRandomQuestionByIncreasingAttemptCount = async ( + req: Request, unknown>, + res: Response +) => { + const { userId1, userId2, topics: payloadTopics, difficulty } = req.body; + + if (userId1 === undefined || !isValidUUID(userId1)) { + return res.status(400).json({ error: 'Invalid or missing userId1. It must be a valid id.' }); + } + + if (!userId2 || !isValidUUID(userId2)) { + return res.status(400).json({ error: 'Invalid userId2. It must be a valid id if provided.' }); + } + + // Ensure topics is an array of strings + const topics = + typeof payloadTopics === 'string' + ? payloadTopics.split(',') + : Array.isArray(payloadTopics) + ? payloadTopics.filter((topic) => !!topic) + : undefined; + + try { + const question = await getRandomQuestion({ + userId1, + userId2, + topics, + difficulty, + }); + + if (question) { + res.json(question); + return; + } + + res.status(404).json({ message: 'No unattempted questions found' }); + return; + } catch (error) { + console.error('Error fetching unattempted question:', error); // Log the actual error + res + .status(500) + .json({ error: 'Error fetching unattempted question', details: (error as any).message }); + } +}; diff --git a/backend/question/src/lib/db/sample-data/questions.ts b/backend/question/src/lib/db/sample-data/questions.ts index cbf6f7207d..8816221270 100644 --- a/backend/question/src/lib/db/sample-data/questions.ts +++ b/backend/question/src/lib/db/sample-data/questions.ts @@ -180,11 +180,11 @@ export const questionDetails = [ interface Question { title: string; difficulty: string; - topic: string[]; + topic: Array; description: string; } -export const questionData: Question[] = questionDetails.map((question) => ({ +export const questionData: Array = questionDetails.map((question) => ({ title: question.title, description: question.description, difficulty: question.difficulty as 'Easy' | 'Medium' | 'Hard', diff --git a/backend/question/src/lib/db/schema.ts b/backend/question/src/lib/db/schema.ts index 2afed759fb..2ac90ef882 100644 --- a/backend/question/src/lib/db/schema.ts +++ b/backend/question/src/lib/db/schema.ts @@ -1,4 +1,14 @@ -import { pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; +import { + integer, + pgEnum, + pgTable, + serial, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; export const questions = pgTable('questions', { id: serial('id').primaryKey(), @@ -10,6 +20,26 @@ export const questions = pgTable('questions', { updatedAt: timestamp('updated_at', { precision: 6, withTimezone: true }).defaultNow(), }); +export const questionAttempts = pgTable( + 'question_attempts', + { + attemptId: serial('attempt_id').primaryKey(), + questionId: integer('question_id').notNull(), + userId1: uuid('user_id_1').notNull(), + userId2: uuid('user_id_2'), // Nullable if only one user is involved + code: text('code').notNull(), + timestamp: timestamp('timestamp', { precision: 6, withTimezone: true }).defaultNow(), + language: varchar('language', { length: 50 }).notNull(), + }, + (questionAttempt) => ({ + uniqueUsersAttempt: uniqueIndex('unique_users_attempt').on( + questionAttempt.questionId, + questionAttempt.userId1, + questionAttempt.userId2 + ), + }) +); + export const actionEnum = pgEnum('action', ['SEED']); export const admin = pgTable('admin', { diff --git a/backend/question/src/lib/uuid.ts b/backend/question/src/lib/uuid.ts new file mode 100644 index 0000000000..4d4a6b3b03 --- /dev/null +++ b/backend/question/src/lib/uuid.ts @@ -0,0 +1,5 @@ +import { validate } from 'uuid'; + +export const isValidUUID = (uuid: string) => { + return validate(uuid); +}; diff --git a/backend/question/src/routes/question.ts b/backend/question/src/routes/question.ts index 356739cfed..a87e05ff94 100644 --- a/backend/question/src/routes/question.ts +++ b/backend/question/src/routes/question.ts @@ -1,16 +1,17 @@ import { Router } from 'express'; +import { createAttempt, getAttempts } from '@/controller/attempted-controller'; import { createQuestion, deleteQuestion, getDifficulties, getQuestionDetails, getQuestions, - getRandomQuestion, getTopics, searchQuestionsByTitle, updateQuestion, } from '@/controller/question-controller'; +import { fetchRandomQuestionByIncreasingAttemptCount } from '@/controller/unattempted-controller'; const router = Router(); @@ -23,8 +24,14 @@ router.get('/', getQuestions); router.get('/:questionId', getQuestionDetails); -router.post('/random', getRandomQuestion); +router.post('/random', fetchRandomQuestionByIncreasingAttemptCount); +router.post('/attempts', getAttempts); +router.post('/newAttempt', createAttempt); + +// ====================================== +// CRUD +// ====================================== router.post('/create', createQuestion); router.put('/:questionId', updateQuestion); router.delete('/:questionId', deleteQuestion); diff --git a/backend/question/src/services/get/get-attempts.ts b/backend/question/src/services/get/get-attempts.ts new file mode 100644 index 0000000000..1ee4d35bf1 --- /dev/null +++ b/backend/question/src/services/get/get-attempts.ts @@ -0,0 +1,29 @@ +import { and, desc, eq, or } from 'drizzle-orm'; + +import { db, questionAttempts as QUESTION_ATTEMPTS_TABLE } from '@/lib/db'; + +type Params = { + questionId: number; + userId: string; + limit?: number; + offset?: number; +}; + +export const getQuestionAttempts = async ({ questionId, userId, limit = 10, offset }: Params) => { + if (limit < 1) { + limit = 1; + } + + const userIdFilters = [ + eq(QUESTION_ATTEMPTS_TABLE.userId1, userId), + eq(QUESTION_ATTEMPTS_TABLE.userId2, userId), + ]; + const filterClauses = [eq(QUESTION_ATTEMPTS_TABLE.questionId, questionId), or(...userIdFilters)]; + return await db + .select() + .from(QUESTION_ATTEMPTS_TABLE) + .where(and(...filterClauses)) + .orderBy(desc(QUESTION_ATTEMPTS_TABLE.timestamp)) + .offset(offset ?? 0) + .limit(limit); +}; diff --git a/backend/question/src/services/get/get-random-question.ts b/backend/question/src/services/get/get-random-question.ts new file mode 100644 index 0000000000..2cb5f62ba4 --- /dev/null +++ b/backend/question/src/services/get/get-random-question.ts @@ -0,0 +1,164 @@ +import { + and, + arrayOverlaps, + asc, + eq, + getTableColumns, + inArray, + isNull, + or, + sql, +} from 'drizzle-orm'; + +import { db } from '@/lib/db/index'; +import { + questionAttempts as QUESTION_ATTEMPTS_TABLE, + questions as QUESTIONS_TABLE, +} from '@/lib/db/schema'; +import { logger } from '@/lib/utils'; + +/** + * Both userIds specified (they are matches after all) + * 1.1. Both Unattempted + * + * SELECT q.* + * FROM + * questions q + * LEFT JOIN + * question_attempts qa + * ON + * q.id = qa.question_id + * AND ( + * qa.user_id_1 IN (userId1, userId2) + * OR qa.user_id_2 IN (userId1, userId2) + * ) + * WHERE + * qa.question_id IS NULL + * AND q.topic && topic + * AND q.difficulty = difficulty + * ORDER BY RANDOM() + * LIMIT 1; + * + * 1.2. + * - Get topic/difficulty for both + * - Pick one with least attempts + * WITH "at" AS ( + * SELECT + * q.*, + * SUM( + * CASE WHEN + * qa.user_id_1 = $userId1 + * OR qa.user_id_2 = $userId1 THEN 1 END + * ) AS user1_attempts, + * SUM( + * CASE WHEN + * qa.user_id_1 = $userId2 + * OR qa.user_id_2 = $userId2 THEN 1 END + * ) AS user2_attempts + * FROM + * questions q + * JOIN question_attempts qa ON q.id = qa.question_id + * AND ( + * qa.user_id_1 IN ($userId1, $userId2) + * OR qa.user_id_2 IN ($userId1, $userId2) + * ) + * WHERE + * q.topic::text[] && $topic + * AND q.difficulty = $difficulty + * GROUP BY + * q.id + * ) + * SELECT + * * + * FROM + * "at" + * ORDER BY + * ( + * COALESCE("at".user1_attempts, 0) + COALESCE("at".user2_attempts, 0) + * ) ASC + * LIMIT 1 + */ + +type Params = { + userId1: string; + userId2: string; + topics?: Array; + difficulty?: string; +}; + +// Fetch an unattempted question or fallback to the least attempted one +export const getRandomQuestion = async ({ userId1, userId2, topics, difficulty }: Params) => { + /** + * 1. Both Unattempted + */ + // If an attempt contains either user's ID + const ids = [userId1, userId2]; + const userIdClause = [ + inArray(QUESTION_ATTEMPTS_TABLE.userId1, ids), + inArray(QUESTION_ATTEMPTS_TABLE.userId2, ids), + ]; + // Join both tables on qId equality, filtering only rows with either user's ID + const joinClause = [ + eq(QUESTIONS_TABLE.id, QUESTION_ATTEMPTS_TABLE.questionId), + or(...userIdClause), + ]; + + // Build the filter clause + // - attempt ID null: No attempts + // - topics: If specified, must intersect using Array Intersect + const filterClause = []; + + if (topics) { + filterClause.push(arrayOverlaps(QUESTIONS_TABLE.topic, topics)); + } + + if (difficulty) { + filterClause.push(eq(QUESTIONS_TABLE.difficulty, difficulty)); + } + + const bothUnattempted = await db + .select({ question: QUESTIONS_TABLE }) + .from(QUESTIONS_TABLE) + .leftJoin(QUESTION_ATTEMPTS_TABLE, and(...joinClause)) + .where(and(isNull(QUESTION_ATTEMPTS_TABLE.attemptId), ...filterClause)) + .orderBy(sql`RANDOM()`) + .limit(1); + + if (bothUnattempted && bothUnattempted.length > 0) { + return bothUnattempted[0].question; + } + + // 2. At least one user has attempted. + // - Fetch all questions, summing attempts by both users, ranking and selecting the lowest count. + const attempts = db.$with('at').as( + db + .select({ + ...getTableColumns(QUESTIONS_TABLE), + user1Count: + sql`SUM(CASE WHEN ${QUESTION_ATTEMPTS_TABLE.userId1} = ${userId1}::uuid OR ${QUESTION_ATTEMPTS_TABLE.userId2} = ${userId1}::uuid THEN 1 END)`.as( + 'user1_attempts' + ), + user2Count: + sql`SUM(CASE WHEN ${QUESTION_ATTEMPTS_TABLE.userId1} = ${userId2}::uuid OR ${QUESTION_ATTEMPTS_TABLE.userId2} = ${userId2}::uuid THEN 1 END)`.as( + 'user2_attempts' + ), + }) + .from(QUESTIONS_TABLE) + .innerJoin(QUESTION_ATTEMPTS_TABLE, and(...joinClause)) + .where(and(...filterClause)) + .groupBy(QUESTIONS_TABLE.id) + ); + const result = await db + .with(attempts) + .select() + .from(attempts) + .orderBy(asc(sql`COALESCE(user1_attempts,0) + COALESCE(user2_attempts,0)`)) + .limit(1); + + if (result && result.length > 0) { + return { ...result[0], user1Count: undefined, user2Count: undefined }; + } + + // This branch should not be reached + logger.info('Unreachable Branch - If first query fails, second query must return something'); +}; diff --git a/backend/question/src/services/get/index.ts b/backend/question/src/services/get/index.ts index 97661fef5c..72534f3303 100644 --- a/backend/question/src/services/get/index.ts +++ b/backend/question/src/services/get/index.ts @@ -1,8 +1,8 @@ -import { and, arrayOverlaps, eq, ilike, inArray, not, sql } from 'drizzle-orm'; +import { and, arrayOverlaps, eq, getTableColumns, ilike, or, sql } from 'drizzle-orm'; import { StatusCodes } from 'http-status-codes'; import { db } from '@/lib/db/index'; -import { questions } from '@/lib/db/schema'; +import { questionAttempts, questions } from '@/lib/db/schema'; import type { IGetDifficultiesResponse, @@ -10,15 +10,13 @@ import type { IGetQuestionResponse, IGetQuestionsPayload, IGetQuestionsResponse, - IGetRandomQuestionPayload, - IGetRandomQuestionResponse, IGetTopicsResponse, } from './types'; export const getQuestionsService = async ( payload: IGetQuestionsPayload ): Promise => { - const { questionName, difficulty, topic, pageNum = 0, recordsPerPage = 20 } = payload; + const { questionName, difficulty, topic, pageNum = 0, recordsPerPage = 20, userId } = payload; const offset = pageNum * recordsPerPage; const whereClause = []; @@ -36,9 +34,20 @@ export const getQuestionsService = async ( } const query = db - .select() + .select({ + ...getTableColumns(questions), + attempted: sql`COALESCE(COUNT(${questionAttempts.attemptId}), 0)`.as('attempted'), + }) .from(questions) + .leftJoin( + questionAttempts, + and( + eq(questionAttempts.questionId, questions.id), + or(eq(questionAttempts.userId1, userId), eq(questionAttempts.userId2, userId)) + ) + ) .where(and(...whereClause)) + .groupBy(questions.id) .limit(recordsPerPage) .offset(offset) .orderBy(questions.id); @@ -60,6 +69,7 @@ export const getQuestionsService = async ( title: q.title, difficulty: q.difficulty, topic: q.topic, + attempted: (q.attempted as number) > 0, })), totalQuestions: totalCount, }, @@ -93,65 +103,6 @@ export const getQuestionDetailsService = async ( }; }; -export const getRandomQuestionService = async ( - payload: IGetRandomQuestionPayload -): Promise => { - const { difficulty, topic, attemptedQuestions } = payload; - const whereClause = []; - - console.log('Starting query construction'); - - if (difficulty) { - console.log(`Adding difficulty filter: ${difficulty}`); - whereClause.push(eq(questions.difficulty, difficulty)); - } - - const topicArray = (Array.isArray(topic) ? topic : [topic]).filter( - (t): t is string => t !== undefined - ); - - if (topicArray.length > 0) { - whereClause.push(arrayOverlaps(questions.topic, topicArray)); - } - - if (attemptedQuestions && attemptedQuestions.length > 0) { - console.log(`Excluding attempted questions: ${attemptedQuestions.join(', ')}`); - whereClause.push(not(inArray(questions.id, attemptedQuestions))); - } - - console.log(`Where clause conditions: ${whereClause.length}`); - - let query = db.select().from(questions); - - if (whereClause.length > 0) { - query = query.where(and(...whereClause)) as typeof query; - } - - query = (query as any).orderBy(sql`RANDOM()`).limit(1); - - console.log('Executing query'); - console.log(query.toSQL()); // This will log the SQL query - - const result = await query; - - console.log(`Query result: ${JSON.stringify(result)}`); - - if (result.length === 0) { - return { - code: StatusCodes.NOT_FOUND, - data: { question: null }, - error: { - message: 'No matching questions found', - }, - }; - } - - return { - code: StatusCodes.OK, - data: { question: result[0] }, - }; -}; - export const searchQuestionsByTitleService = async ( title: string, page: number, @@ -228,8 +179,13 @@ export const getDifficultiesService = async (): Promise result.difficulty); - + const uniqueDifficulties = results + .map((result) => result.difficulty) + .sort((a, b) => { + if (a === 'Hard' || b === 'Easy') return 1; + if (b === 'Hard' || a === 'Easy') return -1; + return 0; + }); return { code: StatusCodes.OK, data: { diff --git a/backend/question/src/services/get/types.ts b/backend/question/src/services/get/types.ts index 4842f5ac86..67c920843b 100644 --- a/backend/question/src/services/get/types.ts +++ b/backend/question/src/services/get/types.ts @@ -5,6 +5,7 @@ import type { IServiceResponse } from '@/types'; //============================================================================= export type IGetQuestionsPayload = { // Filters + userId: string; questionName?: string; difficulty?: string; topic?: Array; @@ -51,18 +52,3 @@ export type IGetQuestionResponse = IServiceResponse<{ //============================================================================= // /random (For matching) //============================================================================= -export type IGetRandomQuestionPayload = { - attemptedQuestions?: number[]; - difficulty?: string; - topic?: string[]; -}; - -export type IGetRandomQuestionResponse = IServiceResponse<{ - question: { - id: number; // question's unique identifier or number - title: string; // name or title of the question - description: string; // question description - difficulty: string; // difficulty level (e.g., 'easy', 'medium', 'hard') - topic: Array; // array of topics the question belongs to - } | null; -}>; diff --git a/backend/question/src/services/post/addAttempt.ts b/backend/question/src/services/post/addAttempt.ts new file mode 100644 index 0000000000..9cfd2d802f --- /dev/null +++ b/backend/question/src/services/post/addAttempt.ts @@ -0,0 +1,22 @@ +import { db } from '@/lib/db/index'; +import { questionAttempts } from '@/lib/db/schema'; + +// Define the data structure for an attempt +interface AttemptData { + questionId: number; + userId1: string; + userId2?: string; + code: string; + language: string; +} + +// Function to add an attempt to the database +export const addAttempt = async (attemptData: AttemptData) => { + return await db.insert(questionAttempts).values({ + questionId: attemptData.questionId, + userId1: attemptData.userId1, + userId2: attemptData.userId2, + code: attemptData.code, + language: attemptData.language, + }); +}; diff --git a/backend/question/src/services/post/types.ts b/backend/question/src/services/post/types.ts index f811deda9c..2ab4bfd4d6 100644 --- a/backend/question/src/services/post/types.ts +++ b/backend/question/src/services/post/types.ts @@ -2,7 +2,7 @@ export interface ICreateQuestionPayload { title: string; description: string; difficulty: string; - topics: string[]; + topics: Array; } export interface IUpdateQuestionPayload extends ICreateQuestionPayload { @@ -12,3 +12,13 @@ export interface IUpdateQuestionPayload extends ICreateQuestionPayload { export interface IDeleteQuestionPayload { id: number; } + +export interface Question { + id: number; + title: string; + difficulty: string; + topic: Array; + description: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/backend/user/src/controllers/auth-check/index.ts b/backend/user/src/controllers/auth-check/index.ts index 9ea3e94211..3b7354d43d 100644 --- a/backend/user/src/controllers/auth-check/index.ts +++ b/backend/user/src/controllers/auth-check/index.ts @@ -1,6 +1,8 @@ +import { eq } from 'drizzle-orm'; import { StatusCodes } from 'http-status-codes'; import { COOKIE_NAME, decodeCookie, isCookieValid } from '@/lib/cookies'; +import { db, users } from '@/lib/db'; import { logger } from '@/lib/utils'; import { IRouteHandler } from '@/types'; @@ -13,9 +15,16 @@ export const checkIsAuthed: IRouteHandler = async (req, res) => { logger.info( '[/auth-check/check-is-authed]: Expires At ' + new Date(expireTimeInMillis).toLocaleString() ); + const user = await db + .select({ name: users.username }) + .from(users) + .where(eq(users.id, decoded.id)) + .limit(1); return res.status(StatusCodes.OK).json({ message: 'OK', expiresAt: expireTimeInMillis, + userId: decoded.id, + username: user.length > 0 ? user[0].name : undefined, }); } diff --git a/backend/user/src/services/questions/index.ts b/backend/user/src/services/questions/index.ts index e3950f1598..4086d23d06 100644 --- a/backend/user/src/services/questions/index.ts +++ b/backend/user/src/services/questions/index.ts @@ -6,7 +6,7 @@ import { users } from '@/lib/db/schema'; interface IGetAttemptedQuestionsResponse { code: StatusCodes; - data?: number[]; + data?: Array; error?: Error; } @@ -17,7 +17,7 @@ interface IAddAttemptedQuestionResponse { } export const addAttemptedQuestionService = async ( - userIds: string[], + userIds: Array, questionId: number ): Promise => { try { diff --git a/frontend/package.json b/frontend/package.json index 1f2b131e72..9e790a43d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", @@ -47,6 +48,7 @@ "react-katex": "^3.0.1", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.2", + "react-syntax-highlighter": "^15.6.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", @@ -67,6 +69,7 @@ "@types/node": "^22.5.5", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-syntax-highlighter": "^15.5.13", "@types/ws": "^8.5.12", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", diff --git a/frontend/src/assets/questions.ts b/frontend/src/assets/questions.ts index 97fe519b7f..c6702617a2 100644 --- a/frontend/src/assets/questions.ts +++ b/frontend/src/assets/questions.ts @@ -179,7 +179,7 @@ export const questionDetails = [ }, ]; -export const questions: Question[] = questionDetails.map((question) => ({ +export const questions: Array = questionDetails.map((question) => ({ id: parseInt(question.id), title: question.title, difficulty: question.difficulty as 'Easy' | 'Medium' | 'Hard', diff --git a/frontend/src/components/blocks/interview/ai-chat.tsx b/frontend/src/components/blocks/interview/ai-chat.tsx index 684ff7b14b..de0c954598 100644 --- a/frontend/src/components/blocks/interview/ai-chat.tsx +++ b/frontend/src/components/blocks/interview/ai-chat.tsx @@ -18,7 +18,7 @@ const API_URL = 'https://api.openai.com/v1/chat/completions'; const API_KEY = process.env.OPENAI_API_KEY; export const AIChat: React.FC = ({ isOpen, onClose }) => { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); diff --git a/frontend/src/components/blocks/interview/chat/chat-layout.tsx b/frontend/src/components/blocks/interview/chat/chat-layout.tsx index e58116894c..70ad247188 100644 --- a/frontend/src/components/blocks/interview/chat/chat-layout.tsx +++ b/frontend/src/components/blocks/interview/chat/chat-layout.tsx @@ -11,7 +11,7 @@ import { ChatMessage, ChatMessageType } from './chat-message'; interface ChatLayoutProps { isOpen: boolean; onClose: () => void; - messages: ChatMessageType[]; + messages: Array; onSend: (message: string) => void; isLoading: boolean; error: string | null; diff --git a/frontend/src/components/blocks/interview/constants.ts b/frontend/src/components/blocks/interview/constants.ts new file mode 100644 index 0000000000..19b2f72ccb --- /dev/null +++ b/frontend/src/components/blocks/interview/constants.ts @@ -0,0 +1,6 @@ +export const COMPLETION_STATES = { + PENDING: 'pending', + SUCCESS: 'success', + ERROR: 'error', + EMPTY: '', +}; diff --git a/frontend/src/components/blocks/interview/editor.tsx b/frontend/src/components/blocks/interview/editor.tsx index bd6d863d41..c228d66ca7 100644 --- a/frontend/src/components/blocks/interview/editor.tsx +++ b/frontend/src/components/blocks/interview/editor.tsx @@ -1,9 +1,8 @@ -import { ChevronLeftIcon } from '@radix-ui/react-icons'; import { useWindowSize } from '@uidotdev/usehooks'; import type { LanguageName } from '@uiw/codemirror-extensions-langs'; import CodeMirror from '@uiw/react-codemirror'; import { Bot, User } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -17,17 +16,23 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { getTheme, type IEditorTheme, languages, themeOptions } from '@/lib/editor/extensions'; import { useCollab } from '@/lib/hooks/use-collab'; +import { useAuthedRoute } from '@/stores/auth-store'; + +import { CompleteDialog } from './room/complete-dialog'; +import { OtherUserCompletingDialog } from './room/other-user-completing-dialog'; const EXTENSION_HEIGHT = 250; const MIN_EDITOR_HEIGHT = 350; type EditorProps = { + questionId: number; room: string; onAIClick: () => void; onPartnerClick: () => void; }; -export const Editor = ({ room, onAIClick, onPartnerClick }: EditorProps) => { +export const Editor = ({ questionId, room, onAIClick, onPartnerClick }: EditorProps) => { + const { userId } = useAuthedRoute(); const { height } = useWindowSize(); const [theme, setTheme] = useState('vscodeDark'); const { @@ -37,6 +42,8 @@ export const Editor = ({ room, onAIClick, onPartnerClick }: EditorProps) => { setLanguage, code, setCode, + isCompleting, + setIsCompleting, cursorPosition, members, isLoading, @@ -45,8 +52,19 @@ export const Editor = ({ room, onAIClick, onPartnerClick }: EditorProps) => { return getTheme(theme); }, [theme]); + const [completionState, setCompletionState] = useState(''); + + useEffect(() => { + if (isCompleting.userId !== userId && isCompleting.state) { + setCompletionState(isCompleting.state); + } else { + setCompletionState(''); + } + }, [isCompleting]); + return (
+ {completionState && } {isLoading ? (
@@ -93,42 +111,57 @@ export const Editor = ({ room, onAIClick, onPartnerClick }: EditorProps) => {
-
- {/* TODO: Get user avatar and display */} - {members.map((member, index) => ( -
- {member.userId} -
- ))} +
+
+ {/* TODO: Get user avatar and display */} + {members.map((member, index) => ( +
+ {member.userId} +
+ ))} +
- - - + + +
+ + +
)} diff --git a/frontend/src/components/blocks/interview/partner-chat.tsx b/frontend/src/components/blocks/interview/partner-chat.tsx index f57c294d1e..1d63f0f084 100644 --- a/frontend/src/components/blocks/interview/partner-chat.tsx +++ b/frontend/src/components/blocks/interview/partner-chat.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useChat } from '@/lib/hooks/use-chat'; -import { getUserId } from '@/services/user-service'; +import { useAuthedRoute } from '@/stores/auth-store'; import { ChatLayout } from './chat/chat-layout'; @@ -12,6 +12,7 @@ interface PartnerChatProps { } export const PartnerChat: React.FC = ({ isOpen, onClose, roomId }) => { + const { userId } = useAuthedRoute(); const { messages, sendMessage, connected, error } = useChat({ roomId }); const [isLoading, setIsLoading] = useState(false); @@ -27,7 +28,7 @@ export const PartnerChat: React.FC = ({ isOpen, onClose, roomI onClose={onClose} messages={messages.map((msg) => ({ text: msg.message, - isUser: msg.senderId === getUserId(), + isUser: msg.senderId === userId, timestamp: new Date(msg.createdAt), }))} onSend={handleSend} diff --git a/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx b/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx new file mode 100644 index 0000000000..ffbe70a7ce --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx @@ -0,0 +1,58 @@ +import { Check, Copy } from 'lucide-react'; +import { FC, useState } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import { Button } from '@/components/ui/button'; + +type ICodeProps = { + code: string; + language: string; +}; + +const defaultCopyCodeText = 'Copy Code'; + +export const CodeViewer: FC = ({ code, language }) => { + const [copyCodeText, setCopyCodeText] = useState('Copy Code'); + + const onCopy = () => { + navigator.clipboard.writeText(code); + setCopyCodeText('Copied!'); + setTimeout(() => { + setCopyCodeText(defaultCopyCodeText); + }, 3000); + }; + + return ( +
+
+ {language} + +
+ + {code} + +
+ ); +}; diff --git a/frontend/src/components/blocks/interview/question-attempts/attempt-details/index.ts b/frontend/src/components/blocks/interview/question-attempts/attempt-details/index.ts new file mode 100644 index 0000000000..aad1ca831e --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/attempt-details/index.ts @@ -0,0 +1 @@ +export * from './main'; diff --git a/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx b/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx new file mode 100644 index 0000000000..09d0ce9536 --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx @@ -0,0 +1,49 @@ +import type { FC, PropsWithChildren } from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { IQuestionAttempt } from '@/types/question-types'; + +import { CodeViewer } from './code-viewer'; + +type AttemptDetailsPaneProps = { + triggerText: string; +} & IQuestionAttempt; + +export const AttemptDetailsDialog: FC> = ({ + children, + triggerText, + code, + language, + attemptId, +}) => { + return ( + + {children ? ( + {children} + ) : ( + {triggerText} + )} + + + + Attempt {attemptId} + + + + + + + Attempted at: {triggerText} + + + + ); +}; diff --git a/frontend/src/components/blocks/interview/question-attempts/columns.tsx b/frontend/src/components/blocks/interview/question-attempts/columns.tsx new file mode 100644 index 0000000000..3c749721ce --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/columns.tsx @@ -0,0 +1,53 @@ +import { ColumnDef } from '@tanstack/react-table'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { DataTableSortableHeader } from '@/components/ui/data-table'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { IQuestionAttempt } from '@/types/question-types'; + +import { AttemptDetailsDialog } from './attempt-details'; + +export const columns: Array> = [ + { + accessorKey: 'attemptId', + header: 'ID', + }, + { + accessorKey: 'timestamp', + header: ({ column }) => ( + + ), + cell({ row }) { + const attemptedTime = row.getValue('timestamp') as string; + const label = new Date(attemptedTime).toLocaleString(); + return ( + + + + + + + View Details + + + + ); + }, + }, + { + accessorKey: 'language', + header: ({ column }) => ( + + ), + cell({ row }) { + return ( +
+ + {row.getValue('language')} + +
+ ); + }, + }, +]; diff --git a/frontend/src/components/blocks/interview/question-attempts/index.ts b/frontend/src/components/blocks/interview/question-attempts/index.ts new file mode 100644 index 0000000000..aad1ca831e --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/index.ts @@ -0,0 +1 @@ +export * from './main'; diff --git a/frontend/src/components/blocks/interview/question-attempts/main.tsx b/frontend/src/components/blocks/interview/question-attempts/main.tsx new file mode 100644 index 0000000000..05d56ed6c4 --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/main.tsx @@ -0,0 +1,52 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import React, { useEffect, useMemo } from 'react'; + +import { CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { getQuestionAttempts } from '@/services/question-service'; +import { useAuthedRoute } from '@/stores/auth-store'; +import { IPostGetQuestionAttemptsResponse } from '@/types/question-types'; + +import { columns } from './columns'; +import { QuestionAttemptsTable } from './table'; + +type QuestionAttemptsProps = { + questionId: number; + pageSize?: number; + className?: string; +}; + +export const QuestionAttemptsPane: React.FC = ({ + questionId, + pageSize, + className, +}) => { + const { userId } = useAuthedRoute(); + const { data, hasNextPage, isFetchingNextPage, fetchNextPage, isError } = useInfiniteQuery({ + queryKey: ['question', 'attempts', questionId, userId], + queryFn: ({ pageParam }) => + getQuestionAttempts({ questionId, userId, ...(pageParam ? { offset: pageParam } : {}) }), + getNextPageParam: (lastPage, pages) => { + return lastPage.length > 0 ? pages.length * 10 : undefined; + }, + initialPageParam: 0, + }); + useEffect(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + const attempts = useMemo(() => { + return data?.pages.flatMap((v) => v as IPostGetQuestionAttemptsResponse) ?? []; + }, [data]); + return ( + + + + ); +}; diff --git a/frontend/src/components/blocks/interview/question-attempts/table.tsx b/frontend/src/components/blocks/interview/question-attempts/table.tsx new file mode 100644 index 0000000000..db2cf889f9 --- /dev/null +++ b/frontend/src/components/blocks/interview/question-attempts/table.tsx @@ -0,0 +1,207 @@ +import { + ArrowLeftIcon, + ArrowRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from '@radix-ui/react-icons'; +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from '@tanstack/react-table'; +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { ComboboxMulti } from '@/components/ui/combobox'; +import { Pagination, PaginationContent, PaginationItem } from '@/components/ui/pagination'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { IQuestionAttempt } from '@/types/question-types'; + +interface QuestionTableProps { + columns: Array>; + data: Array; + isError: boolean; + pageSize?: number; +} + +export function QuestionAttemptsTable({ + columns, + data, + isError, + pageSize, +}: QuestionTableProps) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: pageSize ?? 10, + }); + + const [columnFilters, setColumnFilters] = useState([]); + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { pagination, columnFilters, sorting }, + filterFns: {}, + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getCoreRowModel: getCoreRowModel(), + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + }); + + const setLanguages = (languages: Array) => { + setColumnFilters((columnFilters) => [ + ...columnFilters.filter((v) => v.id !== 'language'), + ...languages.map((v) => ({ id: 'language', value: v })), + ]); + }; + + return ( +
+
+
+ + v.language))).map((v) => ({ + value: v, + label: v, + }))} + placeholderText='Select a language filter' + noOptionsText='None of the available languages match your search' + /> +
+ {/* table.getColumn('title')?.setFilterValue(event.target.value)} + className='max-w-sm' + /> */} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + +
+
+ + + + {!isError && table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + + + + + + + + + + + + + {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/blocks/interview/room/complete-dialog.tsx b/frontend/src/components/blocks/interview/room/complete-dialog.tsx new file mode 100644 index 0000000000..331f84bcb3 --- /dev/null +++ b/frontend/src/components/blocks/interview/room/complete-dialog.tsx @@ -0,0 +1,110 @@ +import { useMutation } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { Dispatch, FC, PropsWithChildren, SetStateAction, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from '@/components/ui/dialog'; +import { addQuestionAttempt } from '@/services/question-service'; +import { IYjsUserState } from '@/types/collab-types'; + +import { COMPLETION_STATES } from '../constants'; + +type CompleteDialogProps = { + userId: string; + questionId: number; + code: string; + language: string; + members: Array; + setCode: Dispatch>; + setCompleting: (state: string, resetId?: boolean) => void; +}; + +export const CompleteDialog: FC> = ({ + children, + setCompleting, + questionId, + userId, + code, + setCode, + language, + members, +}) => { + const navigate = useNavigate(); + + const [isOpen, _setIsOpen] = useState(false); + const setIsOpen = useCallback( + (openState: boolean) => { + _setIsOpen(openState); + + if (openState) { + setCompleting('pending'); + } else { + setCompleting('', true); + } + }, + [isOpen] + ); + + const { mutate: sendCompleteRequest, isPending } = useMutation({ + mutationFn: async () => { + return await addQuestionAttempt({ + questionId, + code, + language, + userId1: userId, + userId2: + members.length < 2 ? undefined : members.filter((v) => v.userId !== userId)[0].userId, + }); + }, + onSuccess: () => { + setCode(''); + setCompleting(COMPLETION_STATES.SUCCESS); + // Navigate to home page + setTimeout(() => { + setCompleting(COMPLETION_STATES.EMPTY, true); + navigate('/'); + }, 200); + }, + onError: () => { + setCompleting(COMPLETION_STATES.ERROR); + }, + }); + + return ( + + {children} + + + Are you sure you wish to mark this question as complete? + + +
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/components/blocks/interview/room/other-user-completing-dialog.tsx b/frontend/src/components/blocks/interview/room/other-user-completing-dialog.tsx new file mode 100644 index 0000000000..eacf716d64 --- /dev/null +++ b/frontend/src/components/blocks/interview/room/other-user-completing-dialog.tsx @@ -0,0 +1,39 @@ +import { FC, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog'; + +import { COMPLETION_STATES } from '../constants'; + +type OtherUserCompletingDialogProps = { + status: string; +}; + +export const OtherUserCompletingDialog: FC = ({ status }) => { + const navigate = useNavigate(); + + useEffect(() => { + if (status === COMPLETION_STATES.SUCCESS) { + setTimeout(() => { + navigate('/'); + }, 200); + } + }, [status]); + + return ( + + + + {status === COMPLETION_STATES.PENDING + ? 'The other user is marking this question attempt as complete. Please wait...' + : status === COMPLETION_STATES.SUCCESS + ? 'Question marked as completed. Navigating to home page...' + : 'An Error occurred.'} + + {status !== COMPLETION_STATES.ERROR && ( // Block exit if not error +
+ )} + +
+ ); +}; diff --git a/frontend/src/components/blocks/route-guard.tsx b/frontend/src/components/blocks/route-guard.tsx index 9e2f817820..e860f017a3 100644 --- a/frontend/src/components/blocks/route-guard.tsx +++ b/frontend/src/components/blocks/route-guard.tsx @@ -9,9 +9,9 @@ import { useNavigate, } from 'react-router-dom'; -import { usePageTitle } from '@/lib/hooks/use-page-title'; import { ROUTES, UNAUTHED_ROUTES } from '@/lib/routes'; import { checkIsAuthed } from '@/services/user-service'; +import { AuthStoreProvider } from '@/stores/auth-store'; import { Loading } from './loading'; @@ -47,7 +47,7 @@ export const RouteGuard = () => { return ( }> - {({ authedPayload, isAuthedRoute, path }) => { + {({ authedPayload, isAuthedRoute, path: _p }) => { const [isLoading, setIsLoading] = useState(true); useEffect(() => { if (authedPayload.isAuthed !== isAuthedRoute) { @@ -55,9 +55,17 @@ export const RouteGuard = () => { } setIsLoading(false); - }, []); - usePageTitle(path); - return isLoading ? : ; + }, [authedPayload]); + return ( + + {isLoading ? : } + + ); }} diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx new file mode 100644 index 0000000000..a5ad6c4037 --- /dev/null +++ b/frontend/src/components/ui/combobox.tsx @@ -0,0 +1,102 @@ +'use client'; + +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +type Option = { + label: string; + value: string; +}; + +type ComboboxProps = { + options: Array