diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..175f95f --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.gitignore +.env \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6c858e0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:24-alpine + +WORKDIR /app + +# Copy package.json and package-lock.json (or yarn.lock) to leverage Docker's layer caching +COPY package*.json ./ + +# Copy the rest of the application code +COPY . . + +# Install dependencies +RUN npm install && npx prisma generate && npm run build + +# Expose the port the app runs on +EXPOSE 3000 + +# Command to run the application when the container starts +CMD ["npm", "start"] \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index d2cc811..b56f0d8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,9 +11,10 @@ "dependencies": { "@fastify/autoload": "^6.3.1", "@fastify/cors": "^11.2.0", + "@fastify/sse": "^0.4.0", "@prisma/adapter-neon": "^7.2.0", "@prisma/client": "^7.2.0", - "fastify": "^5.6.2", + "fastify": "5.8.1", "fastify-type-provider-zod": "^6.1.0", "jose": "^6.1.3", "unique-names-generator": "^4.7.1", @@ -684,6 +685,21 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/sse": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@fastify/sse/-/sse-0.4.0.tgz", + "integrity": "sha512-bBV96iT2kHEw6h3i8IMkZGaqA7Gk81ugUzTNctXuE6N2BEC/qBnUuzlD/O17V43OkJP73h0/kf3Bp/asXlSuFA==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "fastify": "^5.x" + } + }, "node_modules/@fastify/swagger": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.6.1.tgz", @@ -1422,9 +1438,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz", - "integrity": "sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.1.tgz", + "integrity": "sha512-y0kicFvvn7CYWoPOVLOcvn4YyKQz03DIY7UxmyOy21/J8eXm09R+tmb+tVDBW5h+pja30cHI5dqUcSlvY86V2A==", "funding": [ { "type": "github", @@ -1438,7 +1454,7 @@ "license": "MIT", "peer": true, "dependencies": { - "@fastify/ajv-compiler": "^4.0.0", + "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", @@ -1447,7 +1463,7 @@ "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", - "pino": "^10.1.0", + "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", diff --git a/backend/package.json b/backend/package.json index 1ad66d7..769ded8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx watch --env-file=.env src/index.ts", "build": "tsc", - "start": "node dist/index.js", + "start": "node dist/src/index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], @@ -16,9 +16,10 @@ "dependencies": { "@fastify/autoload": "^6.3.1", "@fastify/cors": "^11.2.0", + "@fastify/sse": "^0.4.0", "@prisma/adapter-neon": "^7.2.0", "@prisma/client": "^7.2.0", - "fastify": "^5.6.2", + "fastify": "5.8.1", "fastify-type-provider-zod": "^6.1.0", "jose": "^6.1.3", "unique-names-generator": "^4.7.1", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 06f3c3f..c18c7fa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,14 +8,17 @@ datasource db { } model Admin { - id Int @id @default(autoincrement()) - telegram_id String @unique + id Int @id @default(autoincrement()) + telegram_id String @unique + expiryDate DateTime } model QueueConfig { id Int @id @default(autoincrement()) positionBeforePing Int isOpen Boolean + eventName String + venue String } model Queue { @@ -23,9 +26,3 @@ model Queue { telegram_id String @unique timeCreated DateTime } - -model AdminRequester { - id Int @id @default(autoincrement()) - telegram_id String @unique - telegram_username String -} diff --git a/backend/src/index.ts b/backend/src/index.ts index 3c70ecf..8d3dc51 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,9 +8,11 @@ import { serializerCompiler, type ZodTypeProvider } from 'fastify-type-provider-zod'; -import queueConfigPlugin from "./queueConfigPlugin.js"; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import fastifySSE from "@fastify/sse"; +import {queueHandlerPlugin} from "./queueHandlerPlugin.js"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -28,10 +30,12 @@ fastify.setSerializerCompiler(serializerCompiler); await fastify.register(cors, { origin: true, // set this to frontend url for production methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], - allowedHeaders: ['Content-Type', 'Authorization', 'User-Id'], + allowedHeaders: ['Content-Type', 'Authorization', 'User-Id', 'last-event-id'], credentials: true, }); +await fastify.register(fastifySSE.default); + const authHook = async (request: FastifyRequest, reply: FastifyReply) => { const secret = new TextEncoder().encode( @@ -54,7 +58,7 @@ const authHook = async (request: FastifyRequest, reply: FastifyReply) => { // Init sql db connection fastify.register(prismaPlugin); // Register customs plugins -fastify.register(queueConfigPlugin); +fastify.register(queueHandlerPlugin); fastify.register((fastify) => { diff --git a/backend/src/queueConfigPlugin.ts b/backend/src/queueConfigPlugin.ts deleted file mode 100644 index 0097bdb..0000000 --- a/backend/src/queueConfigPlugin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import fp from 'fastify-plugin' -import type {FastifyPluginAsync} from 'fastify' -import type {QueueConfigModel} from "./generated/prisma/models/QueueConfig.js"; - -const queueConfigPlugin: FastifyPluginAsync = fp(async (fastify, _) => { - - let cacheConfig: QueueConfigModel | undefined; - - // QueueConfig should only be accessed through this function to reduce calls to DB - - // force if true forces a db fetch - let getQueueConfig: (force?: boolean) => Promise = async (force?: boolean) => { - if (cacheConfig != null && !force) { - return cacheConfig!; - } - await fastify.prisma.queueConfig.findFirst().then((queueConfig) => { - if (queueConfig == null) { - throw new Error("No queue configured"); - } else { - cacheConfig = queueConfig; - } - }); - return cacheConfig!; - } - - fastify.decorate("getQueueConfig", getQueueConfig); - -}) - -export default queueConfigPlugin \ No newline at end of file diff --git a/backend/src/queueHandlerPlugin.ts b/backend/src/queueHandlerPlugin.ts new file mode 100644 index 0000000..e5172ac --- /dev/null +++ b/backend/src/queueHandlerPlugin.ts @@ -0,0 +1,115 @@ +import fp from 'fastify-plugin' +import {type FastifyInstance, type FastifyPluginAsync} from 'fastify' +import type {SSEReplyInterface} from "@fastify/sse"; +import type {QueueConfigModel} from "./generated/prisma/models/QueueConfig.js"; +import type {QueueModel} from "./generated/prisma/models/Queue.js"; + +enum PayloadType { + CONFIG = "CFG", + LIST = "LST", +} + +export class QueueHandler { + + private users: SSEReplyInterface[] + private admins: SSEReplyInterface[] + private cacheConfig: QueueConfigModel | undefined; + private cacheEntries: QueueModel[] | undefined; + private fastify: FastifyInstance; + + constructor(fastify: FastifyInstance) { + this.users = []; + this.admins = []; + this.fastify = fastify; + this.getQueueConfig(); + } + + public async updateQueue(transform: Promise): Promise { + return transform.then(async (_) => { + this.cacheEntries = await this.fastify.prisma.queue.findMany({ + orderBy: {timeCreated: "asc"}, + }); + this.notify({type: PayloadType.LIST, data: null}, true); + this.notify({type: PayloadType.LIST, data: null}, false); + return this.cacheEntries; + }); + } + + public async getQueueEntries(): Promise { + if (this.cacheEntries != null) { + return this.cacheEntries!; + } + try { + this.cacheEntries = await this.fastify.prisma.queue.findMany({ + orderBy: {timeCreated: "asc"}, + }); + } catch (_) { + throw new Error("Failed to fetch queue entries"); + } + return this.cacheEntries; + } + + // QueueConfig should only be modified through this function to ensure synchronization + public async updateQueueConfig(config: QueueConfigModel): Promise { + return this.fastify.prisma.queueConfig.update({ + where: { + id: config.id + }, + data: config + }).then((result) => { + this.notify({type: PayloadType.CONFIG, data: result}, true); + this.notify({type: PayloadType.CONFIG, data: result}, false); + this.cacheConfig = result; + return result; + }); + } + + // QueueConfig should only be accessed through this function to reduce calls to DB + // force if true forces a db fetch + public async getQueueConfig(force?: boolean): Promise { + if (this.cacheConfig != null && !force) { + return this.cacheConfig!; + } + await this.fastify.prisma.queueConfig.findFirst().then((queueConfig) => { + if (queueConfig == null) { + throw new Error("No queue configured"); + } else { + this.cacheConfig = queueConfig; + } + }); + return this.cacheConfig!; + } + + public addConnection(conn: SSEReplyInterface, isAdmin: boolean): void { + if (isAdmin) { + this.admins.push(conn); + } else { + this.users.push(conn); + } + } + + private notify(payload: any, admin: boolean) : void { + + this.users = this.users.filter((conn: SSEReplyInterface) => conn.isConnected); + this.admins = this.admins.filter((conn: SSEReplyInterface) => conn.isConnected); + + (admin ? this.admins : this.users).forEach((conn: SSEReplyInterface) => { + conn.send({ + id: "1", + event: 'update', + data: payload, + }); + }) + + } + +} + +export const queueHandlerPlugin: FastifyPluginAsync = fp(async (fastify, _) => { + + let queueHandler = new QueueHandler(fastify); + + fastify.decorate('queueHandler', queueHandler); + +}) + diff --git a/backend/src/routes/private/admins/requests/index.ts b/backend/src/routes/private/admins/requests/index.ts deleted file mode 100644 index ca643bc..0000000 --- a/backend/src/routes/private/admins/requests/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import {isAdmin} from "../../../../shared.js"; -import type {FastifyPluginAsyncZod} from "fastify-type-provider-zod"; -import {z} from 'zod'; - -const route: FastifyPluginAsyncZod = async (fastify, _) => { - - fastify.get('/', {preHandler: isAdmin}, async (request, reply) => { - await fastify.prisma.adminRequester.findMany().then((requesters) => { - return reply.code(200).send(requesters); - }).catch((e) => { - reply.code(500); - throw new Error(e.message); - }); - }); - - // create new admin request - fastify.post('/:targetId', { - schema: { - params: z.object({ - targetId: z.string() - }), - querystring: z.object({ - username: z.string() - }), - },}, async (request, reply) => { - - const {targetId} = request.params; - const {username} = request.query; - - await fastify.prisma.adminRequester.create({data: {telegram_id: targetId, telegram_username: username}}).then( - (requester) => { - return reply.code(200).send(requester); - } - ).catch((e) => { - if (e.code == "P2002") { - reply.code(400); - throw new Error("Request for user already exists"); - } - reply.code(500); - throw new Error(e.message); - }); - - }); - - // accepts or reject an admin request - fastify.patch('/:targetId', { - schema: { - params: z.object({ - targetId: z.string() - }), - querystring: z.object({ - accepts: z.stringbool() - }) - }, - preHandler: isAdmin}, async (request, reply) => { - - const {targetId} = request.params; - const {accepts} = request.query; - - await fastify.prisma.adminRequester.findUnique({ where: {telegram_id: targetId} }).then((requester) => { - if (requester == null) { - reply.code(404); - throw new Error("Requester not found"); - } - }).catch((e) => { - reply.code(500); - throw new Error(e.message); - }); - - await fastify.prisma.$transaction(async (tx) => { - await tx.adminRequester.delete({where: {telegram_id: targetId}}); - if (accepts) { - await tx.admin.create({ - data: { - telegram_id: targetId, - } - }).catch((e) => { - if (e.code == "P2002") { - reply.code(400); - throw new Error("User is already admin"); - } - }); - return reply.code(200).send({message: "Admin added successfully"}); - } else { - return reply.code(200).send({message: "Admin request denied"}); - } - }).catch((e) => { - reply.code(500); - throw new Error(e.message); - }); - - }); - - - -}; - -export default route; \ No newline at end of file diff --git a/backend/src/routes/private/queue/config.ts b/backend/src/routes/private/queue/config.ts index 7f91138..e297f17 100644 --- a/backend/src/routes/private/queue/config.ts +++ b/backend/src/routes/private/queue/config.ts @@ -7,7 +7,7 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { // get config parameters fastify.get('/config', {preHandler: isAdmin}, async (_, reply) => { - return reply.code(200).send(await fastify.getQueueConfig()) + return reply.code(200).send(await fastify.queueHandler.getQueueConfig()) }); // update config parameters @@ -15,10 +15,12 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { preHandler: isAdmin, schema: { querystring: z.object({ - positionBeforePing: z.coerce.number() + positionBeforePing: z.coerce.number(), + venue: z.coerce.string(), + eventName: z.coerce.string() })} }, async (request, reply) => { - let config = await fastify.prisma.queueConfig.findFirst().then((queueConfig) => queueConfig); + let config = await fastify.queueHandler.getQueueConfig(); if (config === null) { reply.code(500); @@ -26,15 +28,10 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { } config.positionBeforePing = request.query.positionBeforePing; + config.venue = request.query.venue; + config.eventName = request.query.eventName; - await fastify.prisma.queueConfig.update({ - where: { - id: config.id - }, - data: config - }).then(async (config) => { - // refresh local copy of queueConfig - await fastify.getQueueConfig(true); + await fastify.queueHandler.updateQueueConfig(config).then(async (config) => { return reply.code(200).send(config); }).catch((e) => { reply.code(500); diff --git a/backend/src/routes/private/queue/entries/index.ts b/backend/src/routes/private/queue/entries/index.ts index ffa5a48..3c8a9dd 100644 --- a/backend/src/routes/private/queue/entries/index.ts +++ b/backend/src/routes/private/queue/entries/index.ts @@ -5,6 +5,15 @@ import {z} from "zod"; const route: FastifyPluginAsyncZod = async (fastify) => { + + fastify.post('/subscribe', {preHandler: isAdmin, sse: true}, async (request, reply) => { + reply.sse.keepAlive(); + fastify.queueHandler.addConnection(reply.sse, true); + // instruct NGINX to not buffer response + reply.header('X-Accel-Buffering', 'no'); + await reply.sse.send({ data: 'Connected'}) + }); + /** * GET /queue/entries * Returns the entire queue ordered from first to last (admin-only) @@ -15,9 +24,7 @@ const route: FastifyPluginAsyncZod = async (fastify) => { async (_request, reply) => { try { - const entries = await fastify.prisma.queue.findMany({ - orderBy: {timeCreated: "asc"}, - }); + const entries = await fastify.queueHandler.getQueueEntries(); return reply.code(200).send({entries}); } catch (e: any) { reply.code(500); @@ -34,33 +41,23 @@ const route: FastifyPluginAsyncZod = async (fastify) => { }), }, }, async (request, reply) => { - await fastify.prisma.queue + + await fastify.queueHandler.updateQueue(fastify.prisma.queue .delete({ where: {telegram_id: request.params.targetId}, - }) - .then( async () => { - - try { - const entries = await fastify.prisma.queue.findMany({ - orderBy: {timeCreated: "asc"}, - }); - return reply.code(200).send({entries}); - } catch (e: any) { - reply.code(500); - throw new Error("Failed to fetch updated queue entries"); - } - - }) - .catch((e: any) => { - // Prisma "record not found" error code (user not in queue) - if (e?.code === "P2025") { - reply.code(400); - throw new Error("User not in queue"); - } + })).then(async (entries) => { + return reply.code(200).send({entries}); + }).catch((e: any) => { + // Prisma "record not found" error code (user not in queue) + if (e?.code === "P2025") { + reply.code(400); + throw new Error("User not in queue"); + } + + reply.code(500); + throw new Error(e?.message ?? "Failed to remove user"); + }); - reply.code(500); - throw new Error(e?.message ?? "Failed to remove user"); - }); }) /** @@ -73,7 +70,7 @@ const route: FastifyPluginAsyncZod = async (fastify) => { const userId = request.userId!; // Queue must exist and be open - if (!(await fastify.getQueueConfig()).isOpen) { + if (!(await fastify.queueHandler.getQueueConfig()).isOpen) { reply.code(400); throw new Error("Queue is closed"); } @@ -94,20 +91,15 @@ const route: FastifyPluginAsyncZod = async (fastify) => { }); // Add user to queue - await fastify.prisma.queue.create({ + const allEntries = await fastify.queueHandler.updateQueue(fastify.prisma.queue.create({ data: { name: name, telegram_id: userId, timeCreated: new Date(), }, - }); + })) // Compute position and number of people ahead (for frontend) - const allEntries = await fastify.prisma.queue.findMany({ - orderBy: {timeCreated: "asc"}, - select: {telegram_id: true}, - }); - const position = allEntries.findIndex((e) => e.telegram_id === userId) + 1; diff --git a/backend/src/routes/private/queue/entries/me/index.ts b/backend/src/routes/private/queue/entries/me/index.ts index 632cb0a..a3eefb2 100644 --- a/backend/src/routes/private/queue/entries/me/index.ts +++ b/backend/src/routes/private/queue/entries/me/index.ts @@ -2,16 +2,24 @@ import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"; const route: FastifyPluginAsyncZod = async (fastify) => { + fastify.post('/subscribe', {sse: true}, async (request, reply) => { + reply.sse.keepAlive(); + fastify.queueHandler.addConnection(reply.sse, false); + // instruct NGINX to not buffer response + reply.header('X-Accel-Buffering', 'no'); + await reply.sse.send({ data: 'Connected'}) + }); + /** * DELETE /queue/entries/me * Delete the user from the queue */ fastify.delete("/", async (request, reply) => { - await fastify.prisma.queue - .delete({ - where: { telegram_id: request.userId! }, - }) - .then(() => { + + await fastify.queueHandler.updateQueue(fastify.prisma.queue + .delete({ + where: { telegram_id: request.userId! }, + })).then(() => { return reply.code(200).send({ left: true }); }) .catch((e: any) => { @@ -33,17 +41,15 @@ const route: FastifyPluginAsyncZod = async (fastify) => { fastify.get("/", async (request, reply) => { const userId = request.userId; - // Queue must exist and be open + // Queue must exist const config = await fastify.prisma.queueConfig.findFirst(); - if (!config || !config.isOpen) { + if (!config) { reply.code(500); throw new Error("No queue configured"); } // Fetch queue in order and locate the user - const entries = await fastify.prisma.queue.findMany({ - orderBy: { timeCreated: "asc" }, - }); + const entries = await fastify.queueHandler.getQueueEntries(); const index = entries.findIndex((e) => e.telegram_id === userId); diff --git a/backend/src/routes/private/queue/info.ts b/backend/src/routes/private/queue/info.ts new file mode 100644 index 0000000..5b11b9b --- /dev/null +++ b/backend/src/routes/private/queue/info.ts @@ -0,0 +1,14 @@ +import type {FastifyPluginAsyncZod} from "fastify-type-provider-zod"; +const route: FastifyPluginAsyncZod = async (fastify, _) => { + + fastify.get('/info', async (request, reply) => { + const config = (await fastify.queueHandler.getQueueConfig()); + return reply.code(200).send({ + status: config.isOpen, venue: config.venue, + eventName: config.eventName, positionBeforePing: config.positionBeforePing + }); + }); + +}; + +export default route; diff --git a/backend/src/routes/private/queue/next.ts b/backend/src/routes/private/queue/next.ts index 4428ddf..1a4d0a7 100644 --- a/backend/src/routes/private/queue/next.ts +++ b/backend/src/routes/private/queue/next.ts @@ -7,9 +7,7 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { fastify.post('/next', {preHandler: isAdmin}, async (request, reply) => { - const allEntries = await fastify.prisma.queue.findMany({ - orderBy: {timeCreated: "asc"}, - }); + const allEntries = await fastify.queueHandler.getQueueEntries(); if (allEntries.length === 0) { return reply.code(200).send({entries: []}); @@ -17,23 +15,24 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { const top = allEntries.shift(); // remove top user from the queue - await fastify.prisma.queue.delete({where: {telegram_id: top!.telegram_id}}); + await fastify.queueHandler.updateQueue(fastify.prisma.queue.delete({where: {telegram_id: top!.telegram_id}})); + + const config = await fastify.queueHandler.getQueueConfig(); // pings the top n user - for (let i = 0; i < (await fastify.getQueueConfig()).positionBeforePing; i++) { + for (let i = 0; i < config.positionBeforePing; i++) { if (allEntries[i] == undefined) { break; } let message; if (i == 0) { - message = `IT'S YOUR TURN NOW!!! Come Quickly to Cendana CR18` + message = `IT'S YOUR TURN NOW!!! Come Quickly to ${config.venue}` } else { - message = `Your turn is coming up! Only ${i} person ahead.\nPlease start making your way to Cendana CR18.` + message = `Your turn is coming up! Only ${i} person ahead.\nPlease start making your way to ${config.venue}.` } const queryString = new URLSearchParams( {'chat_id': allEntries[i]!.telegram_id, 'text': message, 'parse_mode': 'Markdown'}).toString(); - console.log(message); - // hit Telegram API to send user message + //hit Telegram API to send user message // fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage?${queryString}`, { // method: 'POST', // }) diff --git a/backend/src/routes/private/queue/status.ts b/backend/src/routes/private/queue/status.ts index 65cb4d3..335493a 100644 --- a/backend/src/routes/private/queue/status.ts +++ b/backend/src/routes/private/queue/status.ts @@ -2,17 +2,8 @@ import {z} from 'zod'; import type {FastifyPluginAsyncZod} from "fastify-type-provider-zod"; import {isAdmin} from "../../../shared.js"; - const route: FastifyPluginAsyncZod = async (fastify, _) => { - /** - * GET /queue/status - * Checks if the queue is open - */ - fastify.get('/status', async (request, reply) => { - return reply.code(200).send({status: (await fastify.getQueueConfig()).isOpen}); - }); - /** * PATCH /queue/status (admin-only) * Opens/closes the queue @@ -24,7 +15,7 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { open: z.stringbool(), })} }, async (request, reply) => { - let config = await fastify.prisma.queueConfig.findFirst().then((queueConfig) => queueConfig); + let config = await fastify.queueHandler.getQueueConfig(); if (config === null) { reply.code(500); @@ -33,14 +24,7 @@ const route: FastifyPluginAsyncZod = async (fastify, _) => { config.isOpen = request.query.open; - await fastify.prisma.queueConfig.update({ - where: { - id: config.id - }, - data: config - }).then(async (config) => { - // update local copy of queueConfig - await fastify.getQueueConfig(true); + await fastify.queueHandler.updateQueueConfig(config).then(async (config) => { return reply.code(200).send(config); }).catch((e) => { reply.code(500); diff --git a/backend/src/routes/public/auth/index.ts b/backend/src/routes/public/auth/index.ts index d88fe44..1219fdb 100644 --- a/backend/src/routes/public/auth/index.ts +++ b/backend/src/routes/public/auth/index.ts @@ -81,7 +81,7 @@ const route: FastifyPluginAsync = async (fastify, _) => { // Generate a valid JWT for development purpose if (process.env.NODE_ENV === 'development') { - return reply.code(200).send({token: await newJWT("2202843044"), type: "user"}); + return reply.code(200).send({token: await newJWT("2202843044"), type: "admin"}); } const [authType, authData = ''] = (request.headers.authorization || '').split(' '); diff --git a/backend/src/routes/public/index.ts b/backend/src/routes/public/index.ts deleted file mode 100644 index 5e799d6..0000000 --- a/backend/src/routes/public/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type {FastifyPluginAsync} from "fastify"; - -// dummy example endpoint -// TODO: remove later -const route: FastifyPluginAsync = async (fastify, _) => { - - fastify.get('/', async (request, reply) => { - // get number of admins - fastify.prisma.admin.count().then((count) => console.log(count)); - - }); - -}; - -export default route; \ No newline at end of file diff --git a/backend/src/types.ts b/backend/src/types.ts index fbe5b6c..5ffc757 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,6 +1,6 @@ import 'fastify'; import type {PrismaClient} from "./generated/prisma/client.js"; -import type {QueueConfigModel} from "./generated/prisma/models/QueueConfig.js"; +import type {QueueHandler} from "./queueHandlerPlugin.js"; declare module 'fastify' { interface FastifyRequest { @@ -11,7 +11,7 @@ declare module 'fastify' { declare module 'fastify' { interface FastifyInstance { prisma: PrismaClient - getQueueConfig(force?: boolean): Promise + queueHandler: QueueHandler } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4417eb1..b3ab8a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { - "name": "reactjs-template", + "name": "nusc-queuebot-frontend", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "reactjs-template", + "name": "nusc-queuebot-frontend", "version": "0.0.1", "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@tailwindcss/vite": "^4.1.18", "@telegram-apps/telegram-ui": "^2.1.9", "@tma.js/sdk-react": "^3.0.8", @@ -806,6 +807,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0a4eb78..4965a4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "predeploy": "npm run build" }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@tailwindcss/vite": "^4.1.18", "@telegram-apps/telegram-ui": "^2.1.9", "@tma.js/sdk-react": "^3.0.8", diff --git a/frontend/src/components/SettingsAccordion.tsx b/frontend/src/components/SettingsAccordion.tsx new file mode 100644 index 0000000..2541388 --- /dev/null +++ b/frontend/src/components/SettingsAccordion.tsx @@ -0,0 +1,161 @@ +import {useState, useEffect} from 'react'; +import {ChevronDown, Check} from 'lucide-react'; +import {Settings} from "@/pages/AdminDashboard.tsx"; + +interface SettingsAccordionProps { + settings: Settings; + onSettingsChange: (settings: Settings) => void; + onSaveSettings: (settings: Settings) => Promise; + userType: 'admin' | 'user'; +} + +export function SettingsAccordion({settings, onSettingsChange, onSaveSettings, userType}: SettingsAccordionProps) { + const [isOpen, setIsOpen] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + const [formData, setFormData] = useState({ + eventName: settings.eventName, + venue: settings.venue, + positionBeforePing: settings.positionBeforePing.toString(), + }); + + useEffect(() => { + setFormData({ + eventName: settings.eventName, + venue: settings.venue, + positionBeforePing: settings.positionBeforePing.toString(), + }); + }, [settings]); + + if (userType !== 'admin') return null; + + const handleChange = (e: React.ChangeEvent) => { + const {name, value} = e.target; + setFormData((prev) => ({...prev, [name]: value})); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const updatedSettings = { + eventName: formData.eventName, + venue: formData.venue, + positionBeforePing: parseInt(formData.positionBeforePing, 10) || 0, + } + + onSettingsChange(updatedSettings); + + onSaveSettings(updatedSettings).then((res) => { + if (res.status) { + setShowSuccess(true); + setTimeout(() => setShowSuccess(false), 2000); + } + }); + }; + + return ( +
+ + + {isOpen && ( +
+ + {/* Event Name */} +
+ + +
+ + {/* Venue */} +
+ + +
+ + {/* Notify Before */} +
+ + +
+ + {/* Save Button */} + +
+ )} + + {/* Success Modal */} + {showSuccess && ( +
+
+
+
+ +
+ +

Settings Saved!

+

+ Event settings have been updated successfully. +

+ +
+

+ Event: {settings.eventName} +

+

+ Venue: {settings.venue} +

+

+ Notify Before: {settings.positionBeforePing} people +

+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/utils.ts b/frontend/src/components/utils.ts index cb2ba44..60d980e 100644 --- a/frontend/src/components/utils.ts +++ b/frontend/src/components/utils.ts @@ -1,5 +1,6 @@ const BACKEND_DEV_URL = 'http://localhost:3000'; -const BACKEND_PROD_URL = 'https://fastify-test-backend.vercel.app'; +// const BACKEND_PROD_URL = 'https://queuebot-4nfq.onrender.com'; +const BACKEND_PROD_URL = 'https://qbot-backend-79kfu.ondigitalocean.app' export function createPath(path: string) { return `${import.meta.env.DEV ? BACKEND_DEV_URL : BACKEND_PROD_URL}/${path}`; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index b2c298a..cb2c56a 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -1,9 +1,17 @@ import {useEffect, useState} from 'react'; +import {fetchEventSource} from '@microsoft/fetch-event-source'; import {QueueStats} from './QueueStats'; import {QueueControls} from './QueueControls'; import {QueueList} from './QueueList'; import {createPath} from "@/components/utils.ts"; import {Page} from "@/components/Page.tsx"; +import {SettingsAccordion} from '@/components/SettingsAccordion'; + +export interface Settings { + eventName: string; + venue: string; + positionBeforePing: number; +} export interface QueueEntry { id: string; @@ -13,12 +21,54 @@ export interface QueueEntry { export function AdminDashboard() { const [isPaused, setIsPaused] = useState(false); - const [queue, setQueue] = useState([]); const [inQueue, setInQueue] = useState(false); const [username, setUsername] = useState(""); - const [peopleAhead, setPeopleAhead] = useState(null); + const [settings, setSettings] = useState({ + eventName: '-', + venue: '-', + positionBeforePing: 0, + }); + + const establishSSE = (isAdmin: boolean) => { + + fetchEventSource(createPath(isAdmin ? "queue/entries/subscribe" : "queue/entries/me/subscribe"), { + openWhenHidden: true, + method: 'POST', + headers: { + Authorization: sessionStorage.getItem("jwt")!, + }, + onmessage(event) { + if (event.event == 'update') { + try { + const data = JSON.parse(event.data) + if (data['type'] != null && data['type'] == 'CFG') { + configUpdate(data['data']); + } else if (data['type'] != null && data['type'] == 'LST') { + (sessionStorage.getItem("user-type") as ("admin" | "user") == "admin") + ? fetchAllEntries() + : handleRefresh(); + } + } catch (error) { + console.error(error); + } + } + }, + onerror(error) { + console.error("SSE Error: ", error); + } + }); + } + + const configUpdate = (config: any) => { + setSettings({ + venue: config['venue'], + eventName: config['eventName'], + positionBeforePing: config['positionBeforePing'], + }) + setIsPaused(!config['isOpen']); + } const handleRemove = async (id: string) => { await fetch(createPath(`queue/entries/${id}`), @@ -43,6 +93,16 @@ export function AdminDashboard() { }); }; + const fetchAllEntries = async () => { + fetch(createPath("queue/entries"), + {method: "GET", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) + .then(async (res) => { + if (res.status == 200) { + reloadQueue((await res.json())['entries']); + } + }); + } + // transform the object list into a list of QueueEntry and update the queue const reloadQueue = (entries: any[]) => { setQueue(entries.map((entry) => { @@ -54,6 +114,23 @@ export function AdminDashboard() { })); } + const handleRefresh = async () => { + await fetch(createPath("queue/entries/me"), + {method: "GET", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) + .then(async (res) => { + if (res.status == 200) { + const me = (await res.json()); + setPeopleAhead(me["ahead"]); + if (me["name"] != undefined) { + setInQueue(true); + setUsername(me["name"]); + } else { + setInQueue(false); + } + } + }); + } + // Unimplemented // const handleClearQueue = () => { // if (window.confirm('Are you sure you want to clear the entire queue?')) { @@ -66,14 +143,7 @@ export function AdminDashboard() { {method: "POST", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) .then(async (res) => { if (res.status == 200) { - let entries: any[] = (await res.json())['entries']; - setQueue(entries.map((entry) => { - return { - name: entry['name'], - joinedAt: new Date(entry['timeCreated']), - id: entry['telegram_id'] - }; - })); + reloadQueue((await res.json())['entries']); } }); }; @@ -94,35 +164,19 @@ export function AdminDashboard() { const handleTogglePause = async () => { await fetch(createPath(`queue/status?open=${isPaused}`), {method: "PATCH", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) - .then(async (res) => { - if (res.status == 200) { - setIsPaused(!(await res.json())['isOpen']); - } - }); }; - const handleRefresh = async () => { - await Promise.all([ - fetch(createPath("queue/status"), - {method: "GET", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) - .then(async (res) => { - if (res.status == 200) { - setIsPaused(!(await res.json())['status']); - } - }), - fetch(createPath("queue/entries/me"), - {method: "GET", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) - .then(async (res) => { - if (res.status == 200) { - const me = (await res.json()); - setPeopleAhead(me["ahead"]); - if (me["name"] != undefined) { - setInQueue(true); - setUsername(me["name"]); - } - } - }) - ]); + const handleUpdateQueueConfig = async (settings: Settings) => { + const queryString = new URLSearchParams( + { + 'positionBeforePing': settings.positionBeforePing.toString(10), + 'venue': settings.venue, + 'eventName': settings.eventName + }).toString(); + return await fetch(createPath(`queue/config?${queryString}`), { + method: "PATCH", + headers: {Authorization: sessionStorage.getItem("jwt")!,}, + }) } if (sessionStorage.getItem("jwt") == null) { @@ -134,27 +188,21 @@ export function AdminDashboard() { useEffect(() => { const fetchData = async () => { - fetch(createPath("queue/status"), + await fetch(createPath("queue/info"), {method: "GET", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) .then(async (res) => { if (res.status == 200) { - setIsPaused(!(await res.json())['status']); + const data = await res.json(); + setSettings({ + eventName: data.eventName, + positionBeforePing: data.positionBeforePing, + venue: data.venue, + }); + setIsPaused(!data['status']); } }); - - if (userType == "admin") { - fetch(createPath("queue/entries"), - {method: "GET", headers: {Authorization: sessionStorage.getItem("jwt")!,}}) - .then(async (res) => { - if (res.status == 200) { - reloadQueue((await res.json())['entries']); - } - - }); - } else { - handleRefresh(); - } - + establishSSE(userType == "admin"); + userType == "admin" ? fetchAllEntries() : handleRefresh(); } fetchData(); @@ -163,51 +211,62 @@ export function AdminDashboard() { return ( -
-
- {/* Header */} -
-

NUSC Queuebot

- {userType == "user" && inQueue ? -

You Are Queued Up!

: null} - {userType == "admin" ? -

Admin Dashboard

: null} -
+
+
+ {/* Header */} +
+

{settings.eventName}

+

Venue: {settings.venue}

+ {userType === "admin" && ( +

Admin Dashboard

+ )} +
+ + {userType === 'admin' && ( + + )} + + {/* Statistics */} + - {/* Statistics */} - - - {/* Controls */} - - - {/* Content */} - {userType == "admin" ? - : inQueue ? (
-

{`You are ${username}!`}

-
) : null} + isInQueue={inQueue} + onTogglePause={handleTogglePause} + onJoinQueue={handleJoinQueue} + onLeaveQueue={handleLeaveQueue} + onAdvanceQueue={handleAdvanceQueue} + /> + + {/* Content */} + {userType === "admin" ? ( + + ) : inQueue ? ( +
+

{`You are ${username}!`}

+
+ ) : null} +
-
); } \ No newline at end of file diff --git a/frontend/src/pages/InitDataPage.tsx b/frontend/src/pages/InitDataPage.tsx index 3fbd9b7..d8de916 100644 --- a/frontend/src/pages/InitDataPage.tsx +++ b/frontend/src/pages/InitDataPage.tsx @@ -13,6 +13,10 @@ export const InitDataPage: FC = () => { const initDataRaw = useSignal(initData.raw); const navigate = useNavigate(); + useEffect(() => { + navigate("/dashboard"); + }, [navigate]); + useEffect(() => { const initiateAuth = async () => { diff --git a/frontend/src/pages/QueueList.tsx b/frontend/src/pages/QueueList.tsx index 4e19c6c..1203be1 100644 --- a/frontend/src/pages/QueueList.tsx +++ b/frontend/src/pages/QueueList.tsx @@ -8,10 +8,6 @@ interface QueueListProps { } export function QueueList({ queue, onRemove, isPaused }: QueueListProps) { - // const getWaitTime = (joinedAt: Date) => { - // const minutes = Math.floor((Date.now() - joinedAt.getTime()) / 60000); - // return minutes; - // }; if (queue.length === 0) { return ( diff --git a/frontend/src/pages/QueueStats.tsx b/frontend/src/pages/QueueStats.tsx index eeecbc7..d32085d 100644 --- a/frontend/src/pages/QueueStats.tsx +++ b/frontend/src/pages/QueueStats.tsx @@ -1,5 +1,4 @@ -import {Users, CheckCircle, PauseCircle, RefreshCw} from 'lucide-react'; -import LoadingButton from "@/components/LoadingButton.tsx"; +import {Users, CheckCircle, PauseCircle} from 'lucide-react'; interface QueueStatsProps { userType: "user" | "admin"; @@ -7,10 +6,9 @@ interface QueueStatsProps { totalWaiting: number; isPaused: boolean; isInQueue: boolean; - onRefresh: () => void; } -export function QueueStats({ userType, onRefresh, isInQueue, peopleAhead, totalWaiting, isPaused }: QueueStatsProps) { +export function QueueStats({ userType, isInQueue, peopleAhead, totalWaiting, isPaused }: QueueStatsProps) { return (
@@ -18,9 +16,6 @@ export function QueueStats({ userType, onRefresh, isInQueue, peopleAhead, totalW

{userType == "admin" ? "People Waiting" : "People Ahead"}

- - -

{userType == "admin" ? totalWaiting : (peopleAhead ?? "-") == 0 && isInQueue ? "It's Your Turn" : peopleAhead }

@@ -31,19 +26,6 @@ export function QueueStats({ userType, onRefresh, isInQueue, peopleAhead, totalW
- {/*Disabled because unimplemented*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*

Completed Today

*/} - {/*

{totalCompleted}

*/} - {/*
*/} - {/*
*/} - {/* */} - {/*
*/} - {/*
*/} - {/*
*/} -
diff --git a/frontend/src/pages/StatsOverview.tsx b/frontend/src/pages/StatsOverview.tsx deleted file mode 100644 index 8bc8384..0000000 --- a/frontend/src/pages/StatsOverview.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// import { Card, CardContent, CardHeader, CardTitle } from "../components/card"; -// import { Users, Camera, Clock, CheckCircle } from "lucide-react"; -// -// interface StatsOverviewProps { -// totalInQueue: number; -// activeSessions: number; -// completedToday: number; -// averageWaitTime: number; -// } -// -// export function StatsOverview({ -// totalInQueue, -// activeSessions, -// completedToday, -// averageWaitTime -// }: StatsOverviewProps) { -// const stats = [ -// { -// title: "In Queue", -// value: totalInQueue, -// icon: Users, -// color: "text-blue-600", -// bgColor: "bg-blue-100 dark:bg-blue-950", -// }, -// { -// title: "Active Sessions", -// value: activeSessions, -// icon: Camera, -// color: "text-green-600", -// bgColor: "bg-green-100 dark:bg-green-950", -// }, -// { -// title: "Completed Today", -// value: completedToday, -// icon: CheckCircle, -// color: "text-purple-600", -// bgColor: "bg-purple-100 dark:bg-purple-950", -// }, -// { -// title: "Avg Wait Time", -// value: `${averageWaitTime}m`, -// icon: Clock, -// color: "text-orange-600", -// bgColor: "bg-orange-100 dark:bg-orange-950", -// }, -// ]; -// -// return ( -//
-// {stats.map((stat) => { -// const Icon = stat.icon; -// return ( -// -// -// {stat.title} -//
-// -//
-//
-// -//
{stat.value}
-//
-//
-// ); -// })} -//
-// ); -// } diff --git a/frontend/src/pages/SystemControls.tsx b/frontend/src/pages/SystemControls.tsx deleted file mode 100644 index b721d8e..0000000 --- a/frontend/src/pages/SystemControls.tsx +++ /dev/null @@ -1,225 +0,0 @@ -// import { useState } from "react"; -// import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/card"; -// import { Button } from "../components/button"; -// import { Badge } from "../components/badge"; -// import { Switch } from "../components/switch"; -// import { Label } from "../components/label"; -// import { Input } from "../components/input"; -// import { -// Play, -// Pause, -// StopCircle, -// RefreshCw, -// Settings, -// Bell, -// BellOff -// } from "lucide-react"; -// import { -// Dialog, -// DialogContent, -// DialogDescription, -// DialogHeader, -// DialogTitle, -// DialogTrigger, -// DialogFooter, -// } from "../components/dialog"; -// -// interface SystemControlsProps { -// isActive: boolean; -// isPaused: boolean; -// onStart: () => void; -// onPause: () => void; -// onStop: () => void; -// onReset: () => void; -// settings: { -// maxCapacity: number; -// sessionDuration: number; -// autoNotifications: boolean; -// }; -// onUpdateSettings: (settings: SystemControlsProps['settings']) => void; -// } -// -// export function SystemControls({ -// isActive, -// isPaused, -// onStart, -// onPause, -// onStop, -// onReset, -// settings, -// onUpdateSettings -// }: SystemControlsProps) { -// const [localSettings, setLocalSettings] = useState(settings); -// const [isSettingsOpen, setIsSettingsOpen] = useState(false); -// -// const handleSaveSettings = () => { -// onUpdateSettings(localSettings); -// setIsSettingsOpen(false); -// }; -// -// const getSystemStatus = () => { -// if (!isActive) return { label: "Offline", variant: "secondary" as const }; -// if (isPaused) return { label: "Paused", variant: "outline" as const }; -// return { label: "Active", variant: "default" as const, className: "bg-green-600" }; -// }; -// -// const status = getSystemStatus(); -// -// return ( -// -// -//
-//
-// System Controls -// Manage photobooth queue system -//
-// -// {status.label} -// -//
-//
-// -//
-// {!isActive ? ( -// -// ) : ( -// <> -// {!isPaused ? ( -// -// ) : ( -// -// )} -// -// -// )} -// -// -// -// -// -// -// -// -// -// Queue Settings -// -// Configure queue system parameters -// -// -//
-//
-// -// setLocalSettings({ -// ...localSettings, -// maxCapacity: parseInt(e.target.value) || 0 -// })} -// min={1} -// max={100} -// /> -//

-// Maximum number of users allowed in queue -//

-//
-// -//
-// -// setLocalSettings({ -// ...localSettings, -// sessionDuration: parseInt(e.target.value) || 0 -// })} -// min={1} -// max={60} -// /> -//

-// Default duration for each photobooth session -//

-//
-// -//
-//
-// -//

-// Send notifications when user's turn is approaching -//

-//
-// setLocalSettings({ -// ...localSettings, -// autoNotifications: checked -// })} -// /> -//
-//
-// -// -// -// -//
-//
-//
-// -//
-//
-// Queue Capacity -// {settings.maxCapacity} users -//
-//
-// Session Duration -// {settings.sessionDuration} minutes -//
-//
-// Notifications -// -// {settings.autoNotifications ? ( -// <> -// -// Enabled -// -// ) : ( -// <> -// -// Disabled -// -// )} -// -//
-//
-//
-//
-// ); -// }