Files
TX/artifacts/api-server/src/routes/service-orders.ts
T
riyadhafraa 55d19298e7 Add bilingual support for notifications and automatic HTTPS
Implement bilingual text for push notifications, allowing users to receive them in their preferred language. Automatically configure HTTPS with Let's Encrypt for custom domains to ensure persistent subscriptions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 94f06aee-a5cf-45df-979e-f940cce77214
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/yEFtMKN
Replit-Helium-Checkpoint-Created: true
2026-05-16 16:28:42 +00:00

623 lines
20 KiB
TypeScript

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.
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" });
}
return;
}
res.status(204).end();
},
);
// 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 });
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);
}
res.json({ deletedIds, failedIds });
},
);
export default router;