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:
@@ -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;
|
||||
Reference in New Issue
Block a user