import { Router, type IRouter } from "express"; import { eq, and, desc, sql, isNull, inArray, or } from "drizzle-orm"; import { db } from "@workspace/db"; import { serviceOrdersTable, servicesTable, usersTable, notificationsTable, userRolesTable, rolesTable, rolePermissionsTable, permissionsTable, } from "@workspace/db"; import { requireAuth, requirePermission } from "../middlewares/auth"; import { sendPushToUser } from "../lib/push"; import { CreateServiceOrderBody, UpdateServiceOrderStatusParams as ServiceOrderIdParams, UpdateServiceOrderStatusBody, BulkDeleteServiceOrdersBody, } from "@workspace/api-zod"; const router: IRouter = Router(); const PERM_RECEIVE = "orders.receive"; const ACTIVE_STATUSES = ["pending", "received", "preparing"] as const; // Server-side undo window for restoring a cancelled order back to its // prior status. Includes a small buffer over the client UI window // (7s) to absorb network/clock skew while still making cancellation // effectively permanent shortly after. const RESTORE_WINDOW_MS = 15_000; async function loadOrder(id: number) { const [row] = await db .select({ id: serviceOrdersTable.id, serviceId: serviceOrdersTable.serviceId, userId: serviceOrdersTable.userId, assignedTo: serviceOrdersTable.assignedTo, notes: serviceOrdersTable.notes, status: serviceOrdersTable.status, createdAt: serviceOrdersTable.createdAt, updatedAt: serviceOrdersTable.updatedAt, service: { id: servicesTable.id, nameAr: servicesTable.nameAr, nameEn: servicesTable.nameEn, imageUrl: servicesTable.imageUrl, }, }) .from(serviceOrdersTable) .innerJoin(servicesTable, eq(serviceOrdersTable.serviceId, servicesTable.id)) .where(eq(serviceOrdersTable.id, id)); if (!row) return null; const userIds = [row.userId, ...(row.assignedTo ? [row.assignedTo] : [])]; const userRows = await db .select({ id: usersTable.id, username: usersTable.username, displayNameAr: usersTable.displayNameAr, displayNameEn: usersTable.displayNameEn, avatarUrl: usersTable.avatarUrl, }) .from(usersTable) .where(inArray(usersTable.id, userIds)); const userById = new Map(userRows.map((u) => [u.id, u])); return { ...row, requester: userById.get(row.userId) ?? null, assignee: row.assignedTo ? (userById.get(row.assignedTo) ?? null) : null, }; } async function getReceiverUserIds(): Promise { const rows = await db .selectDistinct({ userId: userRolesTable.userId }) .from(userRolesTable) .innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId)) .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id)) .where(eq(permissionsTable.name, PERM_RECEIVE)); return rows.map((r) => r.userId); } async function isAdmin(userId: number): Promise { const rows = await db .select({ name: rolesTable.name }) .from(userRolesTable) .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) .where(and(eq(userRolesTable.userId, userId), eq(rolesTable.name, "admin"))); return rows.length > 0; } async function hasReceivePermission(userId: number): Promise { const rows = await db .select({ id: permissionsTable.id }) .from(userRolesTable) .innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId)) .innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id)) .where(and(eq(userRolesTable.userId, userId), eq(permissionsTable.name, PERM_RECEIVE))) .limit(1); return rows.length > 0; } async function emitToUser(userId: number, event: string, payload?: unknown) { const { io } = await import("../index.js"); io.to(`user:${userId}`).emit(event, payload); } async function notifyUser( userId: number, titleAr: string, titleEn: string, bodyAr: string, bodyEn: string, orderId: number, actorId?: number, ) { // Never notify users about actions they themselves initiated. if (actorId !== undefined && actorId === userId) return; const [notification] = await db .insert(notificationsTable) .values({ userId, titleAr, titleEn, bodyAr, bodyEn, type: "order", relatedId: orderId, relatedType: "order", }) .returning(); await emitToUser(userId, "notification_created", notification); void sendPushToUser(userId, { title: titleAr, body: bodyAr, titleAr, titleEn, bodyAr, bodyEn, type: "order", relatedId: orderId, tag: `order-${orderId}`, url: "/my-orders", }); } async function broadcastIncomingChanged(receiverIds: number[]) { for (const uid of receiverIds) { await emitToUser(uid, "order_incoming_changed"); } } // POST /api/orders — place a new order router.post("/orders", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const parsed = CreateServiceOrderBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const [service] = await db .select() .from(servicesTable) .where(eq(servicesTable.id, parsed.data.serviceId)); if (!service) { res.status(404).json({ error: "Service not found" }); return; } if (!service.isAvailable) { res.status(400).json({ error: "Service is not available" }); return; } const [order] = await db .insert(serviceOrdersTable) .values({ serviceId: parsed.data.serviceId, userId, notes: parsed.data.notes ?? null, }) .returning(); const enriched = await loadOrder(order.id); const receivers = await getReceiverUserIds(); for (const rid of receivers) { await notifyUser( rid, "طلب جديد", "New order", service.nameAr, service.nameEn, order.id, userId, ); } await broadcastIncomingChanged(receivers); res.status(201).json(enriched); }); // GET /api/orders/my router.get("/orders/my", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const rows = await db .select({ id: serviceOrdersTable.id }) .from(serviceOrdersTable) .where(eq(serviceOrdersTable.userId, userId)) .orderBy(desc(serviceOrdersTable.createdAt)) .limit(100); const enriched = await Promise.all(rows.map((r) => loadOrder(r.id))); res.json(enriched.filter(Boolean)); }); // GET /api/orders/incoming — receivers only router.get( "/orders/incoming", requireAuth, requirePermission(PERM_RECEIVE), async (req, res): Promise => { const userId = req.session.userId!; const rows = await db .select({ id: serviceOrdersTable.id }) .from(serviceOrdersTable) .where( or( and( eq(serviceOrdersTable.status, "pending"), isNull(serviceOrdersTable.assignedTo), ), and( eq(serviceOrdersTable.assignedTo, userId), inArray(serviceOrdersTable.status, ACTIVE_STATUSES as unknown as string[]), ), ), ) .orderBy(desc(serviceOrdersTable.createdAt)) .limit(200); const enriched = await Promise.all(rows.map((r) => loadOrder(r.id))); res.json(enriched.filter(Boolean)); }, ); // PATCH /api/orders/:id/confirm-receipt — receiver atomic claim router.patch( "/orders/:id/confirm-receipt", requireAuth, requirePermission(PERM_RECEIVE), async (req, res): Promise => { const params = ServiceOrderIdParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const userId = req.session.userId!; const orderId = params.data.id; const [updated] = await db .update(serviceOrdersTable) .set({ status: "received", assignedTo: userId, updatedAt: new Date() }) .where( and( eq(serviceOrdersTable.id, orderId), eq(serviceOrdersTable.status, "pending"), isNull(serviceOrdersTable.assignedTo), ), ) .returning(); if (!updated) { // Distinguish: was it claimed by someone else, or is it no longer claimable // (cancelled / completed / already in a terminal state)? const current = await db .select({ status: serviceOrdersTable.status }) .from(serviceOrdersTable) .where(eq(serviceOrdersTable.id, orderId)); const currentStatus = current[0]?.status; if (!currentStatus || currentStatus === "cancelled" || currentStatus === "completed") { res.status(409).json({ error: "order_unavailable" }); } else { res.status(409).json({ error: "already_claimed" }); } return; } const enriched = await loadOrder(orderId); if (enriched) { await notifyUser( enriched.userId, "تم استلام طلبك", "Your order was received", enriched.service.nameAr, enriched.service.nameEn, orderId, userId, ); await emitToUser(enriched.userId, "order_updated", enriched); } const receivers = await getReceiverUserIds(); await broadcastIncomingChanged(receivers); res.json(enriched); }, ); // PATCH /api/orders/:id/status router.patch( "/orders/:id/status", requireAuth, async (req, res): Promise => { const params = ServiceOrderIdParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const parsed = UpdateServiceOrderStatusBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const userId = req.session.userId!; const orderId = params.data.id; const next = parsed.data.status; const [existing] = await db .select() .from(serviceOrdersTable) .where(eq(serviceOrdersTable.id, orderId)); if (!existing) { res.status(404).json({ error: "Order not found" }); return; } const admin = await isAdmin(userId); const isRestoreFromCancelled = existing.status === "cancelled" && next !== "cancelled"; if (isRestoreFromCancelled) { // Restore a cancelled order back to a prior active status (Undo). // Owner/admin may restore to pending or received; the original // assignee may restore to received or preparing. const isOwner = existing.userId === userId; const isAssignee = existing.assignedTo === userId; const canOwnerRestore = (isOwner || admin) && (next === "pending" || next === "received"); const canAssigneeRestore = isAssignee && (next === "received" || next === "preparing"); if (!canOwnerRestore && !canAssigneeRestore) { if (!isOwner && !isAssignee && !admin) { res.status(403).json({ error: "Forbidden" }); return; } res.status(400).json({ error: "invalid_transition" }); return; } if (next === "pending" && existing.assignedTo !== null) { res.status(400).json({ error: "invalid_transition" }); return; } if ( (next === "received" || next === "preparing") && existing.assignedTo === null ) { res.status(400).json({ error: "invalid_transition" }); return; } // Enforce the undo window: once the window expires, the // cancellation is permanent. Admin is also bound by this so // restore is always Undo, never an arbitrary reopen. const cancelledAt = existing.updatedAt ? new Date(existing.updatedAt).getTime() : 0; if (Date.now() - cancelledAt > RESTORE_WINDOW_MS) { res.status(400).json({ error: "undo_window_expired" }); return; } } else if (next === "preparing" || next === "completed") { // If the order is already terminal (cancelled or completed by someone // else), surface a specific error so the receiver UI can show the right // message and remove the stale card. if (existing.status === "cancelled" || existing.status === "completed") { res.status(409).json({ error: "order_unavailable" }); return; } // Only the assigned receiver or admin const isAssignee = existing.assignedTo === userId; if (!isAssignee && !admin) { res.status(403).json({ error: "Forbidden" }); return; } if (next === "preparing" && existing.status !== "received") { res.status(400).json({ error: "invalid_transition" }); return; } if (next === "completed" && existing.status !== "preparing") { res.status(400).json({ error: "invalid_transition" }); return; } } else if (next === "pending" || next === "received") { // Restore is handled above; outside cancelled, these targets are // never valid via this endpoint. res.status(400).json({ error: "invalid_transition" }); return; } else if (next === "cancelled") { // Check terminal state first so the receiver sees the right message // regardless of permission gating order. Admin may still cancel a // completed/cancelled order (e.g. corrective action), so they bypass. if ( !admin && (existing.status === "cancelled" || existing.status === "completed") ) { res.status(409).json({ error: "order_unavailable" }); return; } const isOwner = existing.userId === userId; const isAssignee = existing.assignedTo === userId; const cancellableByOwner = existing.status === "pending" || existing.status === "received"; const cancellableByAssignee = existing.status === "received" || existing.status === "preparing"; if ( !admin && !(isOwner && cancellableByOwner) && !(isAssignee && cancellableByAssignee) ) { res.status(403).json({ error: "Forbidden" }); return; } } else { res.status(400).json({ error: "invalid_transition" }); return; } await db .update(serviceOrdersTable) .set({ status: next, updatedAt: new Date() }) .where(eq(serviceOrdersTable.id, orderId)); const enriched = await loadOrder(orderId); // Notify owner on every status change — notifyUser will skip if the // owner themselves initiated the action (e.g. owner-cancel, or an // assignee transition where the owner also holds the receiver role). if (enriched) { const titleMap: Record = { pending: ["تمت استعادة طلبك", "Your order was restored"], received: ["تمت استعادة طلبك", "Your order was restored"], preparing: ["طلبك قيد التحضير", "Your order is being prepared"], completed: ["تم إكمال طلبك", "Your order is completed"], cancelled: ["تم إلغاء طلبك", "Your order was cancelled"], }; const [titleAr, titleEn] = titleMap[next]; let bodyAr = enriched.service.nameAr; let bodyEn = enriched.service.nameEn; // Distinguish receiver-initiated cancel from admin-initiated cancel if (next === "cancelled" && existing.assignedTo === userId) { bodyAr = `${enriched.service.nameAr} — استلم طلبك ولكن تم إلغاؤه لاحقاً`; bodyEn = `${enriched.service.nameEn} — Your order was claimed but later cancelled by the receiver`; } await notifyUser( enriched.userId, titleAr, titleEn, bodyAr, bodyEn, orderId, userId, ); } if (enriched) await emitToUser(enriched.userId, "order_updated", enriched); const receivers = await getReceiverUserIds(); await broadcastIncomingChanged(receivers); if (existing.assignedTo) { await emitToUser(existing.assignedTo, "order_incoming_changed"); } res.json(enriched); }, ); // Authorization + deletion for a single order, shared between the // per-id DELETE and the bulk-delete endpoint. Returns a discriminated // result the callers can map to HTTP status codes. type DeleteOutcome = | { ok: true } | { ok: false; reason: "not_found" | "forbidden" }; async function authorizeAndDeleteOrder( orderId: number, actorId: number, flags: { admin: boolean; receiver: boolean }, ): Promise { const [existing] = await db .select() .from(serviceOrdersTable) .where(eq(serviceOrdersTable.id, orderId)); if (!existing) return { ok: false, reason: "not_found" }; const isOwner = existing.userId === actorId; const isTerminal = existing.status === "completed" || existing.status === "cancelled"; // Receivers may only delete what they actually see on /orders/incoming // (see GET /orders/incoming above): an unclaimed pending order, or one // they have already claimed and is still active. Anything else — another // receiver's claimed order, or any terminal order — is out of scope and // must 403 even at the API layer. const isUnclaimedPending = existing.status === "pending" && existing.assignedTo === null; const isMyActive = existing.assignedTo === actorId && (existing.status === "received" || existing.status === "preparing"); const isInMyIncomingView = isUnclaimedPending || isMyActive; // Allowed when: // - admin (any order, any status) // - owner of a terminal order (completed/cancelled) // - holder of orders.receive permission, but only for orders that // appear in their own incoming view (mirrors GET /orders/incoming). const allowed = flags.admin || (isOwner && isTerminal) || (flags.receiver && isInMyIncomingView); if (!allowed) return { ok: false, reason: "forbidden" }; await db .delete(serviceOrdersTable) .where(eq(serviceOrdersTable.id, orderId)); await emitToUser(existing.userId, "order_deleted", { id: orderId }); if (existing.assignedTo && existing.assignedTo !== existing.userId) { await emitToUser(existing.assignedTo, "order_deleted", { id: orderId }); } // If the deleted order was visible to receivers (pending or active), // refresh their incoming queues. if ( existing.status === "pending" || existing.status === "received" || existing.status === "preparing" ) { const receivers = await getReceiverUserIds(); await broadcastIncomingChanged(receivers); } return { ok: true }; } // DELETE /api/orders/:id — owner can delete terminal orders; admin can delete // any; users with `orders.receive` can delete any incoming-queue order. router.delete( "/orders/:id", requireAuth, async (req, res): Promise => { const params = ServiceOrderIdParams.safeParse(req.params); if (!params.success) { res.status(400).json({ error: params.error.message }); return; } const userId = req.session.userId!; const [admin, receiver] = await Promise.all([ isAdmin(userId), hasReceivePermission(userId), ]); const result = await authorizeAndDeleteOrder(params.data.id, userId, { admin, receiver, }); if (!result.ok) { if (result.reason === "not_found") { res.status(404).json({ error: "Order not found" }); } else { res.status(403).json({ error: "Forbidden" }); } return; } res.status(204).end(); }, ); // POST /api/orders/bulk-delete — delete several orders in one round-trip. // Authorization is enforced **per id** using the same rules as the per-id // DELETE — there is no batching shortcut. Returns a summary the UI can use // to surface partial failures. router.post( "/orders/bulk-delete", requireAuth, async (req, res): Promise => { const parsed = BulkDeleteServiceOrdersBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const userId = req.session.userId!; // Dedupe ids so we don't double-emit on accidental repeats. const ids = Array.from(new Set(parsed.data.ids)); const [admin, receiver] = await Promise.all([ isAdmin(userId), hasReceivePermission(userId), ]); const deletedIds: number[] = []; const failedIds: number[] = []; for (const id of ids) { const result = await authorizeAndDeleteOrder(id, userId, { admin, receiver, }); if (result.ok) deletedIds.push(id); else failedIds.push(id); } res.json({ deletedIds, failedIds }); }, ); export default router;