diff --git a/.env.local b/.env.local index 8212b8ab9b..634819dfbc 100644 --- a/.env.local +++ b/.env.local @@ -20,5 +20,12 @@ MATCHING_DB_PASSWORD="password" MATCHING_DB_HOST_PORT=6378 MATCHING_DB_HOST_MGMT_PORT=3001 +CHAT_SERVICE_NAME=chat-express +CHAT_EXPRESS_PORT=9005 +CHAT_EXPRESS_DB_PORT=5435 +CHAT_PGDATA="/data/chat-db" + FRONTEND_SERVICE_NAME=frontend FRONTEND_PORT=3000 +OPENAI_API_KEY=PUT_YOUR_OPENAI_API_KEY_HERE + diff --git a/.github/workflows/build-deploy-docker.yaml b/.github/workflows/build-deploy-docker.yaml index 4f11ebb315..3f579c26a0 100644 --- a/.github/workflows/build-deploy-docker.yaml +++ b/.github/workflows/build-deploy-docker.yaml @@ -16,6 +16,7 @@ env: QUESTION_EXPRESS_PORT: 9002 COLLAB_EXPRESS_PORT: 9003 MATCH_EXPRESS_PORT: 9004 + CHAT_EXPRESS_PORT: 9005 FRONTEND_PORT: 3000 jobs: @@ -44,6 +45,8 @@ jobs: - 'backend/collaboration/**' matching: - 'backend/matching/**' + chat: + - 'backend/chat/**' frontend: - 'frontend/**' - name: output-job-matrix @@ -91,6 +94,16 @@ jobs: '{package: $pkg, image: $img, context: $ctx, dockerfile: $dkr, "build-args": $bag}') matrix+=("$config") fi + if [[ "${{ steps.filter.outputs.chat }}" == "true" || "$is_main" == "true" ]]; then + config=$(jq -n \ + --arg pkg "chat" \ + --arg img "$DOCKER_REGISTRY_USN/chat-express" \ + --arg ctx "./backend/chat" \ + --arg dkr "./backend/chat/express.Dockerfile" \ + --arg bag "port=$CHAT_EXPRESS_PORT" \ + '{package: $pkg, image: $img, context: $ctx, dockerfile: $dkr, "build-args": $bag}') + matrix+=("$config") + fi if [[ "${{ steps.filter.outputs.frontend }}" == "true" || "$is_main" == "true" ]]; then config=$(jq -n \ --arg pkg "frontend" \ diff --git a/backend/chat/.dockerignore b/backend/chat/.dockerignore new file mode 100644 index 0000000000..d26c7464b6 --- /dev/null +++ b/backend/chat/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist/ \ No newline at end of file diff --git a/backend/chat/.env.compose b/backend/chat/.env.compose new file mode 100644 index 0000000000..3d95051eb7 --- /dev/null +++ b/backend/chat/.env.compose @@ -0,0 +1,7 @@ +EXPRESS_PORT=9005 +EXPRESS_DB_HOST=chat-db +EXPRESS_DB_PORT=5435 +POSTGRES_DB=chat +POSTGRES_USER=peerprep-chat-express +POSTGRES_PASSWORD=Xk8qEcEI2sizjfEn/lF6mLqiyBECjIHY3q6sdXf9poQ= +PGDATA="/data/chat-db" diff --git a/backend/chat/.env.docker b/backend/chat/.env.docker new file mode 100644 index 0000000000..cc5972ea1b --- /dev/null +++ b/backend/chat/.env.docker @@ -0,0 +1,9 @@ +PEERPREP_UI_HOST=http://host.docker.internal:5173 + +EXPRESS_PORT=9005 +EXPRESS_DB_HOST=host.docker.internal +EXPRESS_DB_PORT=5435 +POSTGRES_DB=chat +POSTGRES_USER=peerprep-chat-express +POSTGRES_PASSWORD=Xk8qEcEI2sizjfEn/lF6mLqiyBECjIHY3q6sdXf9poQ= +PGDATA=/data/chat-db diff --git a/backend/chat/.env.local b/backend/chat/.env.local new file mode 100644 index 0000000000..da18c38a94 --- /dev/null +++ b/backend/chat/.env.local @@ -0,0 +1,9 @@ +PEERPREP_UI_HOST=http://localhost:5173 + +EXPRESS_PORT=9005 +EXPRESS_DB_HOST=localhost +EXPRESS_DB_PORT=5435 +POSTGRES_DB=chat +POSTGRES_USER=peerprep-chat-express +POSTGRES_PASSWORD=Xk8qEcEI2sizjfEn/lF6mLqiyBECjIHY3q6sdXf9poQ= +PGDATA=/data/chat-db diff --git a/backend/chat/README.md b/backend/chat/README.md new file mode 100644 index 0000000000..59a9a23552 --- /dev/null +++ b/backend/chat/README.md @@ -0,0 +1,30 @@ +# Matching Service + +## Running with Docker (Standalone) + +1. Run this command to build: + ```sh + docker build \ + -t chat-express-local \ + --build-arg port=9005 \ + -f express.Dockerfile . + ``` +2. Run this command, from the roxot folder: + + ```sh + make db-up + ``` + +3. Run the necessary migrate and seed commands, if you haven't yet. + +4. Run this command to expose the container: + ```sh + docker run -p 9005:9005 --env-file ./.env.docker chat-express-local + ``` +5. To stop the process, use the Docker UI or CLI with `docker rm -f ` (The child process loop has issues terminating) + +## Running with Docker-Compose (Main config) + +Edit the variables in the `.env.compose` file and run `make up` from the root folder. + +Any startup instructions will be run from `entrypoint.sh` instead. diff --git a/backend/chat/drizzle.config.ts b/backend/chat/drizzle.config.ts new file mode 100644 index 0000000000..b95650e9d9 --- /dev/null +++ b/backend/chat/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/chat/drizzle/0000_initial_schema.sql b/backend/chat/drizzle/0000_initial_schema.sql new file mode 100644 index 0000000000..f4b1c69571 --- /dev/null +++ b/backend/chat/drizzle/0000_initial_schema.sql @@ -0,0 +1,20 @@ +DO $$ +BEGIN + CREATE TYPE "public"."action" AS ENUM('SEED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS "admin" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp DEFAULT now(), + "action" "public"."action" NOT NULL +); + +CREATE TABLE IF NOT EXISTS "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "room_id" varchar(255) NOT NULL, + "sender_id" uuid NOT NULL, + "message" text NOT NULL, + "created_at" timestamp DEFAULT now() +); diff --git a/backend/chat/drizzle/meta/0000_snapshot.json b/backend/chat/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000000..b29190be4f --- /dev/null +++ b/backend/chat/drizzle/meta/0000_snapshot.json @@ -0,0 +1,97 @@ +{ + "id": "fb253102-46c6-477c-a0e6-5dad3ea879eb", + "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": {} + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "room_id": { + "name": "room_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "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/chat/drizzle/meta/_journal.json b/backend/chat/drizzle/meta/_journal.json new file mode 100644 index 0000000000..ceb9be2953 --- /dev/null +++ b/backend/chat/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1729871791234, + "tag": "0000_initial_schema", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/chat/entrypoint.sh b/backend/chat/entrypoint.sh new file mode 100644 index 0000000000..61c411f483 --- /dev/null +++ b/backend/chat/entrypoint.sh @@ -0,0 +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/chat/express.Dockerfile b/backend/chat/express.Dockerfile new file mode 100644 index 0000000000..12b52d1cd6 --- /dev/null +++ b/backend/chat/express.Dockerfile @@ -0,0 +1,25 @@ +FROM node:lts-alpine AS build +WORKDIR /data/chat-express +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:lts-alpine AS production +WORKDIR /data/chat-express +COPY --from=build /data/chat-express/package*.json ./ +COPY --from=build --chown=node:node /data/chat-express/dist ./dist + +RUN npm ci --omit=dev + +# 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 +EXPOSE ${port} +ENTRYPOINT [ "/bin/sh", "entrypoint.sh" ] \ No newline at end of file diff --git a/backend/chat/package.json b/backend/chat/package.json new file mode 100644 index 0000000000..ea14e9f0e0 --- /dev/null +++ b/backend/chat/package.json @@ -0,0 +1,47 @@ +{ + "name": "chat", + "version": "1.0.0", + "main": "dist/index.js", + "scripts": { + "dev": "env-cmd -f .env.local nodemon src/index.ts | pino-pretty", + "build": "tsc && tsc-alias", + "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" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "dotenv": "^16.4.5", + "drizzle-orm": "^0.33.0", + "express": "^4.21.0", + "http": "^0.0.1-security", + "http-status-codes": "^2.3.0", + "pino": "^9.4.0", + "pino-http": "^10.3.0", + "postgres": "^3.4.4", + "socket.io": "^4.8.1", + "tsc-alias": "^1.8.10", + "tsx": "^4.19.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.5.5", + "drizzle-kit": "^0.24.2", + "nodemon": "^3.1.4", + "pino-pretty": "^11.2.2", + "ts-node": "^10.9.2", + "tsx": "^4.19.1", + "typescript": "^5.6.3" + } +} diff --git a/backend/chat/src/config.ts b/backend/chat/src/config.ts new file mode 100644 index 0000000000..02c44495bb --- /dev/null +++ b/backend/chat/src/config.ts @@ -0,0 +1,13 @@ +import 'dotenv/config'; + +export const UI_HOST = process.env.PEERPREP_UI_HOST!; + +export const EXPRESS_PORT = process.env.EXPRESS_PORT; + +export const dbConfig = { + 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, +}; diff --git a/backend/chat/src/controller/chat-controller.ts b/backend/chat/src/controller/chat-controller.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/chat/src/index.ts b/backend/chat/src/index.ts new file mode 100644 index 0000000000..319b3c10dd --- /dev/null +++ b/backend/chat/src/index.ts @@ -0,0 +1,35 @@ +import { EXPRESS_PORT } from '@/config'; +import { logger } from '@/lib/utils'; +import server, { io } from '@/server'; +import { dbHealthCheck } from '@/server'; + +const port = Number.parseInt(EXPRESS_PORT || '8001'); + +const listenMessage = `App listening on port: ${port}`; +server.listen(port, () => { + void dbHealthCheck(); + logger.info(listenMessage); +}); + +const shutdown = () => { + logger.info('Shutting down gracefully...'); + + server.close((err) => { + if (err) { + logger.error('Error closing HTTP server', err); + process.exit(1); + } + + void io + .close(() => { + logger.info('WS Server shut down'); + }) + .then(() => { + logger.info('App shut down'); + process.exit(0); + }); + }); +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/backend/chat/src/lib/db/index.ts b/backend/chat/src/lib/db/index.ts new file mode 100644 index 0000000000..2fbbec3b0d --- /dev/null +++ b/backend/chat/src/lib/db/index.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; + +export 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 queryClient = postgres(config); + +export const db = drizzle(queryClient); + +export * from './schema'; diff --git a/backend/chat/src/lib/db/migrate.ts b/backend/chat/src/lib/db/migrate.ts new file mode 100644 index 0000000000..a012ab160a --- /dev/null +++ b/backend/chat/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/chat/src/lib/db/schema.ts b/backend/chat/src/lib/db/schema.ts new file mode 100644 index 0000000000..dafd25a163 --- /dev/null +++ b/backend/chat/src/lib/db/schema.ts @@ -0,0 +1,17 @@ +import { pgEnum, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; + +export const chatMessages = pgTable('chat_messages', { + id: uuid('id').primaryKey().notNull().defaultRandom(), // Unique message ID + roomId: varchar('room_id', { length: 255 }).notNull(), // Room ID to identify chat rooms + senderId: uuid('sender_id').notNull(), // ID of the user sending the message + message: text('message').notNull(), // The chat message content + createdAt: timestamp('created_at').defaultNow(), // Timestamp for when the message was created +}); + +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/chat/src/lib/utils/index.ts b/backend/chat/src/lib/utils/index.ts new file mode 100644 index 0000000000..1ff09efd40 --- /dev/null +++ b/backend/chat/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './logger'; diff --git a/backend/chat/src/lib/utils/logger.ts b/backend/chat/src/lib/utils/logger.ts new file mode 100644 index 0000000000..e41655d003 --- /dev/null +++ b/backend/chat/src/lib/utils/logger.ts @@ -0,0 +1,3 @@ +import pinoLogger from 'pino'; + +export const logger = pinoLogger(); diff --git a/backend/chat/src/server.ts b/backend/chat/src/server.ts new file mode 100644 index 0000000000..2bcadf9544 --- /dev/null +++ b/backend/chat/src/server.ts @@ -0,0 +1,48 @@ +import http from 'http'; +import { config, exit } from 'process'; + +import { sql } from 'drizzle-orm'; +import express, { json } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import pino from 'pino-http'; + +import { chatMessages, db } from './lib/db'; +import { logger } from './lib/utils/logger'; +import { createWs } from './ws'; + +const app = express(); +app.use(pino()); +app.use(json()); + +app.get('/', async (_req, res) => { + res.json({ + message: 'OK', + }); +}); + +// Health Check for Docker +app.get('/health', (_req, res) => res.status(StatusCodes.OK).send('OK')); + +// Ensure DB service is up before running. +app.get('/test-db', async (_req, res) => { + await db.select().from(chatMessages); + res.json({ message: 'OK ' }); +}); + +export const dbHealthCheck = async () => { + try { + await db.execute(sql`SELECT 1`); + logger.info('Connected to DB'); + } catch (error) { + const { message } = error as Error; + logger.error('Cannot connect to DB: ' + message); + logger.error(`DB Config: ${JSON.stringify({ ...config, password: '' })}`); + exit(1); + } +}; + +const server = http.createServer(app); + +export const io = createWs(server); + +export default server; diff --git a/backend/chat/src/types/index.ts b/backend/chat/src/types/index.ts new file mode 100644 index 0000000000..6481206e6b --- /dev/null +++ b/backend/chat/src/types/index.ts @@ -0,0 +1,7 @@ +export interface IChatMessage { + id: string; + roomId: string; + senderId: string; + message: string; + createdAt: number; +} diff --git a/backend/chat/src/ws/events.ts b/backend/chat/src/ws/events.ts new file mode 100644 index 0000000000..1f6fcda0cd --- /dev/null +++ b/backend/chat/src/ws/events.ts @@ -0,0 +1,13 @@ +export const WS_CLIENT_EVENT = { + JOIN_ROOM: 'joinRoom', + LEAVE_ROOM: 'leaveRoom', + SEND_MESSAGE: 'sendMessage', + DISCONNECT: 'disconnect', +}; + +export const WS_SERVER_EVENT = { + JOINED_ROOM: 'joinedRoom', + LEFT_ROOM: 'leftRoof', + NEW_MESSAGE: 'newMessage', + MESSAGE_HISTORY: 'messageHistory', +}; diff --git a/backend/chat/src/ws/handlers.ts b/backend/chat/src/ws/handlers.ts new file mode 100644 index 0000000000..2a752ffcc5 --- /dev/null +++ b/backend/chat/src/ws/handlers.ts @@ -0,0 +1,88 @@ +import { eq } from 'drizzle-orm'; +import type { DefaultEventsMap, Server, Socket } from 'socket.io'; + +import { db } from '@/lib/db'; +import { chatMessages } from '@/lib/db/schema'; +import { logger } from '@/lib/utils'; +import type { IChatMessage } from '@/types'; + +import { WS_CLIENT_EVENT, WS_SERVER_EVENT } from './events'; + +type ISocketIOServer = Server; +type ISocketIOSocket = Socket; + +export const joinRoomHandler = + (socket: ISocketIOSocket) => + async (roomId?: string) => { + if (!roomId) { + logger.warn(`${WS_CLIENT_EVENT.JOIN_ROOM} event received without a roomId`); + return; + } + + socket.join(roomId); + logger.info(`Socket ${socket.id} joined room: ${roomId}`); + socket.emit(WS_SERVER_EVENT.JOINED_ROOM, roomId); + + try { + const messages = await db + .select() + .from(chatMessages) + .where(eq(chatMessages.roomId, roomId)) + .orderBy(chatMessages.createdAt) + .execute(); + + socket.emit(WS_SERVER_EVENT.MESSAGE_HISTORY, messages); + logger.info(`Sent message history to socket ${socket.id} for room ${roomId}`); + } catch (error) { + logger.error('Failed to fetch message history:', error); + socket.emit('error', 'Failed to load message history'); + } + }; + +export const leaveRoomHandler = + (socket: ISocketIOSocket) => + (roomId?: string) => { + if (roomId) { + socket.leave(roomId); + logger.info(`Socket ${socket.id} left room: ${roomId}`); + socket.emit(WS_SERVER_EVENT.LEFT_ROOM, roomId); + } else { + logger.warn(`${WS_CLIENT_EVENT.LEAVE_ROOM} event received without a roomId`); + } + }; + +export const sendMessageHandler = + (io: ISocketIOServer, socket: ISocketIOSocket) => + async (payload: Partial) => { + const { roomId, senderId, message } = payload; + + if (!roomId || !senderId || !message) { + const errorMessage = `${WS_CLIENT_EVENT.SEND_MESSAGE} event received with incomplete data`; + logger.warn(errorMessage); + socket.emit('error', errorMessage); + return; + } + + try { + const datetime = new Date(); + + await db.insert(chatMessages).values({ + roomId, + senderId, + message, + createdAt: datetime, + }); + + const messageData = { + roomId, + senderId, + message, + createdAt: datetime, + }; + socket.broadcast.to(roomId).emit(WS_SERVER_EVENT.NEW_MESSAGE, messageData); + logger.info(`Message from ${senderId} in room ${roomId}: ${message}`); + } catch (error) { + logger.error('Failed to save message:', error); + socket.emit('error', 'Failed to send message'); + } + }; diff --git a/backend/chat/src/ws/index.ts b/backend/chat/src/ws/index.ts new file mode 100644 index 0000000000..aad1ca831e --- /dev/null +++ b/backend/chat/src/ws/index.ts @@ -0,0 +1 @@ +export * from './main'; diff --git a/backend/chat/src/ws/main.ts b/backend/chat/src/ws/main.ts new file mode 100644 index 0000000000..bcb5dda339 --- /dev/null +++ b/backend/chat/src/ws/main.ts @@ -0,0 +1,33 @@ +import { createServer } from 'http'; + +import { Server } from 'socket.io'; + +import { UI_HOST } from '@/config'; +import { logger } from '@/lib/utils'; + +import { WS_CLIENT_EVENT } from './events'; +import { joinRoomHandler, leaveRoomHandler, sendMessageHandler } from './handlers'; + +export const createWs = (server: ReturnType) => { + const io = new Server(server, { + cors: { + origin: [UI_HOST], + credentials: true, + }, + path: '/chat-socket', + }); + + io.on('connection', (socket) => { + logger.info(`Socket ${socket.id} connected`); + + socket.on(WS_CLIENT_EVENT.JOIN_ROOM, joinRoomHandler(socket)); + socket.on(WS_CLIENT_EVENT.LEAVE_ROOM, leaveRoomHandler(socket)); + socket.on(WS_CLIENT_EVENT.SEND_MESSAGE, sendMessageHandler(io, socket)); + socket.on(WS_CLIENT_EVENT.DISCONNECT, () => { + logger.info(`Client disconnected: ${socket.id}`); + socket.disconnect(); + }); + }); + + return io; +}; diff --git a/backend/chat/tsconfig.json b/backend/chat/tsconfig.json new file mode 100644 index 0000000000..f0af9cb65c --- /dev/null +++ b/backend/chat/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + "baseUrl": ".", + /* Visit https://aka.ms/tsconfig to read more about this file */ + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "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. */, + // "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. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "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. */, + // "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. */ + "paths": { + "@/*": ["./src/*"] + } /* 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. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "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. */, + // "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. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + /* Interop Constraints */ + // "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. */, + // "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. */, + /* Type Checking */ + "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. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "exclude": ["drizzle.*.*ts"], + "ts-node": { + "swc": true, + "require": ["tsconfig-paths/register"] + } +} diff --git a/backend/collaboration/package.json b/backend/collaboration/package.json index 5a9d2df43c..25e16369ed 100644 --- a/backend/collaboration/package.json +++ b/backend/collaboration/package.json @@ -35,6 +35,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^22.5.5", + "@types/pg": "^8.11.10", "@types/ws": "^8.5.12", "nodemon": "^3.1.4", "pino-pretty": "^11.2.2", diff --git a/backend/collaboration/src/lib/y-postgres/persistence.ts b/backend/collaboration/src/lib/y-postgres/persistence.ts index 51e6f2747c..d8fa993de5 100644 --- a/backend/collaboration/src/lib/y-postgres/persistence.ts +++ b/backend/collaboration/src/lib/y-postgres/persistence.ts @@ -1,11 +1,48 @@ +import { Pool } from 'pg'; import { PostgresqlPersistence } from 'y-postgresql'; import * as Y from 'yjs'; import { dbConfig } from '@/config'; +import { logger } from '@/lib/utils'; import type { IWSSharedDoc } from '@/types/interfaces'; import { setPersistence } from './utils'; +// From y-postgresql +const defaultTableName = 'yjs-writings'; + +async function migrateTable() { + // Custom logic to add `updated_at` column if purging is desired + const p = new Pool(dbConfig); + const conn = await p.connect().then((client) => { + logger.info('Migration Client connected'); + return client; + }); + await conn + .query( + ` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = '${defaultTableName}' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE "${defaultTableName}" + ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + END IF; + END + $$ + ` + ) + .then(() => { + logger.info('Migration Complete'); + }); + p.end(); + logger.info('Migration Client disconnected'); +} + export const setUpPersistence = async () => { const pgdb = await PostgresqlPersistence.build(dbConfig); setPersistence({ @@ -34,4 +71,6 @@ export const setUpPersistence = async () => { }); }, }); + + await migrateTable(); }; diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index b4811aa4ca..6e5e6d6fcd 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -2,58 +2,71 @@ services: user-db: - hostname: "user-db" + hostname: 'user-db' image: postgres:16.4 - container_name: "user-db" + container_name: 'user-db' build: context: ./backend/user/src/lib/db env_file: - ./backend/user/.env.local volumes: - - "user-db-docker:${USER_PGDATA}" + - 'user-db-docker:${USER_PGDATA}' ports: - - "${USER_EXPRESS_DB_PORT}:5432" + - '${USER_EXPRESS_DB_PORT}:5432' restart: unless-stopped question-db: - hostname: "question-db" + hostname: 'question-db' image: postgres:16.4 - container_name: "question-db" + container_name: 'question-db' build: context: ./backend/question/src/lib/db env_file: - ./backend/question/.env.local volumes: - - "question-db-docker:${QUESTION_PGDATA}" + - 'question-db-docker:${QUESTION_PGDATA}' # - ./init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "${QUESTION_EXPRESS_DB_PORT}:5432" + - '${QUESTION_EXPRESS_DB_PORT}:5432' restart: unless-stopped collab-db: - hostname: "collab-db" + hostname: 'collab-db' image: postgres:16.4 - container_name: "collab-db" + container_name: 'collab-db' env_file: - ./backend/collaboration/.env.local volumes: - - "collab-db-docker:${COLLAB_PGDATA}" + - 'collab-db-docker:${COLLAB_PGDATA}' # - ./init.sql:/docker-entrypoint-initdb.d/init.sql ports: - - "${COLLAB_EXPRESS_DB_PORT}:5432" + - '${COLLAB_EXPRESS_DB_PORT}:5432' restart: unless-stopped match-db: - hostname: "match-db" + hostname: 'match-db' image: redis/redis-stack - container_name: "match-db" + container_name: 'match-db' env_file: - ./backend/matching/.env.local volumes: - - "match-db-docker:/data" + - 'match-db-docker:/data' ports: - - "${MATCHING_DB_HOST_MGMT_PORT}:8001" - - "${MATCHING_DB_HOST_PORT}:6379" + - '${MATCHING_DB_HOST_MGMT_PORT}:8001' + - '${MATCHING_DB_HOST_PORT}:6379' + restart: unless-stopped + + chat-db: + hostname: 'chat-db' + image: postgres:16.4 + container_name: 'chat-db' + env_file: + - ./backend/chat/.env.local + volumes: + - 'chat-db-docker:${CHAT_PGDATA}' + # - ./init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - '${CHAT_EXPRESS_DB_PORT}:5432' restart: unless-stopped # match-db-ui: @@ -64,7 +77,6 @@ services: # - "${MATCHING_DB_HOST_MGMT_PORT}:5540" # restart: unless-stopped - volumes: user-db-docker: external: true @@ -74,3 +86,5 @@ volumes: external: true match-db-docker: external: true + chat-db-docker: + external: true diff --git a/docker-compose.yaml b/docker-compose.yaml index a819f953f2..ce4e91c59c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -43,15 +43,15 @@ services: timeout: 10s collab-db: - hostname: "collab-db" + hostname: 'collab-db' image: postgres:16.4 - container_name: "collab-db" + container_name: 'collab-db' build: context: ./backend/collaboration/src/lib/db env_file: - ./backend/collaboration/.env.local volumes: - - "collab-db-docker:${COLLAB_PGDATA}" + - 'collab-db-docker:${COLLAB_PGDATA}' # - ./init.sql:/docker-entrypoint-initdb.d/init.sql restart: unless-stopped networks: @@ -63,6 +63,27 @@ services: start_period: 30s timeout: 10s + chat-db: + hostname: 'chat-db' + image: postgres:16.4 + container_name: 'chat-db' + build: + context: ./backend/chat/src/lib/db + env_file: + - ./backend/chat/.env.compose + volumes: + - 'chat-db-docker:${CHAT_PGDATA}' + # - ./init.sql:/docker-entrypoint-initdb.d/init.sql + restart: unless-stopped + networks: + - chat-db-network + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U peerprep-chat-express -d chat'] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + match-db: hostname: 'match-db' # To enable Admin UI for cluster ↙️ @@ -137,9 +158,9 @@ services: - question-api-network healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:${QUESTION_EXPRESS_PORT}/health || exit 1 - interval: 30s - timeout: 10s - retries: 5 + interval: 30s + timeout: 10s + retries: 5 start_period: 5s collab-service: @@ -168,9 +189,9 @@ services: - collab-api-network healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:${COLLAB_EXPRESS_PORT}/health || exit 1 - interval: 30s - timeout: 10s - retries: 5 + interval: 30s + timeout: 10s + retries: 5 start_period: 5s matching-service: @@ -180,7 +201,7 @@ services: context: ./backend/matching dockerfile: express.Dockerfile target: production - args: + args: # For building with the correct env vars - port=${MATCHING_EXPRESS_PORT} env_file: @@ -205,9 +226,40 @@ services: - collab-api-network healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:${MATCHING_EXPRESS_PORT}/health || exit 1 - interval: 30s - timeout: 10s - retries: 5 + interval: 30s + timeout: 10s + retries: 5 + start_period: 5s + + chat-service: + image: 'chat-express' + container_name: '${CHAT_SERVICE_NAME}' + build: + context: ./backend/chat + dockerfile: express.Dockerfile + target: production + args: + # For building with the correct env vars + - port=${CHAT_EXPRESS_PORT} + env_file: + - ./backend/chat/.env.compose + environment: + # Docker Compose Specific for Service Discovery + - EXPRESS_DB_HOST=chat-db + - EXPRESS_DB_PORT=5432 + - PEERPREP_UI_HOST=http://${FRONTEND_SERVICE_NAME}:${FRONTEND_PORT} + depends_on: + chat-db: + condition: service_healthy + restart: true + networks: + - chat-db-network + - chat-api-network + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:${CHAT_EXPRESS_PORT}/health || exit 1 + interval: 30s + timeout: 10s + retries: 5 start_period: 5s # Frontend @@ -230,6 +282,7 @@ services: - VITE_COLLAB_SERVICE=http://${COLLAB_SERVICE_NAME}:${COLLAB_EXPRESS_PORT} - VITE_COLLAB_WS=ws://${COLLAB_SERVICE_NAME}:${COLLAB_EXPRESS_PORT} - VITE_MATCHING_SERVICE=http://${MATCHING_SERVICE_NAME}:${MATCHING_EXPRESS_PORT} + - VITE_CHAT_SERVICE=http://${CHAT_SERVICE_NAME}:${CHAT_EXPRESS_PORT} - FRONTEND_PORT=${FRONTEND_PORT} depends_on: user-service: @@ -241,24 +294,33 @@ services: matching-service: condition: service_healthy restart: true + collab-service: + condition: service_healthy + restart: true + chat-service: + condition: service_healthy + restart: true networks: - user-api-network - question-api-network - match-api-network - collab-api-network + - chat-api-network volumes: # Persistent Volumes for Databases user-db-docker: external: true question-db-docker: - external: true + external: true # Persistent Room Server collab-db-docker: external: true # Redis Match server match-db-docker: external: true + chat-db-docker: + external: true networks: # Isolated API Server Networks @@ -270,6 +332,8 @@ networks: driver: bridge match-db-network: driver: bridge + chat-db-network: + driver: bridge # View-Controller Networks user-api-network: @@ -280,3 +344,5 @@ networks: driver: bridge match-api-network: driver: bridge + chat-api-network: + driver: bridge diff --git a/frontend/.env.docker b/frontend/.env.docker index bc248fff11..a2388559c5 100644 --- a/frontend/.env.docker +++ b/frontend/.env.docker @@ -4,4 +4,6 @@ VITE_USER_SERVICE=http://host.docker.internal:9001 VITE_QUESTION_SERVICE=http://host.docker.internal:9002 VITE_COLLAB_SERVICE=http://host.docker.internal:9003 VITE_MATCHING_SERVICE=http://host.docker.internal:9004 -FRONTEND_PORT=3000 \ No newline at end of file +VITE_CHAT_SERVICE=http://host.docker.internal:9005 +FRONTEND_PORT=3000 +OPENAI_API_KEY=PUT_YOUR_OPENAI_API_KEY_HERE \ No newline at end of file diff --git a/frontend/.env.local b/frontend/.env.local index e6d1d250f2..4e79901707 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -4,3 +4,5 @@ VITE_USER_SERVICE=http://localhost:9001 VITE_QUESTION_SERVICE=http://localhost:9002 VITE_COLLAB_SERVICE=http://localhost:9003 VITE_MATCHING_SERVICE=http://localhost:9004 +VITE_CHAT_SERVICE=http://localhost:9005 +OPENAI_API_KEY=PUT_YOUR_OPENAI_API_KEY_HERE diff --git a/frontend/README.md b/frontend/README.md index b8cca666ad..b23c312879 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,21 +2,22 @@ ## Running with Docker (Standalone) -1. Run this command to build: +1. Enter your OPEN AI Api Key in the .env.docker file. +2. Run this command to build: ```sh docker build \ --build-arg FRONTEND_PORT=3000 \ -t frontend-app -f frontend.Dockerfile . ``` -2. Run this command, from the root folder: +3. Run this command, from the root folder: ```sh make db-up ``` -3. Run the necessary migrate and seed commands, if you haven't yet. +4. Run the necessary migrate and seed commands, if you haven't yet. -4. Run this command to expose the container: +5. Run this command to expose the container: ```sh docker run -p 3000:3000 --env-file ./.env.docker frontend-app ``` diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 96f0808fef..28cb6185ad 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh -envsubst '${FRONTEND_PORT} ${VITE_USER_SERVICE} ${VITE_QUESTION_SERVICE} ${VITE_COLLAB_SERVICE} ${VITE_MATCHING_SERVICE}' < /etc/nginx/nginx.conf.template > /etc/nginx/conf.d/default.conf +envsubst '${FRONTEND_PORT} ${VITE_USER_SERVICE} ${VITE_QUESTION_SERVICE} ${VITE_COLLAB_SERVICE} ${VITE_COLLAB_WS} ${VITE_MATCHING_SERVICE} ${VITE_CHAT_SERVICE}' < /etc/nginx/nginx.conf.template > /etc/nginx/conf.d/default.conf nginx -g 'daemon off;' \ No newline at end of file diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index e30c35ba03..167d6def38 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -69,6 +69,17 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /chat-socket/ { + proxy_pass ${VITE_CHAT_SERVICE}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 455f8cab19..1f2b131e72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-visually-hidden": "^1.1.0", "@replit/codemirror-vscode-keymap": "^6.0.2", "@tanstack/react-query": "^5.56.2", @@ -51,6 +52,7 @@ "remark-math": "^6.0.0", "socket.io-client": "^4.8.0", "tailwind-merge": "^2.5.2", + "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", "ws": "^8.18.0", "y-codemirror.next": "^0.3.5", diff --git a/frontend/src/components/blocks/interview/ai-chat.tsx b/frontend/src/components/blocks/interview/ai-chat.tsx new file mode 100644 index 0000000000..684ff7b14b --- /dev/null +++ b/frontend/src/components/blocks/interview/ai-chat.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; + +import { ChatLayout } from './chat/chat-layout'; +import { ChatMessageType } from './chat/chat-message'; + +// Types for OpenAI API +// interface OpenAIMessage { +// role: 'user' | 'assistant'; +// content: string; +// } + +interface AIChatProps { + isOpen: boolean; + onClose: () => void; +} + +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 [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const callOpenAI = async (userMessage: string): Promise => { + if (!API_KEY) { + throw new Error('OpenAI API key is not configured'); + } + + const openAIMessages = messages.map((msg) => ({ + role: msg.isUser ? 'user' : 'assistant', + content: msg.text, + })); + + openAIMessages.push({ role: 'user', content: userMessage }); + + const response = await fetch(API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'system', + content: 'You are a helpful coding assistant. Provide concise, accurate answers.', + }, + ...openAIMessages, + ], + temperature: 0.7, + max_tokens: 500, + }), + }); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + const data = await response.json(); + return data.choices[0].message.content; + }; + + const handleSend = async (userMessage: string): Promise => { + if (!userMessage.trim() || isLoading) return; + + setMessages((prev) => [...prev, { text: userMessage, isUser: true, timestamp: new Date() }]); + setIsLoading(true); + setError(null); + + try { + const response = await callOpenAI(userMessage); + setMessages((prev) => [...prev, { text: response, isUser: false, timestamp: new Date() }]); + } catch (err) { + setError( + err instanceof Error ? err.message : 'An error occurred while fetching the response' + ); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/components/blocks/interview/chat/chat-layout.tsx b/frontend/src/components/blocks/interview/chat/chat-layout.tsx new file mode 100644 index 0000000000..e58116894c --- /dev/null +++ b/frontend/src/components/blocks/interview/chat/chat-layout.tsx @@ -0,0 +1,145 @@ +import { Loader2, MessageSquare, Send, X } from 'lucide-react'; +import React, { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from 'react'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Textarea } from '@/components/ui/textarea'; + +import { ChatMessage, ChatMessageType } from './chat-message'; + +interface ChatLayoutProps { + isOpen: boolean; + onClose: () => void; + messages: ChatMessageType[]; + onSend: (message: string) => void; + isLoading: boolean; + error: string | null; + title: string; +} + +export const ChatLayout: React.FC = ({ + isOpen, + onClose, + messages, + onSend, + isLoading, + error, + title, +}) => { + const [input, setInput] = useState(''); + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + // Focus and scroll to bottom on window open + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + scrollToBottom(); + } + }, [isOpen]); + + // Scroll to bottom on reception of messages + useEffect(() => { + scrollToBottom(); + }, [messages, isLoading]); + + // Resize textarea on input, up to a maximum height + useEffect(() => { + const textAreaEl = inputRef.current; + + if (textAreaEl) { + textAreaEl.style.height = 'auto'; + textAreaEl.style.height = `${Math.min(textAreaEl.scrollHeight, 100)}px`; + } + }, [input]); + + const handleSend = () => { + if (input.trim()) { + onSend(input.trim()); + setInput(''); + } + }; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+
+
+

{title}

+
+
+ +
+
+ + + {messages.length === 0 && ( +
+ +

No messages yet. Start a conversation!

+
+ )} + {messages.map((msg, index) => ( + + ))} + {isLoading && ( +
+
+ +
+
+ )} + {error && ( + + {error} + + )} +
+ + +
+
+