diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index 54e0347d..233d740b 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -76,12 +76,11 @@ export function requirePermission(name: string) { } const rows = await db - .select({ roleName: rolesTable.name }) + .select({ permName: permissionsTable.name }) .from(userRolesTable) - .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) .innerJoin( rolePermissionsTable, - eq(rolePermissionsTable.roleId, rolesTable.id), + eq(rolePermissionsTable.roleId, userRolesTable.roleId), ) .innerJoin( permissionsTable, @@ -94,10 +93,7 @@ export function requirePermission(name: string) { ), ); - const isAdmin = rows.some((r) => r.roleName === "admin"); - const hasPerm = rows.length > 0; - - if (!isAdmin && !hasPerm) { + if (rows.length === 0) { res.status(403).json({ error: "Forbidden" }); return; } diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 6c371053..d37c88cc 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq, and, desc, sql, isNull, inArray } from "drizzle-orm"; +import { eq, and, desc, sql, isNull, inArray, or } from "drizzle-orm"; import { db } from "@workspace/db"; import { serviceOrdersTable, @@ -11,7 +11,7 @@ import { rolePermissionsTable, permissionsTable, } from "@workspace/db"; -import { requireAuth, requirePermission, userHasPermission } from "../middlewares/auth"; +import { requireAuth, requirePermission } from "../middlewares/auth"; import { CreateServiceOrderBody, UpdateServiceOrderStatusParams as ServiceOrderIdParams, @@ -20,25 +20,20 @@ import { const router: IRouter = Router(); -const ACTIVE_STATUSES = ["pending", "claimed", "delivered"] as const; - -type EnrichedOrder = Awaited>; +const PERM_RECEIVE = "orders.receive"; +const ACTIVE_STATUSES = ["pending", "received", "preparing"] as const; async function loadOrder(id: number) { const [row] = await db .select({ id: serviceOrdersTable.id, serviceId: serviceOrdersTable.serviceId, - requestedBy: serviceOrdersTable.requestedBy, + userId: serviceOrdersTable.userId, assignedTo: serviceOrdersTable.assignedTo, - quantity: serviceOrdersTable.quantity, notes: serviceOrdersTable.notes, status: serviceOrdersTable.status, createdAt: serviceOrdersTable.createdAt, - claimedAt: serviceOrdersTable.claimedAt, - deliveredAt: serviceOrdersTable.deliveredAt, - receivedAt: serviceOrdersTable.receivedAt, - cancelledAt: serviceOrdersTable.cancelledAt, + updatedAt: serviceOrdersTable.updatedAt, service: { id: servicesTable.id, nameAr: servicesTable.nameAr, @@ -52,7 +47,7 @@ async function loadOrder(id: number) { if (!row) return null; - const userIds = [row.requestedBy, ...(row.assignedTo ? [row.assignedTo] : [])]; + const userIds = [row.userId, ...(row.assignedTo ? [row.assignedTo] : [])]; const userRows = await db .select({ id: usersTable.id, @@ -65,29 +60,33 @@ async function loadOrder(id: number) { .where(inArray(usersTable.id, userIds)); const userById = new Map(userRows.map((u) => [u.id, u])); - return { ...row, - requester: userById.get(row.requestedBy) ?? null, + requester: userById.get(row.userId) ?? null, assignee: row.assignedTo ? (userById.get(row.assignedTo) ?? null) : null, }; } async function getReceiverUserIds(): Promise { - // Anyone with `orders:receive` permission OR admin role const rows = await db .selectDistinct({ userId: userRolesTable.userId }) .from(userRolesTable) - .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) - .leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, rolesTable.id)) - .leftJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id)) - .where( - sql`${rolesTable.name} = 'admin' OR ${permissionsTable.name} = 'orders:receive'`, - ); + .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 emitToUser(userId: number, event: string, payload: unknown) { +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 emitToUser(userId: number, event: string, payload?: unknown) { const { io } = await import("../index.js"); io.to(`user:${userId}`).emit(event, payload); } @@ -98,8 +97,7 @@ async function notifyUser( titleEn: string, bodyAr: string, bodyEn: string, - type: string, - relatedId: number, + orderId: number, ) { const [notification] = await db .insert(notificationsTable) @@ -109,9 +107,9 @@ async function notifyUser( titleEn, bodyAr, bodyEn, - type, - relatedId, - relatedType: "service_order", + type: "order", + relatedId: orderId, + relatedType: "order", }) .returning(); await emitToUser(userId, "notification_created", notification); @@ -119,11 +117,11 @@ async function notifyUser( async function broadcastIncomingChanged(receiverIds: number[]) { for (const uid of receiverIds) { - await emitToUser(uid, "order_incoming_changed", { changed: true }); + await emitToUser(uid, "order_incoming_changed"); } } -// POST /api/orders - place a new order +// 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); @@ -150,30 +148,21 @@ router.post("/orders", requireAuth, async (req, res): Promise => { .insert(serviceOrdersTable) .values({ serviceId: parsed.data.serviceId, - requestedBy: userId, - quantity: parsed.data.quantity ?? 1, + userId, notes: parsed.data.notes ?? null, }) .returning(); const enriched = await loadOrder(order.id); - - // Notify all receivers const receivers = await getReceiverUserIds(); - const requesterName = - enriched?.requester?.displayNameAr ?? - enriched?.requester?.displayNameEn ?? - enriched?.requester?.username ?? - ""; for (const rid of receivers) { if (rid === userId) continue; await notifyUser( rid, - "طلب خدمة جديد", - "New service order", - `${requesterName} طلب ${service.nameAr}`, - `${requesterName} requested ${service.nameEn}`, - "order", + "طلب جديد", + "New order", + service.nameAr, + service.nameEn, order.id, ); } @@ -182,34 +171,39 @@ router.post("/orders", requireAuth, async (req, res): Promise => { res.status(201).json(enriched); }); -// GET /api/orders/my - orders placed by the current user +// 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.requestedBy, userId)) + .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 - pending+active orders for receivers +// GET /api/orders/incoming — receivers only router.get( "/orders/incoming", requireAuth, - requirePermission("orders:receive"), + requirePermission(PERM_RECEIVE), async (req, res): Promise => { const userId = req.session.userId!; - // Show: all pending (unassigned), plus orders assigned to this receiver const rows = await db .select({ id: serviceOrdersTable.id }) .from(serviceOrdersTable) .where( - and( - inArray(serviceOrdersTable.status, ACTIVE_STATUSES as unknown as string[]), - sql`(${serviceOrdersTable.assignedTo} IS NULL OR ${serviceOrdersTable.assignedTo} = ${userId})`, + 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)) @@ -219,11 +213,60 @@ router.get( }, ); -// PATCH /api/orders/:id/status - receiver updates status (claim/deliver/cancel) +// 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) { + 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, + ); + 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, - requirePermission("orders:receive"), async (req, res): Promise => { const params = ServiceOrderIdParams.safeParse(req.params); if (!params.success) { @@ -235,7 +278,6 @@ router.patch( res.status(400).json({ error: parsed.error.message }); return; } - const userId = req.session.userId!; const orderId = params.data.id; const next = parsed.data.status; @@ -249,145 +291,73 @@ router.patch( return; } - if (next === "claimed") { - // Atomic claim: only succeeds if still pending and unassigned - const [updated] = await db - .update(serviceOrdersTable) - .set({ - status: "claimed", - assignedTo: userId, - claimedAt: new Date(), - }) - .where( - and( - eq(serviceOrdersTable.id, orderId), - eq(serviceOrdersTable.status, "pending"), - isNull(serviceOrdersTable.assignedTo), - ), - ) - .returning(); - if (!updated) { - res.status(409).json({ error: "already_claimed" }); - return; - } - } else if (next === "delivered") { - if (existing.status !== "claimed" || existing.assignedTo !== userId) { - res.status(409).json({ error: "invalid_transition" }); - return; - } - await db - .update(serviceOrdersTable) - .set({ status: "delivered", deliveredAt: new Date() }) - .where(eq(serviceOrdersTable.id, orderId)); - } else if (next === "cancelled") { - // Receiver may cancel only their own active orders, or admin via permission middleware - const isAdminUser = await userHasPermission(userId, "users:manage"); - if ( - !isAdminUser && - !(existing.assignedTo === userId && existing.status !== "received") - ) { + const admin = await isAdmin(userId); + + if (next === "preparing" || next === "completed") { + // Only the assigned receiver or admin + const isAssignee = existing.assignedTo === userId; + if (!isAssignee && !admin) { res.status(403).json({ error: "Forbidden" }); return; } - if (existing.status === "received" || existing.status === "cancelled") { - res.status(409).json({ error: "invalid_transition" }); + 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 === "cancelled") { + const isOwner = existing.userId === userId; + const cancellableByOwner = + existing.status === "pending" || existing.status === "received"; + if (!admin && !(isOwner && cancellableByOwner)) { + res.status(403).json({ error: "Forbidden" }); + return; + } + // Admin can cancel any status. Already-cancelled is a no-op invalid transition. + if (existing.status === "cancelled") { + res.status(400).json({ error: "invalid_transition" }); return; } - await db - .update(serviceOrdersTable) - .set({ status: "cancelled", cancelledAt: new Date() }) - .where(eq(serviceOrdersTable.id, orderId)); } else { - res.status(400).json({ error: "Unsupported status transition" }); - return; - } - - const enriched = await loadOrder(orderId); - if (!enriched) { - res.status(404).json({ error: "Order not found" }); - return; - } - - // Notify the requester about status change - const titleMap: Record = { - claimed: ["تم استلام طلبك", "Your order was claimed"], - delivered: ["تم تسليم طلبك", "Your order was delivered"], - cancelled: ["تم إلغاء طلبك", "Your order was cancelled"], - }; - const [titleAr, titleEn] = titleMap[next] ?? ["", ""]; - if (titleAr) { - await notifyUser( - existing.requestedBy, - titleAr, - titleEn, - enriched.service.nameAr, - enriched.service.nameEn, - "order", - orderId, - ); - } - - // Notify other receivers that the incoming list changed - const receivers = await getReceiverUserIds(); - await broadcastIncomingChanged(receivers); - // Notify requester their order changed too - await emitToUser(existing.requestedBy, "order_updated", enriched); - - res.json(enriched); - }, -); - -// PATCH /api/orders/:id/confirm-receipt - requester confirms receipt -router.patch( - "/orders/:id/confirm-receipt", - 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 orderId = params.data.id; - - const [existing] = await db - .select() - .from(serviceOrdersTable) - .where(eq(serviceOrdersTable.id, orderId)); - if (!existing) { - res.status(404).json({ error: "Order not found" }); - return; - } - if (existing.requestedBy !== userId) { - res.status(403).json({ error: "Forbidden" }); - return; - } - if (existing.status !== "delivered") { - res.status(409).json({ error: "invalid_transition" }); + res.status(400).json({ error: "invalid_transition" }); return; } await db .update(serviceOrdersTable) - .set({ status: "received", receivedAt: new Date() }) + .set({ status: next, updatedAt: new Date() }) .where(eq(serviceOrdersTable.id, orderId)); const enriched = await loadOrder(orderId); - if (existing.assignedTo) { + // Notify owner on every status change initiated by a receiver/admin (not by owner cancel) + const initiatedByOwner = existing.userId === userId; + if (!initiatedByOwner && enriched) { + const titleMap: Record = { + preparing: ["طلبك قيد التحضير", "Your order is being prepared"], + completed: ["تم إكمال طلبك", "Your order is completed"], + cancelled: ["تم إلغاء طلبك", "Your order was cancelled"], + }; + const [titleAr, titleEn] = titleMap[next]; await notifyUser( - existing.assignedTo, - "تم تأكيد الاستلام", - "Receipt confirmed", - enriched?.service.nameAr ?? "", - enriched?.service.nameEn ?? "", - "order", + enriched.userId, + titleAr, + titleEn, + enriched.service.nameAr, + enriched.service.nameEn, orderId, ); - await emitToUser(existing.assignedTo, "order_updated", enriched); } + 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); }, diff --git a/artifacts/api-server/tests/service-orders.test.mjs b/artifacts/api-server/tests/service-orders.test.mjs index fbadd2d4..2a56e622 100644 --- a/artifacts/api-server/tests/service-orders.test.mjs +++ b/artifacts/api-server/tests/service-orders.test.mjs @@ -4,16 +4,13 @@ import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; -if (!DATABASE_URL) { - throw new Error("DATABASE_URL must be set to run these tests"); -} +if (!DATABASE_URL) throw new Error("DATABASE_URL must be set to run these tests"); const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); - const createdUserIds = []; const createdOrderIds = []; @@ -28,11 +25,12 @@ async function createUser(prefix, roleName) { ); const id = rows[0].id; createdUserIds.push(id); - await pool.query( - `INSERT INTO user_roles (user_id, role_id) - SELECT $1, id FROM roles WHERE name = $2`, - [id, roleName], - ); + if (roleName) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2`, + [id, roleName], + ); + } return { id, username }; } @@ -52,10 +50,7 @@ async function login(username) { async function call(cookie, method, path, body) { return fetch(`${API_BASE}/api${path}`, { method, - headers: { - "Content-Type": "application/json", - Cookie: cookie, - }, + headers: { "Content-Type": "application/json", Cookie: cookie }, body: body ? JSON.stringify(body) : undefined, }); } @@ -77,112 +72,171 @@ after(async () => { ]); } if (createdUserIds.length > 0) { - await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [ - createdUserIds, - ]); - await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ - createdUserIds, - ]); + await pool.query(`DELETE FROM service_orders WHERE user_id = ANY($1::int[]) OR assigned_to = ANY($1::int[])`, [createdUserIds]); + await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [createdUserIds]); + await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]); } await pool.end(); }); -test("full order lifecycle: place → claim → deliver → confirm", async () => { +test("client can place + list own orders; service summary excludes descriptions", async () => { const requester = await createUser("req", "user"); - const receiver = await createUser("recv", "order_receiver"); + const cookie = await login(requester.username); - const reqCookie = await login(requester.username); - const recvCookie = await login(receiver.username); - - // Place order - let res = await call(reqCookie, "POST", "/orders", { + let res = await call(cookie, "POST", "/orders", { serviceId, - quantity: 2, notes: "extra hot", }); assert.equal(res.status, 201); const order = await res.json(); createdOrderIds.push(order.id); assert.equal(order.status, "pending"); - assert.equal(order.quantity, 2); - assert.equal(order.requestedBy, requester.id); + assert.equal(order.userId, requester.id); assert.ok(order.service); assert.ok(order.service.nameAr); - // Should NOT include description fields + assert.ok(order.service.nameEn); assert.equal(order.service.descriptionAr, undefined); + assert.equal(order.service.descriptionEn, undefined); - // Receiver lists incoming - res = await call(recvCookie, "GET", "/orders/incoming"); + res = await call(cookie, "GET", "/orders/my"); assert.equal(res.status, 200); - const incoming = await res.json(); - assert.ok(incoming.find((o) => o.id === order.id)); - - // Non-receiver gets 403 on incoming - res = await call(reqCookie, "GET", "/orders/incoming"); - assert.equal(res.status, 403); - - // Receiver claims - res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, { - status: "claimed", - }); - assert.equal(res.status, 200); - const claimed = await res.json(); - assert.equal(claimed.status, "claimed"); - assert.equal(claimed.assignedTo, receiver.id); - - // Requester cannot confirm receipt while only claimed - res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`); - assert.equal(res.status, 409); - - // Receiver delivers - res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, { - status: "delivered", - }); - assert.equal(res.status, 200); - assert.equal((await res.json()).status, "delivered"); - - // Other user cannot confirm receipt - const stranger = await createUser("str", "user"); - const strCookie = await login(stranger.username); - res = await call(strCookie, "PATCH", `/orders/${order.id}/confirm-receipt`); - assert.equal(res.status, 403); - - // Requester confirms receipt - res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`); - assert.equal(res.status, 200); - assert.equal((await res.json()).status, "received"); - - // Verify the order shows up in /orders/my - res = await call(reqCookie, "GET", "/orders/my"); - assert.equal(res.status, 200); - const my = await res.json(); - assert.ok(my.find((o) => o.id === order.id && o.status === "received")); + const list = await res.json(); + const found = list.find((o) => o.id === order.id); + assert.ok(found); + assert.equal(found.service.descriptionAr, undefined); }); -test("atomic claim: only one receiver wins", async () => { - const requester = await createUser("req2", "user"); - const recv1 = await createUser("recv1", "order_receiver"); - const recv2 = await createUser("recv2", "order_receiver"); +test("non-receiver gets 403 from /incoming and /confirm-receipt", async () => { + const owner = await createUser("own", "user"); + const cookieOwner = await login(owner.username); - const reqCookie = await login(requester.username); - const c1 = await login(recv1.username); - const c2 = await login(recv2.username); - - let res = await call(reqCookie, "POST", "/orders", { serviceId }); - assert.equal(res.status, 201); + let res = await call(cookieOwner, "POST", "/orders", { serviceId }); const order = await res.json(); createdOrderIds.push(order.id); - const [r1, r2] = await Promise.all([ - call(c1, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }), - call(c2, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }), - ]); - const statuses = [r1.status, r2.status].sort(); - assert.deepEqual(statuses, [200, 409]); + // Non-receiver listing + res = await call(cookieOwner, "GET", "/orders/incoming"); + assert.equal(res.status, 403); + + // Non-receiver tries to claim + res = await call(cookieOwner, "PATCH", `/orders/${order.id}/confirm-receipt`); + assert.equal(res.status, 403); }); -test("non-authenticated cannot place order", async () => { +test("user with no roles is 403 on /incoming (middleware has no auto-bypass)", async () => { + const noRole = await createUser("noro", null); + const cookie = await login(noRole.username); + const res = await call(cookie, "GET", "/orders/incoming"); + assert.equal(res.status, 403); +}); + +test("two parallel confirm-receipt: one wins (200), other 409 already_claimed", async () => { + const owner = await createUser("own2", "user"); + const r1 = await createUser("r1", "order_receiver"); + const r2 = await createUser("r2", "order_receiver"); + const co = await login(owner.username); + const c1 = await login(r1.username); + const c2 = await login(r2.username); + + let res = await call(co, "POST", "/orders", { serviceId }); + const order = await res.json(); + createdOrderIds.push(order.id); + + const [a, b] = await Promise.all([ + call(c1, "PATCH", `/orders/${order.id}/confirm-receipt`), + call(c2, "PATCH", `/orders/${order.id}/confirm-receipt`), + ]); + const codes = [a.status, b.status].sort(); + assert.deepEqual(codes, [200, 409]); + + const losing = a.status === 409 ? a : b; + const body = await losing.json(); + assert.equal(body.error, "already_claimed"); +}); + +test("status transitions matrix: receiver flows + owner/admin cancel rules", async () => { + const owner = await createUser("own3", "user"); + const recv = await createUser("recv3", "order_receiver"); + const stranger = await createUser("strg3", "user"); + const adminUser = await createUser("admn3", "admin"); + const co = await login(owner.username); + const cr = await login(recv.username); + const cs = await login(stranger.username); + const ca = await login(adminUser.username); + + // Order 1: receiver flow pending → received → preparing → completed + let res = await call(co, "POST", "/orders", { serviceId }); + let order = await res.json(); + createdOrderIds.push(order.id); + + // Owner CAN cancel while pending + res = await call(co, "POST", "/orders", { serviceId }); + const cancelOrder = await res.json(); + createdOrderIds.push(cancelOrder.id); + res = await call(co, "PATCH", `/orders/${cancelOrder.id}/status`, { + status: "cancelled", + }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, "cancelled"); + + // Stranger cannot move status to preparing + res = await call(cs, "PATCH", `/orders/${order.id}/status`, { + status: "preparing", + }); + assert.equal(res.status, 403); + + // Receiver claims first + res = await call(cr, "PATCH", `/orders/${order.id}/confirm-receipt`); + assert.equal(res.status, 200); + + // Receiver moves to preparing (allowed from received) + res = await call(cr, "PATCH", `/orders/${order.id}/status`, { + status: "preparing", + }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, "preparing"); + + // Owner cannot cancel once preparing (only pending/received) + res = await call(co, "PATCH", `/orders/${order.id}/status`, { + status: "cancelled", + }); + assert.equal(res.status, 403); + + // Admin can cancel anytime + // (use a NEW preparing order) + res = await call(co, "POST", "/orders", { serviceId }); + const o2 = await res.json(); + createdOrderIds.push(o2.id); + res = await call(cr, "PATCH", `/orders/${o2.id}/confirm-receipt`); + assert.equal(res.status, 200); + res = await call(cr, "PATCH", `/orders/${o2.id}/status`, { status: "preparing" }); + assert.equal(res.status, 200); + res = await call(ca, "PATCH", `/orders/${o2.id}/status`, { status: "cancelled" }); + assert.equal(res.status, 200); + + // Receiver completes original order + res = await call(cr, "PATCH", `/orders/${order.id}/status`, { + status: "completed", + }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, "completed"); + + // Cannot transition completed → anything (non-admin) + res = await call(cr, "PATCH", `/orders/${order.id}/status`, { + status: "preparing", + }); + assert.equal(res.status, 400); + + // Admin CAN cancel a completed order + res = await call(ca, "PATCH", `/orders/${order.id}/status`, { + status: "cancelled", + }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, "cancelled"); +}); + +test("unauthenticated cannot place order", async () => { const res = await fetch(`${API_BASE}/api/orders`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 211546ee..5199d4b8 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -273,31 +273,23 @@ export type ServiceOrderStatus = export const ServiceOrderStatus = { pending: "pending", - claimed: "claimed", - delivered: "delivered", received: "received", + preparing: "preparing", + completed: "completed", cancelled: "cancelled", } as const; export interface ServiceOrder { id: number; serviceId: number; - requestedBy: number; + userId: number; /** @nullable */ assignedTo?: number | null; - quantity: number; /** @nullable */ notes?: string | null; status: ServiceOrderStatus; createdAt: string; - /** @nullable */ - claimedAt?: string | null; - /** @nullable */ - deliveredAt?: string | null; - /** @nullable */ - receivedAt?: string | null; - /** @nullable */ - cancelledAt?: string | null; + updatedAt: string; service: ServiceOrderService; requester?: ServiceOrderUser | null; assignee?: ServiceOrderUser | null; @@ -305,11 +297,6 @@ export interface ServiceOrder { export interface CreateServiceOrderBody { serviceId: number; - /** - * @minimum 1 - * @maximum 50 - */ - quantity?: number; /** * @maxLength 500 * @nullable @@ -321,8 +308,8 @@ export type UpdateServiceOrderStatusBodyStatus = (typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus]; export const UpdateServiceOrderStatusBodyStatus = { - claimed: "claimed", - delivered: "delivered", + preparing: "preparing", + completed: "completed", cancelled: "cancelled", } as const; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 51252437..ab24beea 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -2462,7 +2462,7 @@ export function useListMyServiceOrders< } /** - * @summary List active orders for receivers (requires orders:receive) + * @summary List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled. */ export const getListIncomingServiceOrdersUrl = () => { return `/api/orders/incoming`; @@ -2514,7 +2514,7 @@ export type ListIncomingServiceOrdersQueryResult = NonNullable< export type ListIncomingServiceOrdersQueryError = ErrorType; /** - * @summary List active orders for receivers (requires orders:receive) + * @summary List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled. */ export function useListIncomingServiceOrders< @@ -2538,7 +2538,7 @@ export function useListIncomingServiceOrders< } /** - * @summary Receiver updates order status (claim/deliver/cancel) + * @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time). */ export const getUpdateServiceOrderStatusUrl = (id: number) => { return `/api/orders/${id}/status`; @@ -2603,7 +2603,7 @@ export type UpdateServiceOrderStatusMutationBody = export type UpdateServiceOrderStatusMutationError = ErrorType; /** - * @summary Receiver updates order status (claim/deliver/cancel) + * @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time). */ export const useUpdateServiceOrderStatus = < TError = ErrorType, @@ -2626,7 +2626,7 @@ export const useUpdateServiceOrderStatus = < }; /** - * @summary Requester confirms they received their order + * @summary Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned. */ export const getConfirmServiceOrderReceiptUrl = (id: number) => { return `/api/orders/${id}/confirm-receipt`; @@ -2687,7 +2687,7 @@ export type ConfirmServiceOrderReceiptMutationResult = NonNullable< export type ConfirmServiceOrderReceiptMutationError = ErrorType; /** - * @summary Requester confirms they received their order + * @summary Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned. */ export const useConfirmServiceOrderReceipt = < TError = ErrorType, diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 3c380a09..efe286ff 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -634,7 +634,7 @@ paths: get: operationId: listIncomingServiceOrders tags: [orders] - summary: List active orders for receivers (requires orders:receive) + summary: List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled. responses: "200": description: Incoming orders @@ -655,7 +655,7 @@ paths: patch: operationId: updateServiceOrderStatus tags: [orders] - summary: Receiver updates order status (claim/deliver/cancel) + summary: Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time). parameters: - name: id in: path @@ -686,7 +686,7 @@ paths: patch: operationId: confirmServiceOrderReceipt tags: [orders] - summary: Requester confirms they received their order + summary: Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned. parameters: - name: id in: path @@ -1782,7 +1782,7 @@ components: ServiceOrderStatus: type: string - enum: [pending, claimed, delivered, received, cancelled] + enum: [pending, received, preparing, completed, cancelled] ServiceOrder: type: object @@ -1791,12 +1791,10 @@ components: type: integer serviceId: type: integer - requestedBy: + userId: type: integer assignedTo: type: ["integer", "null"] - quantity: - type: integer notes: type: ["string", "null"] status: @@ -1804,17 +1802,8 @@ components: createdAt: type: string format: date-time - claimedAt: - type: ["string", "null"] - format: date-time - deliveredAt: - type: ["string", "null"] - format: date-time - receivedAt: - type: ["string", "null"] - format: date-time - cancelledAt: - type: ["string", "null"] + updatedAt: + type: string format: date-time service: $ref: "#/components/schemas/ServiceOrderService" @@ -1829,10 +1818,10 @@ components: required: - id - serviceId - - requestedBy - - quantity + - userId - status - createdAt + - updatedAt - service CreateServiceOrderBody: @@ -1840,11 +1829,6 @@ components: properties: serviceId: type: integer - quantity: - type: integer - minimum: 1 - maximum: 50 - default: 1 notes: type: ["string", "null"] maxLength: 500 @@ -1856,7 +1840,7 @@ components: properties: status: type: string - enum: [claimed, delivered, cancelled] + enum: [preparing, completed, cancelled] required: - status diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 87ee99f1..8b95004c 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -554,18 +554,10 @@ export const DeleteServiceParams = zod.object({ /** * @summary Place a service order */ -export const createServiceOrderBodyQuantityDefault = 1; -export const createServiceOrderBodyQuantityMax = 50; - export const createServiceOrderBodyNotesMax = 500; export const CreateServiceOrderBody = zod.object({ serviceId: zod.number(), - quantity: zod - .number() - .min(1) - .max(createServiceOrderBodyQuantityMax) - .default(createServiceOrderBodyQuantityDefault), notes: zod.string().max(createServiceOrderBodyNotesMax).nullish(), }); @@ -575,22 +567,18 @@ export const CreateServiceOrderBody = zod.object({ export const ListMyServiceOrdersResponseItem = zod.object({ id: zod.number(), serviceId: zod.number(), - requestedBy: zod.number(), + userId: zod.number(), assignedTo: zod.number().nullish(), - quantity: zod.number(), notes: zod.string().nullish(), status: zod.enum([ "pending", - "claimed", - "delivered", "received", + "preparing", + "completed", "cancelled", ]), createdAt: zod.coerce.date(), - claimedAt: zod.coerce.date().nullish(), - deliveredAt: zod.coerce.date().nullish(), - receivedAt: zod.coerce.date().nullish(), - cancelledAt: zod.coerce.date().nullish(), + updatedAt: zod.coerce.date(), service: zod.object({ id: zod.number(), nameAr: zod.string(), @@ -627,27 +615,23 @@ export const ListMyServiceOrdersResponse = zod.array( ); /** - * @summary List active orders for receivers (requires orders:receive) + * @summary List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled. */ export const ListIncomingServiceOrdersResponseItem = zod.object({ id: zod.number(), serviceId: zod.number(), - requestedBy: zod.number(), + userId: zod.number(), assignedTo: zod.number().nullish(), - quantity: zod.number(), notes: zod.string().nullish(), status: zod.enum([ "pending", - "claimed", - "delivered", "received", + "preparing", + "completed", "cancelled", ]), createdAt: zod.coerce.date(), - claimedAt: zod.coerce.date().nullish(), - deliveredAt: zod.coerce.date().nullish(), - receivedAt: zod.coerce.date().nullish(), - cancelledAt: zod.coerce.date().nullish(), + updatedAt: zod.coerce.date(), service: zod.object({ id: zod.number(), nameAr: zod.string(), @@ -684,35 +668,31 @@ export const ListIncomingServiceOrdersResponse = zod.array( ); /** - * @summary Receiver updates order status (claim/deliver/cancel) + * @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time). */ export const UpdateServiceOrderStatusParams = zod.object({ id: zod.coerce.number(), }); export const UpdateServiceOrderStatusBody = zod.object({ - status: zod.enum(["claimed", "delivered", "cancelled"]), + status: zod.enum(["preparing", "completed", "cancelled"]), }); export const UpdateServiceOrderStatusResponse = zod.object({ id: zod.number(), serviceId: zod.number(), - requestedBy: zod.number(), + userId: zod.number(), assignedTo: zod.number().nullish(), - quantity: zod.number(), notes: zod.string().nullish(), status: zod.enum([ "pending", - "claimed", - "delivered", "received", + "preparing", + "completed", "cancelled", ]), createdAt: zod.coerce.date(), - claimedAt: zod.coerce.date().nullish(), - deliveredAt: zod.coerce.date().nullish(), - receivedAt: zod.coerce.date().nullish(), - cancelledAt: zod.coerce.date().nullish(), + updatedAt: zod.coerce.date(), service: zod.object({ id: zod.number(), nameAr: zod.string(), @@ -746,7 +726,7 @@ export const UpdateServiceOrderStatusResponse = zod.object({ }); /** - * @summary Requester confirms they received their order + * @summary Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned. */ export const ConfirmServiceOrderReceiptParams = zod.object({ id: zod.coerce.number(), @@ -755,22 +735,18 @@ export const ConfirmServiceOrderReceiptParams = zod.object({ export const ConfirmServiceOrderReceiptResponse = zod.object({ id: zod.number(), serviceId: zod.number(), - requestedBy: zod.number(), + userId: zod.number(), assignedTo: zod.number().nullish(), - quantity: zod.number(), notes: zod.string().nullish(), status: zod.enum([ "pending", - "claimed", - "delivered", "received", + "preparing", + "completed", "cancelled", ]), createdAt: zod.coerce.date(), - claimedAt: zod.coerce.date().nullish(), - deliveredAt: zod.coerce.date().nullish(), - receivedAt: zod.coerce.date().nullish(), - cancelledAt: zod.coerce.date().nullish(), + updatedAt: zod.coerce.date(), service: zod.object({ id: zod.number(), nameAr: zod.string(), diff --git a/lib/db/src/schema/service-orders.ts b/lib/db/src/schema/service-orders.ts index a212ed20..c077cf79 100644 --- a/lib/db/src/schema/service-orders.ts +++ b/lib/db/src/schema/service-orders.ts @@ -1,31 +1,34 @@ -import { pgTable, text, serial, timestamp, integer, varchar } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, timestamp, integer, varchar, check } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; import { usersTable } from "./users"; import { servicesTable } from "./services"; -export const serviceOrdersTable = pgTable("service_orders", { - id: serial("id").primaryKey(), - serviceId: integer("service_id").notNull().references(() => servicesTable.id, { onDelete: "restrict" }), - requestedBy: integer("requested_by").notNull().references(() => usersTable.id, { onDelete: "cascade" }), - assignedTo: integer("assigned_to").references(() => usersTable.id, { onDelete: "set null" }), - quantity: integer("quantity").notNull().default(1), - notes: text("notes"), - status: varchar("status", { length: 30 }).notNull().default("pending"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - claimedAt: timestamp("claimed_at", { withTimezone: true }), - deliveredAt: timestamp("delivered_at", { withTimezone: true }), - receivedAt: timestamp("received_at", { withTimezone: true }), - cancelledAt: timestamp("cancelled_at", { withTimezone: true }), -}); +export const serviceOrdersTable = pgTable( + "service_orders", + { + id: serial("id").primaryKey(), + userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }), + serviceId: integer("service_id").notNull().references(() => servicesTable.id, { onDelete: "restrict" }), + notes: text("notes"), + status: varchar("status", { length: 20 }).notNull().default("pending"), + assignedTo: integer("assigned_to").references(() => usersTable.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), + }, + (t) => [ + check( + "service_orders_status_check", + sql`${t.status} IN ('pending','received','preparing','completed','cancelled')`, + ), + ], +); export const insertServiceOrderSchema = createInsertSchema(serviceOrdersTable).omit({ id: true, createdAt: true, - claimedAt: true, - deliveredAt: true, - receivedAt: true, - cancelledAt: true, + updatedAt: true, status: true, assignedTo: true, }); @@ -34,9 +37,9 @@ export type ServiceOrder = typeof serviceOrdersTable.$inferSelect; export const SERVICE_ORDER_STATUSES = [ "pending", - "claimed", - "delivered", "received", + "preparing", + "completed", "cancelled", ] as const; export type ServiceOrderStatus = (typeof SERVICE_ORDER_STATUSES)[number]; diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index cfd703a4..ce93714e 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -37,7 +37,7 @@ async function main() { { name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" }, { name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" }, { name: "chat:access", descriptionAr: "الوصول للمحادثات", descriptionEn: "Access Chat" }, - { name: "orders:receive", descriptionAr: "استقبال الطلبات", descriptionEn: "Receive Service Orders" }, + { name: "orders.receive", descriptionAr: "استلام الطلبات", descriptionEn: "Receive service orders" }, ]; await db.insert(permissionsTable).values(permissions).onConflictDoNothing(); @@ -50,8 +50,8 @@ async function main() { .insert(rolesTable) .values({ name: "order_receiver", - descriptionAr: "مستقبل الطلبات", - descriptionEn: "Service Order Receiver", + descriptionAr: "مستلم الطلبات", + descriptionEn: "Order Receiver", }) .onConflictDoNothing() .returning(); @@ -83,7 +83,7 @@ async function main() { // Assign orders:receive permission to order_receiver role if (orderReceiverResolved && allInsertedPerms.length > 0) { - const ordersPerm = allInsertedPerms.find((p) => p.name === "orders:receive"); + const ordersPerm = allInsertedPerms.find((p) => p.name === "orders.receive"); if (ordersPerm) { await db .insert(rolePermissionsTable)