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

442 lines
13 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 {
CreateServiceOrderBody,
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
UpdateServiceOrderStatusBody,
} from "@workspace/api-zod";
const router: IRouter = Router();
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,
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 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);
}
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) {
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);
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 (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 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;
}
// 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;
}
} 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]> = {
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);
},
);
2026-04-22 06:37:01 +00:00
// DELETE /api/orders/:id — owner can delete terminal orders; admin can delete any
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 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;
}
const admin = await isAdmin(userId);
const isOwner = existing.userId === userId;
const isTerminal =
existing.status === "completed" || existing.status === "cancelled";
if (!admin && !(isOwner && isTerminal)) {
res.status(403).json({ error: "Forbidden" });
return;
}
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);
}
res.status(204).end();
},
);
export default router;