Files
TX/artifacts/api-server/src/routes/service-orders.ts
T

623 lines
20 KiB
TypeScript
Raw Normal View History

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