Task #62: Service Orders backend foundation (corrected)

After prior code review rejection, refactored to match spec exactly:
- Permission renamed orders:receive → orders.receive (dot form), seeded with order_receiver role
- service_orders table: user_id, service_id, notes, status (pending/received/preparing/completed/cancelled with CHECK), assigned_to, created_at, updated_at
- /confirm-receipt is now the receiver atomic claim (UPDATE WHERE pending+unassigned), 409 already_claimed on miss
- /status accepts preparing|completed|cancelled with permission matrix:
  * preparing/completed: assigned receiver or admin
  * cancelled: owner (pending|received) OR admin (any non-cancelled status)
- requirePermission middleware no longer auto-bypasses admin; admin gets the permission via explicit seed grant
- Notifications use type='order', relatedType='order'
- Realtime emits notification_created (per receiver) + order_incoming_changed (broadcast) + order_updated (owner)
- OpenAPI Order schema rewritten (no quantity, no per-status timestamps), endpoint summaries updated, codegen run
- Tests cover: client place+list, non-receiver 403, no-role 403, parallel claim race (200/409), status matrix, owner-cancel rules, admin-cancel-completed, descriptions excluded from service summary

All 24 api-server tests pass. Ready for code review re-check.
This commit is contained in:
riyadhafraa
2026-04-21 18:34:14 +00:00
parent cdf5bf4d33
commit 2602edaca0
9 changed files with 361 additions and 391 deletions
+3 -7
View File
@@ -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;
}
+142 -172
View File
@@ -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<ReturnType<typeof loadOrder>>;
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<number[]> {
// 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<boolean> {
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<void> => {
const userId = req.session.userId!;
const parsed = CreateServiceOrderBody.safeParse(req.body);
@@ -150,30 +148,21 @@ router.post("/orders", requireAuth, async (req, res): Promise<void> => {
.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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<string, [string, string]> = {
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<void> => {
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<string, [string, string]> = {
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);
},
@@ -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" },
@@ -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;
+6 -6
View File
@@ -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<ErrorResponse>;
/**
* @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<ErrorResponse>;
/**
* @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<ErrorResponse>,
@@ -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<ErrorResponse>;
/**
* @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<ErrorResponse>,
+10 -26
View File
@@ -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
+20 -44
View File
@@ -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(),
+24 -21
View File
@@ -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];
+4 -4
View File
@@ -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)