From 8fa0056b45ee956ee7a676716151d928f48a90e4 Mon Sep 17 00:00:00 2001 From: Richmond Baltazar Date: Mon, 12 May 2025 16:40:09 +0800 Subject: [PATCH 1/3] (feat): Neu Staff Current Serving Option Screen (refactor): added deletion of current serving in counters --- .../controllers/authorizedQueueController.ts | 77 +++++++++++++++++++ .../src/controllers/queueControllers.ts | 15 ++-- backend/functions/src/index.ts | 27 ++++++- .../src/routers/authorizedQueueRoutes.ts | 18 +++++ backend/functions/src/routers/queueRoutes.ts | 4 +- backend/functions/src/routes/index.ts | 2 + 6 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 backend/functions/src/controllers/authorizedQueueController.ts create mode 100644 backend/functions/src/routers/authorizedQueueRoutes.ts diff --git a/backend/functions/src/controllers/authorizedQueueController.ts b/backend/functions/src/controllers/authorizedQueueController.ts new file mode 100644 index 0000000..71bce93 --- /dev/null +++ b/backend/functions/src/controllers/authorizedQueueController.ts @@ -0,0 +1,77 @@ +import { Response } from "express"; +import { realtimeDb } from "../config/firebaseConfig"; +import AuthRequest from "../types/AuthRequest"; +import CashierType from "../types/CashierType"; +import Counter from "../types/Counter"; + +export const getAllOpenedStation = async (req: AuthRequest, res: Response) => { + if (!req.user) { + res.status(401).json({ message: "Unauthorized request" }); + return; + } + + const stationRef = realtimeDb.ref("stations"); + const stationSnapshot = await stationRef.get(); + const stations = stationSnapshot.val(); + + + if (!stations) { + res.status(200).json({ openedStations: [] }); + return; + } + + + type Station = { + id: string; + name: string; + description: string; + activated: boolean; + type: CashierType; + }; + + const allOpenedStations: Station[] = Object.entries(stations).map(([stationId, data]) => ({ + id: stationId, + ...(data as Omit), + })).filter((station) => station.activated === true); + + + res.status(200).json({ openedStations: allOpenedStations }); +}; + + +export const displayCurrentServing = async ( + req: AuthRequest, + res: Response +) => { + try { + if (!req.user) { + res.status(401).json({ message: "Unauthorized request" }); + return; + } + const { stationId }: { stationId: string } = req.body; + if (!stationId || stationId === "") { + res.status(400).json({ message: "Station Id is missing" }); + return; + } + const countersRef = realtimeDb.ref("counters"); + const snapshot = await countersRef + .orderByChild("stationID") + .equalTo(stationId) + .once("value"); + + if (!snapshot.exists()) { + res + .status(404) + .json({ message: "No counters found for the given stationID" }); + } + + const counters: Record = snapshot.val(); + const servingCounters = Object.values(counters) + .filter((counter) => counter.serving && counter.serving.trim() !== "") + .map(({ counterNumber, serving }) => ({ counterNumber, serving })); + + res.status(200).json({ servingCounters }); + } catch (error) { + res.status(500).json({ message: (error as Error).message }); + } +}; diff --git a/backend/functions/src/controllers/queueControllers.ts b/backend/functions/src/controllers/queueControllers.ts index e0d77f4..1cc07f3 100644 --- a/backend/functions/src/controllers/queueControllers.ts +++ b/backend/functions/src/controllers/queueControllers.ts @@ -37,7 +37,7 @@ export const generateQrCode = async (req: Request, res: Response) => { } }; -export const getValidJwtForFormAccess = async (req: QueueRequest, res: Response ) => { +export const getValidJwtForFormAccess = async (req: QueueRequest, res: Response) => { try { if (!req.token) { res.status(401).json({ message: "The token is invalid or missing" }); @@ -59,11 +59,11 @@ export const getValidJwtForFormAccess = async (req: QueueRequest, res: Response type: "queue-form", }; - const token = jwt.sign(payload, SECRET_KEY, {expiresIn: "10m"}); + const token = jwt.sign(payload, SECRET_KEY, { expiresIn: "10m" }); res.status(201).json({ token: token }); } catch (error) { if (error instanceof TokenExpiredError) { - res.status(401).json({ message: "Token has expired, please sign in again"}); + res.status(401).json({ message: "Token has expired, please sign in again" }); } else { res.status(500).json({ message: (error as Error).message }); } @@ -164,7 +164,7 @@ export const addQueue = async (req: QueueRequest, res: Response) => { } const queueToken = jwt.sign( - { queueID: queueIDWithPrefix, stationID, email, type: "queue-status"}, + { queueID: queueIDWithPrefix, stationID, email, type: "queue-status" }, SECRET_KEY, { expiresIn: "10h" } ); @@ -401,7 +401,7 @@ export const leaveQueue = async (req: QueueRequest, res: Response) => { }; const { queueID, email, stationID } = decodedToken; const queueRef = firestoreDb.collection("queue").doc(queueID); - await queueRef.set({customerStatus: "unsuccessful"}, {merge: true}); + await queueRef.set({ customerStatus: "unsuccessful" }, { merge: true }); const invalidTokenRef = firestoreDb .collection("invalid-token") .doc(req.token); @@ -409,7 +409,7 @@ export const leaveQueue = async (req: QueueRequest, res: Response) => { const station = await realtimeDb.ref(`stations/${stationID}`).get(); // check if the customer is in the current serving const currentServingRef = realtimeDb.ref(`current-serving/${stationID}`); - type CounterWithID = {[key: string]: Counter}; + type CounterWithID = { [key: string]: Counter }; await currentServingRef.transaction((currentServing: CounterWithID) => { if (!currentServing) return; @@ -427,7 +427,7 @@ export const leaveQueue = async (req: QueueRequest, res: Response) => { const counterSnapshot = await countersRef.once("value"); const counters = counterSnapshot.val(); - const countersWithKey: {[key: string]: Counter} = {}; + const countersWithKey: { [key: string]: Counter } = {}; Object.keys(counters).forEach((counterKey) => { const counterData = counters[counterKey] as Counter; countersWithKey[counterKey] = counterData; @@ -735,3 +735,4 @@ export const notifyCurrentlyServing = async ( res.status(500).json({ message: (error as Error).message }); } }; + diff --git a/backend/functions/src/index.ts b/backend/functions/src/index.ts index c65eb8a..00da69e 100644 --- a/backend/functions/src/index.ts +++ b/backend/functions/src/index.ts @@ -3,7 +3,7 @@ import express, { Express } from "express"; import dotenv from "dotenv"; import cors from "cors"; import routes from "./routes"; -import { firestoreDb } from "./config/firebaseConfig"; +import { firestoreDb, realtimeDb } from "./config/firebaseConfig"; dotenv.config(); const app: Express = express(); @@ -39,7 +39,13 @@ export const archiveQueueAndResetQueueNumbers = v2.scheduler.onSchedule( const batch = firestoreDb.batch(); queueSnapshot.forEach((doc) => { - batch.set(historyRef.doc(doc.id), doc.data()); + const data = doc.data(); + + if (data.customerStatus === "ongoing" || data.customerStatus === "pending") { + data.customerStatus = "unsuccessful"; + } + + batch.set(historyRef.doc(doc.id), data); batch.delete(doc.ref); }); await batch.commit(); @@ -47,7 +53,7 @@ export const archiveQueueAndResetQueueNumbers = v2.scheduler.onSchedule( const queueNumberSnapshot = await firestoreDb.collection("queue-numbers").get(); const resetBatch = firestoreDb.batch(); queueNumberSnapshot.forEach((doc) => { - resetBatch.set(doc.ref, {currentNumber: 0}); + resetBatch.set(doc.ref, { currentNumber: 0 }); }); await resetBatch.commit(); v2.logger.info("Reset all queue-numbers to 0."); @@ -59,6 +65,21 @@ export const archiveQueueAndResetQueueNumbers = v2.scheduler.onSchedule( }); await deleteBatch.commit(); v2.logger.info(`Deleted all ${fcmTokensSnapshot.size} documents from fcm-tokens.`); + + const counterRef = realtimeDb.ref("counters"); + const counterSnapshot = await counterRef.get(); + const counters = counterSnapshot.val(); + if (counters) { + const deleteServing: Record = {}; + Object.keys(counters).forEach((counterId) => { + deleteServing[`counters/${counterId}/serving`] = null; + }); + await realtimeDb.ref().update(deleteServing); + v2.logger + .info(`Deleted 'serving' attributes for ${Object.keys(counters).length} counters in Realtime Database.`); + } else { + v2.logger.info("No counters found in Realtime Database."); + } } catch (error) { v2.logger.error("Error archiving queue records or resetting queue numbers:", error); } diff --git a/backend/functions/src/routers/authorizedQueueRoutes.ts b/backend/functions/src/routers/authorizedQueueRoutes.ts new file mode 100644 index 0000000..036b9dc --- /dev/null +++ b/backend/functions/src/routers/authorizedQueueRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { displayCurrentServing, getAllOpenedStation } from "../controllers/authorizedQueueController"; +import { verifyAuthTokenAndDomain } from "../middlewares/verifyAuthTokenAndDomain"; +import { verifyRole } from "../middlewares/verifyRole"; + + +// eslint-disable-next-line new-cap +const router: Router = Router(); + + +router.use(verifyAuthTokenAndDomain, + verifyRole(["information", "cashier", "admin", "superAdmin"])); + + +router.get("/get-opened-stations", getAllOpenedStation); +router.post("/display-current-serving", displayCurrentServing); + +export default router; diff --git a/backend/functions/src/routers/queueRoutes.ts b/backend/functions/src/routers/queueRoutes.ts index 142b1ee..99505cd 100644 --- a/backend/functions/src/routers/queueRoutes.ts +++ b/backend/functions/src/routers/queueRoutes.ts @@ -15,6 +15,7 @@ import { verifyCustomerToken, } from "../controllers/queueControllers"; import { verifyAuthTokenAndDomain } from "../middlewares/verifyAuthTokenAndDomain"; +import { verifyRole } from "../middlewares/verifyRole"; import { verifyTypedToken, verifyUsedToken, @@ -23,7 +24,8 @@ import { // eslint-disable-next-line new-cap const router: Router = Router(); -router.get("/qrcode", verifyAuthTokenAndDomain, generateQrCode); +router.get("/qrcode", verifyAuthTokenAndDomain, + verifyRole(["information", "cashier", "admin", "superAdmin"]), generateQrCode); router.post( "/add", verifyTypedToken(["queue-form"]), diff --git a/backend/functions/src/routes/index.ts b/backend/functions/src/routes/index.ts index 12c3d00..f0702fd 100644 --- a/backend/functions/src/routes/index.ts +++ b/backend/functions/src/routes/index.ts @@ -5,6 +5,7 @@ import counterRoutes from "../routers/counterRoutes"; import adminRoutes from "../routers/adminRoutes"; import userRoutes from "../routers/userRoutes"; import cashierRoutes from "../routers/cashierRoutes"; +import authorizedQueueRoutes from "../routers/authorizedQueueRoutes"; // eslint-disable-next-line new-cap const router = Router(); @@ -19,5 +20,6 @@ router.use("/station", stationRoutes); router.use("/counter", counterRoutes); router.use("/admin", adminRoutes); router.use("/cashier", cashierRoutes); +router.use("/auth-queue", authorizedQueueRoutes); export default router; From ef7ffbfe76a4dda91c13a45ac9db17e6d4290dfd Mon Sep 17 00:00:00 2001 From: Richmond Baltazar Date: Mon, 12 May 2025 19:12:49 +0800 Subject: [PATCH 2/3] (feat): customer rating --- .../controllers/authorizedQueueController.ts | 51 ++++++++++--------- .../src/controllers/queueControllers.ts | 29 +++++++++++ backend/functions/src/routers/queueRoutes.ts | 4 ++ .../src/zod-schemas/customerRating.ts | 12 +++++ 4 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 backend/functions/src/zod-schemas/customerRating.ts diff --git a/backend/functions/src/controllers/authorizedQueueController.ts b/backend/functions/src/controllers/authorizedQueueController.ts index 71bce93..5b19ff1 100644 --- a/backend/functions/src/controllers/authorizedQueueController.ts +++ b/backend/functions/src/controllers/authorizedQueueController.ts @@ -5,37 +5,40 @@ import CashierType from "../types/CashierType"; import Counter from "../types/Counter"; export const getAllOpenedStation = async (req: AuthRequest, res: Response) => { - if (!req.user) { - res.status(401).json({ message: "Unauthorized request" }); - return; - } - - const stationRef = realtimeDb.ref("stations"); - const stationSnapshot = await stationRef.get(); - const stations = stationSnapshot.val(); + try { + if (!req.user) { + res.status(401).json({ message: "Unauthorized request" }); + return; + } + const stationRef = realtimeDb.ref("stations"); + const stationSnapshot = await stationRef.get(); + const stations = stationSnapshot.val(); - if (!stations) { - res.status(200).json({ openedStations: [] }); - return; - } + if (!stations) { + res.status(200).json({ openedStations: [] }); + return; + } - type Station = { - id: string; - name: string; - description: string; - activated: boolean; - type: CashierType; - }; + type Station = { + id: string; + name: string; + description: string; + activated: boolean; + type: CashierType; + }; - const allOpenedStations: Station[] = Object.entries(stations).map(([stationId, data]) => ({ - id: stationId, - ...(data as Omit), - })).filter((station) => station.activated === true); + const allOpenedStations: Station[] = Object.entries(stations).map(([stationId, data]) => ({ + id: stationId, + ...(data as Omit), + })).filter((station) => station.activated === true); - res.status(200).json({ openedStations: allOpenedStations }); + res.status(200).json({ openedStations: allOpenedStations }); + } catch (error) { + res.status(500).json({ message: (error as Error).message }); + } }; diff --git a/backend/functions/src/controllers/queueControllers.ts b/backend/functions/src/controllers/queueControllers.ts index 1cc07f3..f474563 100644 --- a/backend/functions/src/controllers/queueControllers.ts +++ b/backend/functions/src/controllers/queueControllers.ts @@ -13,6 +13,7 @@ import { sendEmail } from "../utils/sendEmail"; import { recordLog } from "../utils/recordLog"; import { ActionType } from "../types/activityLog"; import { ZodError } from "zod"; +import { customerRatingSchema } from "../zod-schemas/customerRating"; const SECRET_KEY = process.env.JWT_SECRET; const NEUQUEUE_ROOT_URL = process.env.NEUQUEUE_ROOT_URL; @@ -736,3 +737,31 @@ export const notifyCurrentlyServing = async ( } }; +export const rateCashier = async (req: QueueRequest, res: Response) => { + try { + if (!req.token) { + res.status(400).json({ message: "Missing token" }); + return; + } + + const parsedBody = customerRatingSchema.parse(req.body); + + const ratingRef = firestoreDb.collection("rating").doc(req.token); + const ratings = await ratingRef.get(); + + if (ratings.exists) { + res.status(403).json({ message: "Feedback already sent" }); + return; + } + + await ratingRef.set({ + ...parsedBody, + ...(parsedBody.comment ? { comment: parsedBody.comment } : {}), + }); + + res.status(201).json({ message: "Feedback sent successfully!" }); + } catch (error) { + res.status(500).json({ message: (error as Error).message }); + } +}; + diff --git a/backend/functions/src/routers/queueRoutes.ts b/backend/functions/src/routers/queueRoutes.ts index 99505cd..5a08dea 100644 --- a/backend/functions/src/routers/queueRoutes.ts +++ b/backend/functions/src/routers/queueRoutes.ts @@ -11,6 +11,7 @@ import { leaveQueue, notifyCurrentlyServing, notifyOnSuccessScan, + rateCashier, storeFCMToken, verifyCustomerToken, } from "../controllers/queueControllers"; @@ -80,4 +81,7 @@ router.post( verifyTypedToken(["permission", "queue-form", "queue-status"]), storeFCMToken ); + +router.post("/rate-cashier", verifyTypedToken(["queue-status"]), rateCashier); + export default router; diff --git a/backend/functions/src/zod-schemas/customerRating.ts b/backend/functions/src/zod-schemas/customerRating.ts new file mode 100644 index 0000000..053cddd --- /dev/null +++ b/backend/functions/src/zod-schemas/customerRating.ts @@ -0,0 +1,12 @@ +import { number, object, string } from "zod"; + + +export const customerRatingSchema = object({ + cashierUid: string().min(1, "cashierUid can not be empty"), + rating: number() + .min(1, "Rating must be at least 1") + .max(5, "Rating must be at most 5") + .int("Rating must be an integer"), + comment: string().nullable(), + timestamp: number(), +}); From 9e8938b8c92e487c394c69051d0311bf3e20a0fb Mon Sep 17 00:00:00 2001 From: Richmond Baltazar Date: Wed, 14 May 2025 17:00:38 +0800 Subject: [PATCH 3/3] feat(queue): added timestamp for servedAt and completedAt, added servedBy --- .../src/controllers/cashierController.ts | 60 +++++++++++-------- backend/functions/src/index.ts | 6 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/backend/functions/src/controllers/cashierController.ts b/backend/functions/src/controllers/cashierController.ts index 85cfb8e..e298804 100644 --- a/backend/functions/src/controllers/cashierController.ts +++ b/backend/functions/src/controllers/cashierController.ts @@ -37,7 +37,7 @@ export const serveCustomer = async (req: AuthRequest, res: Response) => { } const customerRef = firestoreDb.collection("queue"); - const { customerDocID, customerEmail} = await firestoreDb.runTransaction( + const { customerDocID, customerEmail } = await firestoreDb.runTransaction( async (transaction) => { const queueSnapshot = await transaction.get( customerRef @@ -67,20 +67,24 @@ export const serveCustomer = async (req: AuthRequest, res: Response) => { ); await currentServingRef.set(customerDocID); + if (!req.user) { + res.status(401).json({ message: "User ID is missing!" }); + return; + } + // 🔄 Assign Customer to Counter await counterRef.update({ counterNumber, stationID, - uid: req.user?.uid, + uid: req.user.uid, serving: customerDocID, // ✅ Use document ID as queueID }); - if (!req.user) { - res.status(401).json({message: "User ID is missing!"}); - return; - } const receiver = await auth.getUser(req.user.uid); const displayName = receiver.displayName; + + await customerRef.doc(customerDocID).set({ servedAt: Date.now() }, { merge: true }); + await recordLog( req.user.uid, ActionType.SERVE_CUSTOMER, @@ -90,6 +94,8 @@ export const serveCustomer = async (req: AuthRequest, res: Response) => { await queueCountRef.transaction((currentValue) => { return currentValue === 1 ? 0 : 1; }); + + res.status(200).json({ message: "Customer assigned to cashier", customer: customerDocID, @@ -109,6 +115,10 @@ export const completeTransaction = async (req: AuthRequest, res: Response) => { counterID, }: { queueID: string; stationID: string; counterID: string } = req.body; + if (!req.user) { + res.status(401).json({ message: "User ID is missing!" }); + return; + } const queueRef = firestoreDb.collection("queue").doc(queueID); const queueSnapshot = await queueRef.get(); @@ -117,9 +127,11 @@ export const completeTransaction = async (req: AuthRequest, res: Response) => { return; } - await queueRef.update({ + await queueRef.set({ customerStatus: "complete", - }); + completedAt: Date.now(), + servedBy: req.user.uid, + }, { merge: true }); const currentServingRef = realtimeDb.ref( `current-serving/${stationID}/${counterID}` @@ -131,10 +143,7 @@ export const completeTransaction = async (req: AuthRequest, res: Response) => { ); const currentCustomerData = await currentCounterServing.get(); const currentCustomer = currentCustomerData.val(); - if (!req.user) { - res.status(401).json({message: "User ID is missing!"}); - return; - } + const receiver = await auth.getUser(req.user.uid); const displayName = receiver.displayName; await recordLog( @@ -243,10 +252,10 @@ export const getCurrentServing = async (req: AuthRequest, res: Response) => { export const notifyCustomer = async (req: AuthRequest, res: Response) => { try { - const {counterNumber, queueID}: {counterNumber: string, queueID: string} = req.body; + const { counterNumber, queueID }: { counterNumber: string, queueID: string } = req.body; if (!counterNumber || !queueID) { - res.status(400).json({message: "Missing Counter Number or QueueID"}); + res.status(400).json({ message: "Missing Counter Number or QueueID" }); return; } const queueDoc = await firestoreDb.collection("queue").doc(queueID).get(); @@ -296,14 +305,21 @@ export const skipCustomer = async (req: AuthRequest, res: Response) => { const queueRef = firestoreDb.collection("queue").doc(queueID); const queueSnapshot = await queueRef.get(); + if (!req.user) { + res.status(401).json({ message: "User ID is missing!" }); + return; + } + if (!queueSnapshot.exists) { res.status(404).json({ message: "Queue entry not found" }); return; } - await queueRef.update({ + await queueRef.set({ customerStatus: "unsuccessful", - }); + completedAt: Date.now(), + servedBy: req.user.uid, + }, { merge: true }); const currentServingRef = realtimeDb.ref( `current-serving/${stationID}/${counterID}` @@ -314,10 +330,6 @@ export const skipCustomer = async (req: AuthRequest, res: Response) => { `counters/${counterID}/serving` ); await currentCounterServing.remove(); - if (!req.user) { - res.status(401).json({message: "User ID is missing!"}); - return; - } const customerRef = firestoreDb.collection("queue").doc(queueID); const customer = await customerRef.get(); @@ -337,11 +349,11 @@ export const skipCustomer = async (req: AuthRequest, res: Response) => { } }; -export const getRemainingPendingCustomerCount = async (req: AuthRequest, res:Response) => { +export const getRemainingPendingCustomerCount = async (req: AuthRequest, res: Response) => { try { - const {stationID} = req.query; + const { stationID } = req.query; if (!stationID) { - res.status(400).json({message: "Missing StationID"}); + res.status(400).json({ message: "Missing StationID" }); return; } const queueRef = firestoreDb.collection("queue") @@ -349,7 +361,7 @@ export const getRemainingPendingCustomerCount = async (req: AuthRequest, res:Res .where("stationID", "==", stationID.toString()); const remainingQueue = await queueRef.get(); - res.status(200).json({remainingCustomersCount: remainingQueue.size}); + res.status(200).json({ remainingCustomersCount: remainingQueue.size }); } catch (error) { res.status(500).json({ message: (error as Error).message }); } diff --git a/backend/functions/src/index.ts b/backend/functions/src/index.ts index 00da69e..ac056d4 100644 --- a/backend/functions/src/index.ts +++ b/backend/functions/src/index.ts @@ -29,7 +29,10 @@ app.use(routes); export const neu = v2.https.onRequest(app); export const archiveQueueAndResetQueueNumbers = v2.scheduler.onSchedule( - "every day 19:00", + { + schedule: "every day 19:00", + timeZone: "Asia/Manila", + }, async () => { try { const dateKey = new Date().toISOString(); @@ -74,6 +77,7 @@ export const archiveQueueAndResetQueueNumbers = v2.scheduler.onSchedule( Object.keys(counters).forEach((counterId) => { deleteServing[`counters/${counterId}/serving`] = null; }); + deleteServing["current-serving"] = null; await realtimeDb.ref().update(deleteServing); v2.logger .info(`Deleted 'serving' attributes for ${Object.keys(counters).length} counters in Realtime Database.`);