Service Orders — backend foundation (Task #62)

- New `service_orders` table (status pending|claimed|delivered|received|cancelled)
- New `orders:receive` permission + `order_receiver` role; admins implicitly allowed
- Added `requirePermission(name)` and `userHasPermission(userId, name)` middleware helpers
- New routes:
  - POST   /api/orders                    place order (authenticated)
  - GET    /api/orders/my                 list current user's orders
  - GET    /api/orders/incoming           receivers see pending+active visible orders
  - PATCH  /api/orders/:id/status         claim (atomic), deliver, cancel
  - PATCH  /api/orders/:id/confirm-receipt requester confirms a delivered order
- Atomic claim via UPDATE ... WHERE status='pending' AND assigned_to IS NULL
  (returns 409 already_claimed on race)
- Realtime: emits `notification_created` to receivers/requester,
  `order_incoming_changed` to all receivers, `order_updated` to requester
- Service shape in order responses limited to id/nameAr/nameEn/imageUrl
  (no description fields), per spec
- OpenAPI updated with new paths and schemas; codegen run
- Seed updated idempotently (permission, role, role_permissions)
- New tests in artifacts/api-server/tests/service-orders.test.mjs
  (full lifecycle, atomic claim race, unauth rejection) — all 21 api tests pass

No deviations from the planned scope. Tasks #63 (client UI) and #64
(receiver page + admin role toggle) remain blocked-by #62 and are next.
This commit is contained in:
Riyadh
2026-04-21 18:24:20 +00:00
parent c118490643
commit fbab7ac5a6
11 changed files with 1713 additions and 11 deletions
@@ -0,0 +1,396 @@
import { Router, type IRouter } from "express";
import { eq, and, desc, sql, isNull, inArray } from "drizzle-orm";
import { db } from "@workspace/db";
import {
serviceOrdersTable,
servicesTable,
usersTable,
notificationsTable,
userRolesTable,
rolesTable,
rolePermissionsTable,
permissionsTable,
} from "@workspace/db";
import { requireAuth, requirePermission, userHasPermission } from "../middlewares/auth";
import {
CreateServiceOrderBody,
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
UpdateServiceOrderStatusBody,
} from "@workspace/api-zod";
const router: IRouter = Router();
const ACTIVE_STATUSES = ["pending", "claimed", "delivered"] as const;
type EnrichedOrder = Awaited<ReturnType<typeof loadOrder>>;
async function loadOrder(id: number) {
const [row] = await db
.select({
id: serviceOrdersTable.id,
serviceId: serviceOrdersTable.serviceId,
requestedBy: serviceOrdersTable.requestedBy,
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,
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.requestedBy, ...(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.requestedBy) ?? 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'`,
);
return rows.map((r) => r.userId);
}
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,
type: string,
relatedId: number,
) {
const [notification] = await db
.insert(notificationsTable)
.values({
userId,
titleAr,
titleEn,
bodyAr,
bodyEn,
type,
relatedId,
relatedType: "service_order",
})
.returning();
await emitToUser(userId, "notification_created", notification);
}
async function broadcastIncomingChanged(receiverIds: number[]) {
for (const uid of receiverIds) {
await emitToUser(uid, "order_incoming_changed", { changed: true });
}
}
// 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);
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,
requestedBy: userId,
quantity: parsed.data.quantity ?? 1,
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",
order.id,
);
}
await broadcastIncomingChanged(receivers);
res.status(201).json(enriched);
});
// GET /api/orders/my - orders placed by the current user
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))
.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
router.get(
"/orders/incoming",
requireAuth,
requirePermission("orders: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})`,
),
)
.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/status - receiver updates status (claim/deliver/cancel)
router.patch(
"/orders/:id/status",
requireAuth,
requirePermission("orders: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 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;
}
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")
) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (existing.status === "received" || existing.status === "cancelled") {
res.status(409).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" });
return;
}
await db
.update(serviceOrdersTable)
.set({ status: "received", receivedAt: new Date() })
.where(eq(serviceOrdersTable.id, orderId));
const enriched = await loadOrder(orderId);
if (existing.assignedTo) {
await notifyUser(
existing.assignedTo,
"تم تأكيد الاستلام",
"Receipt confirmed",
enriched?.service.nameAr ?? "",
enriched?.service.nameEn ?? "",
"order",
orderId,
);
await emitToUser(existing.assignedTo, "order_updated", enriched);
}
const receivers = await getReceiverUserIds();
await broadcastIncomingChanged(receivers);
res.json(enriched);
},
);
export default router;