3ceabb85bc
Refactor notification sound playback to use per-bucket throttling, adjust socket warmup, and ensure push notifications are sent even if the client is considered connected.
635 lines
21 KiB
TypeScript
635 lines
21 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);
|
|
// #628: pass `ignoreConnected: true` so a stale/ghost Socket.IO
|
|
// session on a forgotten laptop tab no longer suppresses the Web
|
|
// Push to the receiver's iPad. Orders are time-critical exactly
|
|
// like the 5-min meeting reminder — better to ring twice on the
|
|
// foreground device than to ring zero times on the device the user
|
|
// is actually carrying. The in-app chime path stays unchanged for
|
|
// truly-foregrounded sockets; this just removes the silent-skip
|
|
// when the "connected" tab is in fact idle/closed.
|
|
void sendPushToUser(
|
|
userId,
|
|
{
|
|
title: titleAr,
|
|
body: bodyAr,
|
|
titleAr,
|
|
titleEn,
|
|
bodyAr,
|
|
bodyEn,
|
|
type: "order",
|
|
relatedId: orderId,
|
|
tag: `order-${orderId}`,
|
|
url: "/my-orders",
|
|
},
|
|
{ ignoreConnected: true },
|
|
);
|
|
}
|
|
|
|
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;
|