2026-04-21 18:24:20 +00:00
|
|
|
import { Router, type IRouter } from "express";
|
2026-04-21 18:34:14 +00:00
|
|
|
import { eq, and, desc, sql, isNull, inArray, or } from "drizzle-orm";
|
2026-04-21 18:24:20 +00:00
|
|
|
import { db } from "@workspace/db";
|
|
|
|
|
import {
|
|
|
|
|
serviceOrdersTable,
|
|
|
|
|
servicesTable,
|
|
|
|
|
usersTable,
|
|
|
|
|
notificationsTable,
|
|
|
|
|
userRolesTable,
|
|
|
|
|
rolesTable,
|
|
|
|
|
rolePermissionsTable,
|
|
|
|
|
permissionsTable,
|
|
|
|
|
} from "@workspace/db";
|
2026-04-21 18:34:14 +00:00
|
|
|
import { requireAuth, requirePermission } from "../middlewares/auth";
|
2026-04-21 18:24:20 +00:00
|
|
|
import {
|
|
|
|
|
CreateServiceOrderBody,
|
|
|
|
|
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
|
|
|
|
|
UpdateServiceOrderStatusBody,
|
|
|
|
|
} from "@workspace/api-zod";
|
|
|
|
|
|
|
|
|
|
const router: IRouter = Router();
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
const PERM_RECEIVE = "orders.receive";
|
|
|
|
|
const ACTIVE_STATUSES = ["pending", "received", "preparing"] as const;
|
2026-04-21 18:24:20 +00:00
|
|
|
|
|
|
|
|
async function loadOrder(id: number) {
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: serviceOrdersTable.id,
|
|
|
|
|
serviceId: serviceOrdersTable.serviceId,
|
2026-04-21 18:34:14 +00:00
|
|
|
userId: serviceOrdersTable.userId,
|
2026-04-21 18:24:20 +00:00
|
|
|
assignedTo: serviceOrdersTable.assignedTo,
|
|
|
|
|
notes: serviceOrdersTable.notes,
|
|
|
|
|
status: serviceOrdersTable.status,
|
|
|
|
|
createdAt: serviceOrdersTable.createdAt,
|
2026-04-21 18:34:14 +00:00
|
|
|
updatedAt: serviceOrdersTable.updatedAt,
|
2026-04-21 18:24:20 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
const userIds = [row.userId, ...(row.assignedTo ? [row.assignedTo] : [])];
|
2026-04-21 18:24:20 +00:00
|
|
|
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,
|
2026-04-21 18:34:14 +00:00
|
|
|
requester: userById.get(row.userId) ?? null,
|
2026-04-21 18:24:20 +00:00
|
|
|
assignee: row.assignedTo ? (userById.get(row.assignedTo) ?? null) : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getReceiverUserIds(): Promise<number[]> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.selectDistinct({ userId: userRolesTable.userId })
|
|
|
|
|
.from(userRolesTable)
|
2026-04-21 18:34:14 +00:00
|
|
|
.innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
|
|
|
|
|
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
|
|
|
|
.where(eq(permissionsTable.name, PERM_RECEIVE));
|
2026-04-21 18:24:20 +00:00
|
|
|
return rows.map((r) => r.userId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
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) {
|
2026-04-21 18:24:20 +00:00
|
|
|
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,
|
2026-04-21 18:34:14 +00:00
|
|
|
orderId: number,
|
2026-04-21 18:24:20 +00:00
|
|
|
) {
|
|
|
|
|
const [notification] = await db
|
|
|
|
|
.insert(notificationsTable)
|
|
|
|
|
.values({
|
|
|
|
|
userId,
|
|
|
|
|
titleAr,
|
|
|
|
|
titleEn,
|
|
|
|
|
bodyAr,
|
|
|
|
|
bodyEn,
|
2026-04-21 18:34:14 +00:00
|
|
|
type: "order",
|
|
|
|
|
relatedId: orderId,
|
|
|
|
|
relatedType: "order",
|
2026-04-21 18:24:20 +00:00
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
await emitToUser(userId, "notification_created", notification);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function broadcastIncomingChanged(receiverIds: number[]) {
|
|
|
|
|
for (const uid of receiverIds) {
|
2026-04-21 18:34:14 +00:00
|
|
|
await emitToUser(uid, "order_incoming_changed");
|
2026-04-21 18:24:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
// POST /api/orders — place a new order
|
2026-04-21 18:24:20 +00:00
|
|
|
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,
|
2026-04-21 18:34:14 +00:00
|
|
|
userId,
|
2026-04-21 18:24:20 +00:00
|
|
|
notes: parsed.data.notes ?? null,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
const enriched = await loadOrder(order.id);
|
|
|
|
|
const receivers = await getReceiverUserIds();
|
|
|
|
|
for (const rid of receivers) {
|
|
|
|
|
await notifyUser(
|
|
|
|
|
rid,
|
2026-04-21 18:34:14 +00:00
|
|
|
"طلب جديد",
|
|
|
|
|
"New order",
|
|
|
|
|
service.nameAr,
|
|
|
|
|
service.nameEn,
|
2026-04-21 18:24:20 +00:00
|
|
|
order.id,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await broadcastIncomingChanged(receivers);
|
|
|
|
|
|
|
|
|
|
res.status(201).json(enriched);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
// GET /api/orders/my
|
2026-04-21 18:24:20 +00:00
|
|
|
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)
|
2026-04-21 18:34:14 +00:00
|
|
|
.where(eq(serviceOrdersTable.userId, userId))
|
2026-04-21 18:24:20 +00:00
|
|
|
.orderBy(desc(serviceOrdersTable.createdAt))
|
|
|
|
|
.limit(100);
|
|
|
|
|
const enriched = await Promise.all(rows.map((r) => loadOrder(r.id)));
|
|
|
|
|
res.json(enriched.filter(Boolean));
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
// GET /api/orders/incoming — receivers only
|
2026-04-21 18:24:20 +00:00
|
|
|
router.get(
|
|
|
|
|
"/orders/incoming",
|
|
|
|
|
requireAuth,
|
2026-04-21 18:34:14 +00:00
|
|
|
requirePermission(PERM_RECEIVE),
|
2026-04-21 18:24:20 +00:00
|
|
|
async (req, res): Promise<void> => {
|
|
|
|
|
const userId = req.session.userId!;
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({ id: serviceOrdersTable.id })
|
|
|
|
|
.from(serviceOrdersTable)
|
|
|
|
|
.where(
|
2026-04-21 18:34:14 +00:00
|
|
|
or(
|
|
|
|
|
and(
|
|
|
|
|
eq(serviceOrdersTable.status, "pending"),
|
|
|
|
|
isNull(serviceOrdersTable.assignedTo),
|
|
|
|
|
),
|
|
|
|
|
and(
|
|
|
|
|
eq(serviceOrdersTable.assignedTo, userId),
|
|
|
|
|
inArray(serviceOrdersTable.status, ACTIVE_STATUSES as unknown as string[]),
|
|
|
|
|
),
|
2026-04-21 18:24:20 +00:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(desc(serviceOrdersTable.createdAt))
|
|
|
|
|
.limit(200);
|
|
|
|
|
const enriched = await Promise.all(rows.map((r) => loadOrder(r.id)));
|
|
|
|
|
res.json(enriched.filter(Boolean));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
// PATCH /api/orders/:id/confirm-receipt — receiver atomic claim
|
2026-04-21 18:24:20 +00:00
|
|
|
router.patch(
|
2026-04-21 18:34:14 +00:00
|
|
|
"/orders/:id/confirm-receipt",
|
2026-04-21 18:24:20 +00:00
|
|
|
requireAuth,
|
2026-04-21 18:34:14 +00:00
|
|
|
requirePermission(PERM_RECEIVE),
|
2026-04-21 18:24:20 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
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();
|
2026-04-21 18:24:20 +00:00
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
if (!updated) {
|
|
|
|
|
res.status(409).json({ error: "already_claimed" });
|
2026-04-21 18:24:20 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const enriched = await loadOrder(orderId);
|
2026-04-21 18:34:14 +00:00
|
|
|
if (enriched) {
|
2026-04-21 18:24:20 +00:00
|
|
|
await notifyUser(
|
2026-04-21 18:34:14 +00:00
|
|
|
enriched.userId,
|
|
|
|
|
"تم استلام طلبك",
|
|
|
|
|
"Your order was received",
|
2026-04-21 18:24:20 +00:00
|
|
|
enriched.service.nameAr,
|
|
|
|
|
enriched.service.nameEn,
|
|
|
|
|
orderId,
|
|
|
|
|
);
|
2026-04-21 18:34:14 +00:00
|
|
|
await emitToUser(enriched.userId, "order_updated", enriched);
|
2026-04-21 18:24:20 +00:00
|
|
|
}
|
|
|
|
|
const receivers = await getReceiverUserIds();
|
|
|
|
|
await broadcastIncomingChanged(receivers);
|
|
|
|
|
|
|
|
|
|
res.json(enriched);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
// PATCH /api/orders/:id/status
|
2026-04-21 18:24:20 +00:00
|
|
|
router.patch(
|
2026-04-21 18:34:14 +00:00
|
|
|
"/orders/:id/status",
|
2026-04-21 18:24:20 +00:00
|
|
|
requireAuth,
|
|
|
|
|
async (req, res): Promise<void> => {
|
|
|
|
|
const params = ServiceOrderIdParams.safeParse(req.params);
|
|
|
|
|
if (!params.success) {
|
|
|
|
|
res.status(400).json({ error: params.error.message });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-21 18:34:14 +00:00
|
|
|
const parsed = UpdateServiceOrderStatusBody.safeParse(req.body);
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
res.status(400).json({ error: parsed.error.message });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-21 18:24:20 +00:00
|
|
|
const userId = req.session.userId!;
|
|
|
|
|
const orderId = params.data.id;
|
2026-04-21 18:34:14 +00:00
|
|
|
const next = parsed.data.status;
|
2026-04-21 18:24:20 +00:00
|
|
|
|
|
|
|
|
const [existing] = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(serviceOrdersTable)
|
|
|
|
|
.where(eq(serviceOrdersTable.id, orderId));
|
|
|
|
|
if (!existing) {
|
|
|
|
|
res.status(404).json({ error: "Order not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-21 18:34:14 +00:00
|
|
|
|
|
|
|
|
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;
|
2026-04-21 18:58:19 +00:00
|
|
|
const isAssignee = existing.assignedTo === userId;
|
2026-04-21 18:34:14 +00:00
|
|
|
const cancellableByOwner =
|
|
|
|
|
existing.status === "pending" || existing.status === "received";
|
2026-04-21 18:58:19 +00:00
|
|
|
const cancellableByAssignee =
|
|
|
|
|
existing.status === "received" || existing.status === "preparing";
|
|
|
|
|
if (
|
|
|
|
|
!admin &&
|
|
|
|
|
!(isOwner && cancellableByOwner) &&
|
|
|
|
|
!(isAssignee && cancellableByAssignee)
|
|
|
|
|
) {
|
2026-04-21 18:34:14 +00:00
|
|
|
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" });
|
2026-04-21 18:24:20 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.update(serviceOrdersTable)
|
2026-04-21 18:34:14 +00:00
|
|
|
.set({ status: next, updatedAt: new Date() })
|
2026-04-21 18:24:20 +00:00
|
|
|
.where(eq(serviceOrdersTable.id, orderId));
|
|
|
|
|
|
|
|
|
|
const enriched = await loadOrder(orderId);
|
|
|
|
|
|
2026-04-21 18:34:14 +00:00
|
|
|
// 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];
|
2026-04-22 06:28:29 +00:00
|
|
|
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`;
|
|
|
|
|
}
|
2026-04-21 18:24:20 +00:00
|
|
|
await notifyUser(
|
2026-04-21 18:34:14 +00:00
|
|
|
enriched.userId,
|
|
|
|
|
titleAr,
|
|
|
|
|
titleEn,
|
2026-04-22 06:28:29 +00:00
|
|
|
bodyAr,
|
|
|
|
|
bodyEn,
|
2026-04-21 18:24:20 +00:00
|
|
|
orderId,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-21 18:34:14 +00:00
|
|
|
if (enriched) await emitToUser(enriched.userId, "order_updated", enriched);
|
|
|
|
|
|
2026-04-21 18:24:20 +00:00
|
|
|
const receivers = await getReceiverUserIds();
|
|
|
|
|
await broadcastIncomingChanged(receivers);
|
2026-04-21 18:34:14 +00:00
|
|
|
if (existing.assignedTo) {
|
|
|
|
|
await emitToUser(existing.assignedTo, "order_incoming_changed");
|
|
|
|
|
}
|
2026-04-21 18:24:20 +00:00
|
|
|
|
|
|
|
|
res.json(enriched);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export default router;
|