diff --git a/backend/collaboration/drizzle.config.ts b/backend/collaboration/drizzle.config.ts new file mode 100644 index 0000000000..b95650e9d9 --- /dev/null +++ b/backend/collaboration/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'drizzle-kit'; + +const config = { + host: process.env.EXPRESS_DB_HOST!, + port: Number.parseInt(process.env.EXPRESS_DB_PORT!), + database: process.env.POSTGRES_DB!, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, +}; + +export default defineConfig({ + schema: './src/lib/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: config, +}); diff --git a/backend/collaboration/drizzle/0000_initial_schema.sql b/backend/collaboration/drizzle/0000_initial_schema.sql new file mode 100644 index 0000000000..b9726967b1 --- /dev/null +++ b/backend/collaboration/drizzle/0000_initial_schema.sql @@ -0,0 +1,14 @@ +CREATE TYPE "public"."action" AS ENUM('SEED');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "admin" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp DEFAULT now(), + "action" "action" NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "rooms" ( + "room_id" varchar(255) PRIMARY KEY NOT NULL, + "user_id_1" uuid NOT NULL, + "user_id_2" uuid NOT NULL, + "question_id" serial NOT NULL, + "created_at" timestamp DEFAULT now() +); diff --git a/backend/collaboration/drizzle/meta/0000_snapshot.json b/backend/collaboration/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000000..73323e3074 --- /dev/null +++ b/backend/collaboration/drizzle/meta/0000_snapshot.json @@ -0,0 +1,105 @@ +{ + "id": "0fa8a8f0-f2e1-432e-890f-4a1da22f1a18", + "prevId": "00000000-0000-0000-0000-000000000000", + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "room_id": { + "name": "room_id", + "type": "varchar(255)", + "primaryKey": true, + "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": true + }, + "question_id": { + "name": "question_id", + "type": "serial", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.action": { + "name": "action", + "schema": "public", + "values": [ + "SEED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/collaboration/drizzle/meta/_journal.json b/backend/collaboration/drizzle/meta/_journal.json new file mode 100644 index 0000000000..17254cc462 --- /dev/null +++ b/backend/collaboration/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1731050544004, + "tag": "0000_initial_schema", + "breakpoints": true + } + ] +} diff --git a/backend/collaboration/entrypoint.sh b/backend/collaboration/entrypoint.sh index b66060f739..61c411f483 100755 --- a/backend/collaboration/entrypoint.sh +++ b/backend/collaboration/entrypoint.sh @@ -1,2 +1,13 @@ #!/bin/sh + +# Drizzle will handle its own logic to remove conflicts +npm run db:prod:migrate + +# Checks admin table and will not seed if data exists +npm run db:prod:seed + +rm -rf drizzle src tsconfig.json + +npm uninstall tsx drizzle-kit + npm run start \ No newline at end of file diff --git a/backend/collaboration/express.Dockerfile b/backend/collaboration/express.Dockerfile index f4fd1efdc2..69c6c147ab 100644 --- a/backend/collaboration/express.Dockerfile +++ b/backend/collaboration/express.Dockerfile @@ -15,6 +15,12 @@ RUN npm ci --omit=dev RUN sed -i 's|./ws|ws|g' ./dist/ws.js +# For migration +RUN npm install tsx drizzle-kit +COPY drizzle ./drizzle +COPY src/lib/db/ ./src/lib/db +COPY src/config.ts ./src +COPY tsconfig.json . COPY entrypoint.sh . ARG port diff --git a/backend/collaboration/package.json b/backend/collaboration/package.json index 9ed950b433..61b22f9b70 100644 --- a/backend/collaboration/package.json +++ b/backend/collaboration/package.json @@ -8,6 +8,12 @@ "start": "node dist/index.js", "build:local": "env-cmd -f .env.local tsc && tsc-alias", "start:local": "env-cmd -f .env.local node dist/index.js", + "db:generate": "env-cmd -f .env.local drizzle-kit generate", + "db:migrate": "env-cmd -f .env.local tsx ./src/lib/db/migrate.ts", + "db:prod:migrate": "tsx ./src/lib/db/migrate.ts", + "db:prod:seed": "tsx ./src/lib/db/seed.ts", + "db:seed": "env-cmd -f .env.local tsx src/lib/db/seed.ts", + "db:seed:prod": "tsx src/lib/db/seed.ts", "fmt": "prettier --config .prettierrc src --write", "test": "echo \"Error: no test specified\" && exit 1" }, @@ -18,6 +24,7 @@ "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.5", + "drizzle-orm": "^0.36.1", "env-cmd": "^10.1.0", "express": "^4.21.1", "http-status-codes": "^2.3.0", @@ -33,6 +40,7 @@ "yjs": "^13.6.19" }, "devDependencies": { + "drizzle-kit": "^0.28.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^22.5.5", diff --git a/backend/collaboration/src/controller/get-rooms-controller.ts b/backend/collaboration/src/controller/get-rooms-controller.ts new file mode 100644 index 0000000000..53a54d5554 --- /dev/null +++ b/backend/collaboration/src/controller/get-rooms-controller.ts @@ -0,0 +1,31 @@ +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +import { getRoomsService } from '@/service/get/rooms-get-service'; + +type QueryParams = { + userId: string; + offset?: number; + limit?: number; +}; + +export async function getRoomsController( + req: Request>, + res: Response +) { + const { userId, ...rest } = req.query; + + if (!userId) { + return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json('Malformed Request'); + } + + const response = await getRoomsService({ userId, ...rest }); + + if (response.data) { + return res.status(response.code).json(response.data); + } + + return res + .status(response.code) + .json({ error: response.error || { message: 'An error occurred' } }); +} diff --git a/backend/collaboration/src/controller/room-auth-controller.ts b/backend/collaboration/src/controller/room-auth-controller.ts new file mode 100644 index 0000000000..603f706ec5 --- /dev/null +++ b/backend/collaboration/src/controller/room-auth-controller.ts @@ -0,0 +1,43 @@ +import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +import { logger } from '@/lib/utils'; +import { roomAuthService } from '@/service/get/room-auth-service'; + +type QueryParams = { + roomId: string; + userId: string; +}; + +// Returns the questionId if valid. +export async function authCheck( + req: Request>, + res: Response +) { + const { roomId, userId } = req.query; + + if (!roomId || !userId) { + return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json('Malformed request'); + } + + try { + const response = await roomAuthService({ + roomId, + userId, + }); + + if (response.data) { + return res.status(response.code).json(response.data); + } + + return res + .status(response.code) + .json({ error: response.error || { message: 'An error occurred.' } }); + } catch (error) { + const { name, stack, cause, message } = error as Error; + logger.error('Error authenticating room: ' + JSON.stringify({ name, stack, message, cause })); + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: { message: 'An error occurred while authenticating the room' }, + }); + } +} diff --git a/backend/collaboration/src/lib/db/index.ts b/backend/collaboration/src/lib/db/index.ts index 2394ddd2c2..2fbbec3b0d 100644 --- a/backend/collaboration/src/lib/db/index.ts +++ b/backend/collaboration/src/lib/db/index.ts @@ -1,3 +1,4 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; export const config = { @@ -8,4 +9,8 @@ export const config = { password: process.env.POSTGRES_PASSWORD, }; -export const db = postgres(config); +const queryClient = postgres(config); + +export const db = drizzle(queryClient); + +export * from './schema'; diff --git a/backend/collaboration/src/lib/db/migrate.ts b/backend/collaboration/src/lib/db/migrate.ts new file mode 100644 index 0000000000..a012ab160a --- /dev/null +++ b/backend/collaboration/src/lib/db/migrate.ts @@ -0,0 +1,21 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; + +const config = { + host: process.env.EXPRESS_DB_HOST!, + port: Number.parseInt(process.env.EXPRESS_DB_PORT!), + database: process.env.POSTGRES_DB, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, +}; +const migrationConnection = postgres({ ...config, max: 1 }); + +const db = drizzle(migrationConnection); + +const main = async () => { + await migrate(db, { migrationsFolder: 'drizzle' }); + await migrationConnection.end(); +}; + +void main(); diff --git a/backend/collaboration/src/lib/db/schema.ts b/backend/collaboration/src/lib/db/schema.ts new file mode 100644 index 0000000000..2a3df7a82e --- /dev/null +++ b/backend/collaboration/src/lib/db/schema.ts @@ -0,0 +1,17 @@ +import { pgEnum, pgTable, serial, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; + +export const rooms = pgTable('rooms', { + roomId: varchar('room_id', { length: 255 }).primaryKey().notNull(), + userId1: uuid('user_id_1').notNull(), + userId2: uuid('user_id_2').notNull(), + questionId: serial('question_id').notNull(), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const actionEnum = pgEnum('action', ['SEED']); + +export const admin = pgTable('admin', { + id: uuid('id').primaryKey().notNull().defaultRandom(), + createdAt: timestamp('created_at').defaultNow(), + action: actionEnum('action').notNull(), +}); diff --git a/backend/collaboration/src/routes/room.ts b/backend/collaboration/src/routes/room.ts index 337efed6c4..1fc9781ec3 100644 --- a/backend/collaboration/src/routes/room.ts +++ b/backend/collaboration/src/routes/room.ts @@ -1,9 +1,13 @@ import express from 'express'; import { getCollabRoom } from '@/controller/collab-controller'; +import { getRoomsController } from '@/controller/get-rooms-controller'; +import { authCheck } from '@/controller/room-auth-controller'; const router = express.Router(); router.get('/', getCollabRoom); +router.get('/rooms', getRoomsController); +router.get('/auth', authCheck); export default router; diff --git a/backend/collaboration/src/server.ts b/backend/collaboration/src/server.ts index 294150d7ea..bcaafec56a 100644 --- a/backend/collaboration/src/server.ts +++ b/backend/collaboration/src/server.ts @@ -2,6 +2,7 @@ import http from 'http'; import { exit } from 'process'; import cors from 'cors'; +import { sql } from 'drizzle-orm'; import express, { json } from 'express'; import { StatusCodes } from 'http-status-codes'; import pino from 'pino-http'; @@ -47,7 +48,7 @@ app.get('/health', (_req, res) => res.status(StatusCodes.OK).send('OK')); export const dbHealthCheck = async () => { try { - await db`SELECT 1`; + await db.execute(sql`SELECT 1`); logger.info('Connected to DB'); } catch (error) { const { message } = error as Error; diff --git a/backend/collaboration/src/service/get/collab-get-service.ts b/backend/collaboration/src/service/get/collab-get-service.ts index 657b11211d..5996d058ae 100644 --- a/backend/collaboration/src/service/get/collab-get-service.ts +++ b/backend/collaboration/src/service/get/collab-get-service.ts @@ -2,6 +2,8 @@ import crypto from 'crypto'; import { StatusCodes } from 'http-status-codes'; +import { db, rooms } from '@/lib/db'; + import { IGetCollabRoomPayload, IGetCollabRoomResponse } from './types'; export async function getCollabRoomService( @@ -9,7 +11,9 @@ export async function getCollabRoomService( ): Promise { const { userid1, userid2, questionid } = payload; - if (!userid1 || !userid2 || !questionid) { + const qid = Number(questionid); + + if (!userid1 || !userid2 || isNaN(qid)) { return { code: StatusCodes.UNPROCESSABLE_ENTITY, error: { @@ -18,14 +22,30 @@ export async function getCollabRoomService( }; } - const randomString = crypto.randomBytes(4).toString('hex'); - const combinedString = `uid1=${userid1}|uid2=${userid2}|qid=${questionid}|rand=${randomString}`; - const hash = crypto.createHash('sha256'); - const uniqueRoomName = hash.update(combinedString).digest('hex'); - return { - code: StatusCodes.OK, - data: { - roomName: uniqueRoomName, - }, - }; + const roomId = crypto.randomBytes(6).toString('hex'); + + try { + await db.insert(rooms).values({ + roomId, + userId1: userid1, + userId2: userid2, + questionId: qid, + createdAt: new Date(), + }); + + return { + code: StatusCodes.OK, + data: { + roomName: roomId, + }, + }; + } catch (error) { + console.error('Error saving room to database:', error); + return { + code: StatusCodes.INTERNAL_SERVER_ERROR, + error: { + message: 'Failed to create room', + }, + }; + } } diff --git a/backend/collaboration/src/service/get/room-auth-service.ts b/backend/collaboration/src/service/get/room-auth-service.ts new file mode 100644 index 0000000000..113297f3c6 --- /dev/null +++ b/backend/collaboration/src/service/get/room-auth-service.ts @@ -0,0 +1,45 @@ +import { and, eq } from 'drizzle-orm'; +import { StatusCodes } from 'http-status-codes'; + +import { db, rooms } from '@/lib/db'; +import { IServiceResponse } from '@/types'; + +import { IGetAuthRoomPayload } from './types'; + +export const roomAuthService = async ( + params: IGetAuthRoomPayload +): Promise> => { + const authedRooms = await db + .select() + .from(rooms) + .where(and(eq(rooms.roomId, params.roomId))) + .limit(1); + + if (!authedRooms || authedRooms.length === 0) { + return { + code: StatusCodes.UNAUTHORIZED, + error: { + message: 'No room with the given ID exists', + }, + }; + } + + const authedRoom = authedRooms[0]; + const { userId1, userId2, questionId } = authedRoom; + + if (![userId1, userId2].includes(params.userId)) { + return { + code: StatusCodes.UNAUTHORIZED, + error: { + message: 'No room with the given ID exists', + }, + }; + } + + return { + code: StatusCodes.OK, + data: { + questionId, + }, + }; +}; diff --git a/backend/collaboration/src/service/get/rooms-get-service.ts b/backend/collaboration/src/service/get/rooms-get-service.ts new file mode 100644 index 0000000000..5e1aa5da2f --- /dev/null +++ b/backend/collaboration/src/service/get/rooms-get-service.ts @@ -0,0 +1,44 @@ +import { desc, eq, type InferSelectModel, or } from 'drizzle-orm'; +import { StatusCodes } from 'http-status-codes'; + +import { db, rooms } from '@/lib/db'; +import { logger } from '@/lib/utils'; +import type { IServiceResponse } from '@/types'; + +import type { IGetRoomsPayload } from './types'; + +export const getRoomsService = async ( + params: IGetRoomsPayload +): Promise>>> => { + const { offset, limit: rawLimit, userId } = params; + const limit = rawLimit && rawLimit > 0 ? rawLimit : 10; + let query = db + .select() + .from(rooms) + .where(or(eq(rooms.userId1, userId), eq(rooms.userId2, userId))) + .limit(limit) + .$dynamic(); + + if (offset) { + query = query.offset(offset * limit); + } + + query = query.orderBy(desc(rooms.createdAt)); + + try { + const result = await query; + return { + code: StatusCodes.OK, + data: result, + }; + } catch (error) { + const { name, message, stack, cause } = error as Error; + logger.error(`An error occurred: ` + JSON.stringify({ name, message, stack, cause })); + return { + code: StatusCodes.INTERNAL_SERVER_ERROR, + error: { + message, + }, + }; + } +}; diff --git a/backend/collaboration/src/service/get/types.ts b/backend/collaboration/src/service/get/types.ts index 4529d01eec..48d08cb5f8 100644 --- a/backend/collaboration/src/service/get/types.ts +++ b/backend/collaboration/src/service/get/types.ts @@ -9,3 +9,14 @@ export type IGetCollabRoomPayload = { export type IGetCollabRoomResponse = IServiceResponse<{ roomName: string; }>; + +export type IGetAuthRoomPayload = { + roomId: string; + userId: string; +}; + +export type IGetRoomsPayload = { + userId: string; + offset?: number; + limit?: number; +}; diff --git a/backend/collaboration/tsconfig.json b/backend/collaboration/tsconfig.json index 3a5e3b136f..a550460793 100644 --- a/backend/collaboration/tsconfig.json +++ b/backend/collaboration/tsconfig.json @@ -9,7 +9,7 @@ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ @@ -22,13 +22,13 @@ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ - "rootDir": "./src", /* Specify the root folder within your source files. */ + "module": "commonjs" /* Specify what module code is generated. */, + "rootDir": "./src" /* Specify the root folder within your source files. */, // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */ + "baseUrl": "." /* Specify the base directory to resolve non-relative module names. */, "paths": { "@/*": ["./src/*"] - }, /* Specify a set of entries that re-map imports to additional lookup locations. */ + } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ @@ -52,7 +52,7 @@ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist", /* Specify an output folder for all emitted files. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ @@ -73,11 +73,11 @@ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ + "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ @@ -100,9 +100,9 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "exclude": [], + "exclude": ["drizzle.*.*ts"], "ts-node": { "swc": true, "require": ["tsconfig-paths/register"] } -} \ No newline at end of file +} diff --git a/frontend/src/components/blocks/interview/ai-chat.tsx b/frontend/src/components/blocks/interview/ai-chat.tsx index ed33431a20..bbe5a0638d 100644 --- a/frontend/src/components/blocks/interview/ai-chat.tsx +++ b/frontend/src/components/blocks/interview/ai-chat.tsx @@ -3,7 +3,7 @@ import { MessageSquareIcon } from 'lucide-react'; import React, { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { sendChatMessage } from '@/services/collab-service'; +import { sendChatMessage } from '@/services/ai-service'; import { ChatLayout } from './chat/chat-layout'; import { ChatMessageType } from './chat/chat-message'; 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 index 796f853cdb..bb8fe1cf86 100644 --- a/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx +++ b/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx @@ -31,13 +31,13 @@ export const AttemptDetailsDialog: FC ) : ( {triggerText} )} - + Attempt {attemptId} - + diff --git a/frontend/src/components/blocks/nav-bar.tsx b/frontend/src/components/blocks/nav-bar.tsx index 2f37f05358..119d70eabf 100644 --- a/frontend/src/components/blocks/nav-bar.tsx +++ b/frontend/src/components/blocks/nav-bar.tsx @@ -30,6 +30,9 @@ const NavBar = observer(() => { + )}
diff --git a/frontend/src/lib/router.tsx b/frontend/src/lib/router.tsx index eb20e1706e..e76de7ca3b 100644 --- a/frontend/src/lib/router.tsx +++ b/frontend/src/lib/router.tsx @@ -5,7 +5,8 @@ import { RootLayout } from '@/components/blocks/root-layout'; import { loader as routeGuardLoader, RouteGuard } from '@/components/blocks/route-guard'; import { ForgotPassword } from '@/routes/forgot-password'; import { HomePage } from '@/routes/home'; -import { InterviewRoom, loader as interviewRoomLoader } from '@/routes/interview/[room]'; +import { InterviewsPage } from '@/routes/interview'; +import { InterviewRoomContainer, loader as interviewRoomLoader } from '@/routes/interview/[room]'; import { Login } from '@/routes/login'; import { Match } from '@/routes/match'; import { loader as topicsLoader } from '@/routes/match/logic'; @@ -42,10 +43,14 @@ export const router = createBrowserRouter([ loader: questionDetailsLoader(queryClient), element: , }, + { + path: ROUTES.INTERVIEWS, + element: , + }, { path: ROUTES.INTERVIEW, - loader: interviewRoomLoader(queryClient), - element: , + loader: interviewRoomLoader, + element: , }, { path: ROUTES.MATCH, diff --git a/frontend/src/lib/routes.ts b/frontend/src/lib/routes.ts index 2fa942eb22..c2b7bc6ef4 100644 --- a/frontend/src/lib/routes.ts +++ b/frontend/src/lib/routes.ts @@ -8,6 +8,7 @@ export const ROUTES = { QUESTION_DETAILS: '/questions/:questionId', MATCH: '/match', + INTERVIEWS: '/interviews', INTERVIEW: '/interview/:roomId', }; @@ -25,11 +26,21 @@ const TOP_LEVEL_AUTHED_ROUTES = { }, ], [ROUTES.INTERVIEW.replace(':roomId', '')]: [ + { + path: ROUTES.INTERVIEWS, + title: 'Interviews', + }, { path: ROUTES.INTERVIEW, title: 'Interview', }, ], + [ROUTES.INTERVIEWS]: [ + { + path: ROUTES.INTERVIEWS, + title: 'Interviews', + }, + ], }; export type BreadCrumb = { diff --git a/frontend/src/routes/interview/[room]/interview-room.tsx b/frontend/src/routes/interview/[room]/interview-room.tsx new file mode 100644 index 0000000000..a6f06a5044 --- /dev/null +++ b/frontend/src/routes/interview/[room]/interview-room.tsx @@ -0,0 +1,100 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { LanguageName } from '@uiw/codemirror-extensions-langs'; +import { useMemo, useState } from 'react'; + +import { WithNavBanner, WithNavBlocker } from '@/components/blocks/authed'; +import { AIChat } from '@/components/blocks/interview/ai-chat'; +import { Editor } from '@/components/blocks/interview/editor'; +import { PartnerChat } from '@/components/blocks/interview/partner-chat'; +import { QuestionAttemptsPane } from '@/components/blocks/interview/question-attempts'; +import { QuestionDetails } from '@/components/blocks/questions/details'; +import { Card } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useCrumbs } from '@/lib/hooks'; +import { questionDetailsQuery } from '@/lib/queries/question-details'; + +interface InterviewRoomProps { + questionId: number; + roomId: string; +} + +const InterviewRoom = ({ questionId, roomId }: InterviewRoomProps) => { + const { crumbs } = useCrumbs(); + const { data: details } = useSuspenseQuery(questionDetailsQuery(questionId)); + const questionDetails = useMemo(() => details.question, [details]); + const [isAIChatOpen, setIsAIChatOpen] = useState(false); + const [isPartnerChatOpen, setIsPartnerChatOpen] = useState(false); + const [currentCode, setCurrentCode] = useState(''); + const [currentLanguage, setCurrentLanguage] = useState('typescript'); + + const handleCodeChange = (code: string, language: LanguageName) => { + setCurrentCode(code); + setCurrentLanguage(language); + }; + + const handleAIClick = () => { + setIsPartnerChatOpen(false); + setIsAIChatOpen(!isAIChatOpen); + }; + + const handlePartnerClick = () => { + setIsAIChatOpen(false); + setIsPartnerChatOpen(!isPartnerChatOpen); + }; + + return ( + + +
+ + + + Question Details + Past Attempts + + + + + + + + + + +
+ +
+ + {(isAIChatOpen || isPartnerChatOpen) && ( + + {isAIChatOpen && ( + setIsAIChatOpen(false)} + editorCode={currentCode} + language={currentLanguage} + questionDetails={questionDetails.description} + /> + )} + {isPartnerChatOpen && ( + setIsPartnerChatOpen(false)} + /> + )} + + )} +
+
+
+ ); +}; + +export default InterviewRoom; diff --git a/frontend/src/routes/interview/[room]/main.tsx b/frontend/src/routes/interview/[room]/main.tsx index 6f54b89141..a6086001f0 100644 --- a/frontend/src/routes/interview/[room]/main.tsx +++ b/frontend/src/routes/interview/[room]/main.tsx @@ -1,111 +1,36 @@ -import { QueryClient, useSuspenseQuery } from '@tanstack/react-query'; -import { LanguageName } from '@uiw/codemirror-extensions-langs'; -import { useMemo, useState } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; import { type LoaderFunctionArgs, Navigate, useLoaderData } from 'react-router-dom'; -import { WithNavBanner, WithNavBlocker } from '@/components/blocks/authed'; -import { AIChat } from '@/components/blocks/interview/ai-chat'; -import { Editor } from '@/components/blocks/interview/editor'; -import { PartnerChat } from '@/components/blocks/interview/partner-chat'; -import { QuestionAttemptsPane } from '@/components/blocks/interview/question-attempts'; -import { QuestionDetails } from '@/components/blocks/questions/details'; -import { Card } from '@/components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useCrumbs, usePageTitle } from '@/lib/hooks'; -import { questionDetailsQuery } from '@/lib/queries/question-details'; +import { usePageTitle } from '@/lib/hooks'; import { ROUTES } from '@/lib/routes'; +import { checkRoomAuthorization } from '@/services/collab-service'; +import { useAuthedRoute } from '@/stores/auth-store'; -export const loader = - (queryClient: QueryClient) => - async ({ params, request }: LoaderFunctionArgs) => { - const roomId = params.roomId; - const url = new URL(request.url); - const questionId = Number.parseInt(url.searchParams.get('questionId')!); - await queryClient.ensureQueryData(questionDetailsQuery(questionId)); - return { - roomId, - questionId, - }; - }; +import InterviewRoom from './interview-room'; -export const InterviewRoom = () => { - usePageTitle(ROUTES.INTERVIEW); - const { questionId, roomId } = useLoaderData() as Awaited>>; - const { crumbs } = useCrumbs(); - const { data: details } = useSuspenseQuery(questionDetailsQuery(questionId)); - const questionDetails = useMemo(() => details.question, [details]); - const [isAIChatOpen, setIsAIChatOpen] = useState(false); - const [isPartnerChatOpen, setIsPartnerChatOpen] = useState(false); - const [currentCode, setCurrentCode] = useState(''); - const [currentLanguage, setCurrentLanguage] = useState('typescript'); +export const loader = ({ params }: LoaderFunctionArgs) => { + const roomId = params.roomId; - const handleCodeChange = (code: string, language: LanguageName) => { - setCurrentCode(code); - setCurrentLanguage(language); + return { + roomId, }; +}; - const handleAIClick = () => { - setIsPartnerChatOpen(false); - setIsAIChatOpen(!isAIChatOpen); - }; +export const InterviewRoomContainer = () => { + usePageTitle(ROUTES.INTERVIEW); + const { roomId } = useLoaderData() as ReturnType; + const { userId } = useAuthedRoute(); - const handlePartnerClick = () => { - setIsAIChatOpen(false); - setIsPartnerChatOpen(!isPartnerChatOpen); - }; + const safeRoomId = roomId ?? ''; + + const { data: authResult, error } = useSuspenseQuery({ + queryKey: ['checkRoomAuthorization', safeRoomId, userId], + queryFn: () => checkRoomAuthorization(safeRoomId, userId), + }); - return !questionId || !roomId ? ( - - ) : ( - - -
- - - - Question Details - Past Attempts - - - - - - - - - + if (error || !authResult?.isAuthed || !roomId || !authResult?.questionId) { + return ; + } -
- -
- {(isAIChatOpen || isPartnerChatOpen) && ( - - {isAIChatOpen && ( - setIsAIChatOpen(false)} - editorCode={currentCode} - language={currentLanguage} - questionDetails={questionDetails.description} - /> - )} - {isPartnerChatOpen && ( - setIsPartnerChatOpen(false)} - /> - )} - - )} -
-
-
- ); + return ; }; diff --git a/frontend/src/routes/interview/index.ts b/frontend/src/routes/interview/index.ts new file mode 100644 index 0000000000..aad1ca831e --- /dev/null +++ b/frontend/src/routes/interview/index.ts @@ -0,0 +1 @@ +export * from './main'; diff --git a/frontend/src/routes/interview/interviews-columns.tsx b/frontend/src/routes/interview/interviews-columns.tsx new file mode 100644 index 0000000000..cdabd7727c --- /dev/null +++ b/frontend/src/routes/interview/interviews-columns.tsx @@ -0,0 +1,84 @@ +import { DotsVerticalIcon, TrashIcon } from '@radix-ui/react-icons'; +import { ColumnDef } from '@tanstack/react-table'; +import { Link } from 'react-router-dom'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { DataTableSortableHeader } from '@/components/ui/data-table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ROUTES } from '@/lib/routes'; +import { useAuthedRoute } from '@/stores/auth-store'; +import { IInterviewRoom } from '@/types/collab-types'; + +export const columns: Array> = [ + { + id: 'roomId', + accessorKey: 'roomId', + header: 'Room Link', + cell: ({ row }) => ( + + ), + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return {new Date(row.getValue('createdAt')).toLocaleString()}; + }, + }, + { + id: 'question', + accessorKey: 'questionId', + header: ({ column }) => , + }, + { + id: 'users', + header: 'Users', + cell: ({ row }) => { + const { userId } = useAuthedRoute(); + const { userId1, userId2 } = row.original; + const isMe = (value?: string) => userId === value; + return ( +
+ {[userId1, userId2].map((value, index) => ( + + {isMe(value) ? 'Me' : value?.slice(0, 6)} + + ))} +
+ ); + }, + }, + { + id: 'actions', + cell: ({ row: _ }) => { + // const { isAdmin } = useAuthedRoute(); + return ( + + {/* */} + + + + + + Delete Room + + + + + ); + }, + }, +]; diff --git a/frontend/src/routes/interview/interviews-table.tsx b/frontend/src/routes/interview/interviews-table.tsx new file mode 100644 index 0000000000..a3976fd012 --- /dev/null +++ b/frontend/src/routes/interview/interviews-table.tsx @@ -0,0 +1,181 @@ +import { + ArrowLeftIcon, + ArrowRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from '@radix-ui/react-icons'; +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { type FC, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Pagination, PaginationContent, PaginationItem } from '@/components/ui/pagination'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { IInterviewRoom } from '@/types/collab-types'; + +type InterviewsTableProps = { + columns: Array>; + data: Array; + isError: boolean; +}; + +export const InterviewsTable: FC = ({ columns, data, isError }) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const [columnFilters, setColumnFilters] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { pagination, columnFilters }, + filterFns: {}, + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getCoreRowModel: getCoreRowModel(), + onColumnFiltersChange: setColumnFilters, + onPaginationChange: setPagination, + }); + + return ( +
+ {/*
+
+ +
+
+ +
+ 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/routes/interview/main.tsx b/frontend/src/routes/interview/main.tsx new file mode 100644 index 0000000000..324594bec1 --- /dev/null +++ b/frontend/src/routes/interview/main.tsx @@ -0,0 +1,48 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +import { WithNavBanner } from '@/components/blocks/authed'; +import { useCrumbs } from '@/lib/hooks'; +import { getRooms } from '@/services/collab-service'; +import { useAuthedRoute } from '@/stores/auth-store'; +import { IInterviewRoom } from '@/types/collab-types'; + +import { columns } from './interviews-columns'; +import { InterviewsTable } from './interviews-table'; + +export const InterviewsPage = () => { + const { userId } = useAuthedRoute(); + const { crumbs } = useCrumbs(); + const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isError } = useInfiniteQuery< + Array + >({ + queryKey: ['interviews', userId], + initialPageParam: 0, + queryFn: async ({ pageParam }) => getRooms(userId, pageParam as number), + getNextPageParam: (_lastPage, pages) => { + if (_lastPage.length === 0) { + return undefined; + } + + return pages.length; + }, + }); + + useEffect(() => { + if (!isFetchingNextPage && hasNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, isFetchingNextPage, hasNextPage]); + + const _rooms = useMemo(() => { + return data ? data.pages.flatMap((page) => page) : []; + }, [data]); + + return ( + +
+ +
+
+ ); +}; diff --git a/frontend/src/routes/match/waiting-room/waiting.tsx b/frontend/src/routes/match/waiting-room/waiting.tsx index 24c7fc5b50..85a8347e75 100644 --- a/frontend/src/routes/match/waiting-room/waiting.tsx +++ b/frontend/src/routes/match/waiting-room/waiting.tsx @@ -128,12 +128,11 @@ export const WaitingRoom = ({ socketPort, setIsModalOpen }: IWaitingRoomProps) = console.log(`Received match: ${JSON.stringify(data)}`); const roomId = data?.roomId; - const questionId = data?.questionId; countdownRef.current = 0; clearInterval(timerRef.current!); socket.close(); - navigate(`${ROUTES.INTERVIEW.replace(':roomId', roomId)}?questionId=${questionId}`); + navigate(`${ROUTES.INTERVIEW.replace(':roomId', roomId)}`); }); socket.on(MATCHING_EVENT.FAILED, () => { diff --git a/frontend/src/services/ai-service.ts b/frontend/src/services/ai-service.ts new file mode 100644 index 0000000000..a27d61b8cf --- /dev/null +++ b/frontend/src/services/ai-service.ts @@ -0,0 +1,62 @@ +import { collabApiClient } from './api-clients'; + +const AI_SERVICE_ROUTES = { + CHAT: '/ai/chat', +}; + +interface ChatMessage { + role: string; + content: string; +} + +interface ChatPayload { + messages: Array; + editorCode?: string; + language?: string; + questionDetails?: string; +} + +interface ChatResponse { + success: boolean; + message: string; +} + +export const sendChatMessage = async ( + payload: ChatPayload, + onStream?: (chunk: string) => void +): Promise => { + try { + if (onStream) { + // Streaming request + await collabApiClient.post(AI_SERVICE_ROUTES.CHAT, payload, { + headers: { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + responseType: 'text', + onDownloadProgress: (progressEvent) => { + const rawText: string = progressEvent.event.target.responseText; + + if (rawText) { + onStream(rawText); + } + }, + }); + + return { + success: true, + message: 'Streaming completed successfully', + }; + } else { + const response = await collabApiClient.post(AI_SERVICE_ROUTES.CHAT, payload); + return response.data as ChatResponse; + } + } catch (err) { + console.error('Error in sendChatMessage:', err); + return { + success: false, + message: 'An error occurred while processing your request.', + }; + } +}; diff --git a/frontend/src/services/api-clients.ts b/frontend/src/services/api-clients.ts index a875023b86..f0a0abb2f7 100644 --- a/frontend/src/services/api-clients.ts +++ b/frontend/src/services/api-clients.ts @@ -36,6 +36,8 @@ export const matchApiClient = axios.create({ ...basePostHeaders, }); +export const collabApiGetClient = axios.create(getApiClientBaseConfig(COLLAB_SERVICE)); + export const collabApiClient = axios.create({ ...getApiClientBaseConfig(COLLAB_SERVICE), ...basePostHeaders, diff --git a/frontend/src/services/collab-service.ts b/frontend/src/services/collab-service.ts index fe641f34b4..1ce2339c64 100644 --- a/frontend/src/services/collab-service.ts +++ b/frontend/src/services/collab-service.ts @@ -1,62 +1,40 @@ -import { collabApiClient } from './api-clients'; +import { IInterviewRoom } from '@/types/collab-types'; -const AI_SERVICE_ROUTES = { - CHAT: '/ai/chat', -}; - -interface ChatMessage { - role: string; - content: string; -} +import { collabApiGetClient } from './api-clients'; -interface ChatPayload { - messages: ChatMessage[]; - editorCode?: string; - language?: string; - questionDetails?: string; -} +const COLLAB_SERVICE_ROUTES = { + CHECK_ROOM_AUTH: '/room/auth', + GET_ROOMS: '/room/rooms', +}; -interface ChatResponse { - success: boolean; - message: string; -} +export const checkRoomAuthorization = (roomId: string, userId: string) => { + const searchParams = new URLSearchParams(); + searchParams.set('roomId', roomId); + searchParams.set('userId', userId); + return collabApiGetClient + .get(`${COLLAB_SERVICE_ROUTES.CHECK_ROOM_AUTH}?${searchParams.toString()}`) + .then((response) => { + return { isAuthed: response.status < 400, questionId: response.data?.questionId }; + }) + .catch((_err) => { + return { isAuthed: false, questionId: undefined }; + }); +}; -export const sendChatMessage = async ( - payload: ChatPayload, - onStream?: (chunk: string) => void -): Promise => { - try { - if (onStream) { - // Streaming request - await collabApiClient.post(AI_SERVICE_ROUTES.CHAT, payload, { - headers: { - Accept: 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - responseType: 'text', - onDownloadProgress: (progressEvent) => { - const rawText: string = progressEvent.event.target.responseText; +export const getRooms = (userId: string, offset?: number, limit?: number) => { + const searchParams = new URLSearchParams(); + searchParams.set('userId', userId); - if (rawText) { - onStream(rawText); - } - }, - }); + if (offset) { + searchParams.set('offset', String(offset)); + } - return { - success: true, - message: 'Streaming completed successfully', - }; - } else { - const response = await collabApiClient.post(AI_SERVICE_ROUTES.CHAT, payload); - return response.data as ChatResponse; - } - } catch (err) { - console.error('Error in sendChatMessage:', err); - return { - success: false, - message: 'An error occurred while processing your request.', - }; + if (limit) { + searchParams.set('limit', String(limit)); } + + return collabApiGetClient + .get(`${COLLAB_SERVICE_ROUTES.GET_ROOMS}?${searchParams.toString()}`) + .then((response) => response.data as Array) + .catch((_err) => []); }; diff --git a/frontend/src/types/collab-types.ts b/frontend/src/types/collab-types.ts index 767e5fbaf8..ff53e743d9 100644 --- a/frontend/src/types/collab-types.ts +++ b/frontend/src/types/collab-types.ts @@ -1,3 +1,11 @@ export type IYjsUserState = { user: { name: string; userId: string; color: string; colorLight: string }; }; + +export type IInterviewRoom = { + roomId: string; + questionId: number; + userId1: string; + userId2?: string; + createdAt: string; +}; diff --git a/package-lock.json b/package-lock.json index adbbc9ce10..f4db50cfa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.5", + "drizzle-orm": "^0.36.1", "env-cmd": "^10.1.0", "express": "^4.21.1", "http-status-codes": "^2.3.0", @@ -83,6 +84,7 @@ "@types/node": "^22.5.5", "@types/pg": "^8.11.10", "@types/ws": "^8.5.12", + "drizzle-kit": "^0.28.0", "nodemon": "^3.1.4", "pino-pretty": "^11.2.2", "ts-node": "^10.9.2", @@ -90,6 +92,551 @@ "tsx": "^4.19.1" } }, + "backend/collaboration/node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "backend/collaboration/node_modules/drizzle-kit": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.28.0.tgz", + "integrity": "sha512-KqI+CS2Ga9GYIrXpxpCDUJJrH/AT/k4UY0Pb4oRgQEGkgN1EdCnqp664cXgwPWjDr5RBtTsjZipw8+8C//K63A==", + "dev": true, + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.19.7", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "backend/collaboration/node_modules/drizzle-orm": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.36.1.tgz", + "integrity": "sha512-F4hbimnMEhyWzDowQB4xEuVJJWXLHZYD7FYwvo8RImY+N7pStGqsbfmT95jDbec1s4qKmQbiuxEDZY90LRrfIw==", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=3", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.1", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/react": ">=18", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=13.2.0", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "react": ">=18", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "backend/collaboration/node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, "backend/collaboration/node_modules/ws": { "version": "8.18.0", "license": "MIT", diff --git a/scripts/migrate-seed-databases.sh b/scripts/migrate-seed-databases.sh index bd3b06e38c..d419302b0a 100755 --- a/scripts/migrate-seed-databases.sh +++ b/scripts/migrate-seed-databases.sh @@ -1,7 +1,7 @@ #!/bin/bash # Migrate Services -migrate_services=("question" "user" "chat") +migrate_services=("question" "user" "chat" "collaboration") for service in "${migrate_services[@]}"; do cd "backend/$service" npm run db:migrate