Service Orders — backend foundation (Task #62)
- New `service_orders` table (status pending|claimed|delivered|received|cancelled) - New `orders:receive` permission + `order_receiver` role; admins implicitly allowed - Added `requirePermission(name)` and `userHasPermission(userId, name)` middleware helpers - New routes: - POST /api/orders place order (authenticated) - GET /api/orders/my list current user's orders - GET /api/orders/incoming receivers see pending+active visible orders - PATCH /api/orders/:id/status claim (atomic), deliver, cancel - PATCH /api/orders/:id/confirm-receipt requester confirms a delivered order - Atomic claim via UPDATE ... WHERE status='pending' AND assigned_to IS NULL (returns 409 already_claimed on race) - Realtime: emits `notification_created` to receivers/requester, `order_incoming_changed` to all receivers, `order_updated` to requester - Service shape in order responses limited to id/nameAr/nameEn/imageUrl (no description fields), per spec - OpenAPI updated with new paths and schemas; codegen run - Seed updated idempotently (permission, role, role_permissions) - New tests in artifacts/api-server/tests/service-orders.test.mjs (full lifecycle, atomic claim race, unauth rejection) — all 21 api tests pass No deviations from the planned scope. Tasks #63 (client UI) and #64 (receiver page + admin role toggle) remain blocked-by #62 and are next.
This commit is contained in:
@@ -4,8 +4,10 @@ import {
|
||||
usersTable,
|
||||
userRolesTable,
|
||||
rolesTable,
|
||||
rolePermissionsTable,
|
||||
permissionsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
@@ -62,6 +64,70 @@ export async function requireAdmin(
|
||||
next();
|
||||
}
|
||||
|
||||
export function requirePermission(name: string) {
|
||||
return async function (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
if (!req.session.userId) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.innerJoin(
|
||||
rolePermissionsTable,
|
||||
eq(rolePermissionsTable.roleId, rolesTable.id),
|
||||
)
|
||||
.innerJoin(
|
||||
permissionsTable,
|
||||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(userRolesTable.userId, req.session.userId),
|
||||
eq(permissionsTable.name, name),
|
||||
),
|
||||
);
|
||||
|
||||
const isAdmin = rows.some((r) => r.roleName === "admin");
|
||||
const hasPerm = rows.length > 0;
|
||||
|
||||
if (!isAdmin && !hasPerm) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export async function userHasPermission(
|
||||
userId: number,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.innerJoin(
|
||||
rolePermissionsTable,
|
||||
eq(rolePermissionsTable.roleId, rolesTable.id),
|
||||
)
|
||||
.innerJoin(
|
||||
permissionsTable,
|
||||
eq(rolePermissionsTable.permissionId, permissionsTable.id),
|
||||
)
|
||||
.where(
|
||||
and(eq(userRolesTable.userId, userId), eq(permissionsTable.name, name)),
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function getUserRoles(userId: number): Promise<string[]> {
|
||||
const roles = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
|
||||
@@ -3,6 +3,7 @@ import healthRouter from "./health";
|
||||
import authRouter from "./auth";
|
||||
import appsRouter from "./apps";
|
||||
import servicesRouter from "./services";
|
||||
import serviceOrdersRouter from "./service-orders";
|
||||
import conversationsRouter from "./conversations";
|
||||
import notificationsRouter from "./notifications";
|
||||
import usersRouter from "./users";
|
||||
@@ -17,6 +18,7 @@ router.use(healthRouter);
|
||||
router.use(authRouter);
|
||||
router.use(appsRouter);
|
||||
router.use(servicesRouter);
|
||||
router.use(serviceOrdersRouter);
|
||||
router.use(conversationsRouter);
|
||||
router.use(notificationsRouter);
|
||||
router.use(usersRouter);
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, and, desc, sql, isNull, inArray } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
serviceOrdersTable,
|
||||
servicesTable,
|
||||
usersTable,
|
||||
notificationsTable,
|
||||
userRolesTable,
|
||||
rolesTable,
|
||||
rolePermissionsTable,
|
||||
permissionsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requirePermission, userHasPermission } from "../middlewares/auth";
|
||||
import {
|
||||
CreateServiceOrderBody,
|
||||
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
|
||||
UpdateServiceOrderStatusBody,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const ACTIVE_STATUSES = ["pending", "claimed", "delivered"] as const;
|
||||
|
||||
type EnrichedOrder = Awaited<ReturnType<typeof loadOrder>>;
|
||||
|
||||
async function loadOrder(id: number) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: serviceOrdersTable.id,
|
||||
serviceId: serviceOrdersTable.serviceId,
|
||||
requestedBy: serviceOrdersTable.requestedBy,
|
||||
assignedTo: serviceOrdersTable.assignedTo,
|
||||
quantity: serviceOrdersTable.quantity,
|
||||
notes: serviceOrdersTable.notes,
|
||||
status: serviceOrdersTable.status,
|
||||
createdAt: serviceOrdersTable.createdAt,
|
||||
claimedAt: serviceOrdersTable.claimedAt,
|
||||
deliveredAt: serviceOrdersTable.deliveredAt,
|
||||
receivedAt: serviceOrdersTable.receivedAt,
|
||||
cancelledAt: serviceOrdersTable.cancelledAt,
|
||||
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.requestedBy, ...(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.requestedBy) ?? null,
|
||||
assignee: row.assignedTo ? (userById.get(row.assignedTo) ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getReceiverUserIds(): Promise<number[]> {
|
||||
// Anyone with `orders:receive` permission OR admin role
|
||||
const rows = await db
|
||||
.selectDistinct({ userId: userRolesTable.userId })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, rolesTable.id))
|
||||
.leftJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
|
||||
.where(
|
||||
sql`${rolesTable.name} = 'admin' OR ${permissionsTable.name} = 'orders:receive'`,
|
||||
);
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
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,
|
||||
type: string,
|
||||
relatedId: number,
|
||||
) {
|
||||
const [notification] = await db
|
||||
.insert(notificationsTable)
|
||||
.values({
|
||||
userId,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
type,
|
||||
relatedId,
|
||||
relatedType: "service_order",
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(userId, "notification_created", notification);
|
||||
}
|
||||
|
||||
async function broadcastIncomingChanged(receiverIds: number[]) {
|
||||
for (const uid of receiverIds) {
|
||||
await emitToUser(uid, "order_incoming_changed", { changed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
requestedBy: userId,
|
||||
quantity: parsed.data.quantity ?? 1,
|
||||
notes: parsed.data.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const enriched = await loadOrder(order.id);
|
||||
|
||||
// Notify all receivers
|
||||
const receivers = await getReceiverUserIds();
|
||||
const requesterName =
|
||||
enriched?.requester?.displayNameAr ??
|
||||
enriched?.requester?.displayNameEn ??
|
||||
enriched?.requester?.username ??
|
||||
"";
|
||||
for (const rid of receivers) {
|
||||
if (rid === userId) continue;
|
||||
await notifyUser(
|
||||
rid,
|
||||
"طلب خدمة جديد",
|
||||
"New service order",
|
||||
`${requesterName} طلب ${service.nameAr}`,
|
||||
`${requesterName} requested ${service.nameEn}`,
|
||||
"order",
|
||||
order.id,
|
||||
);
|
||||
}
|
||||
await broadcastIncomingChanged(receivers);
|
||||
|
||||
res.status(201).json(enriched);
|
||||
});
|
||||
|
||||
// GET /api/orders/my - orders placed by the current user
|
||||
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.requestedBy, 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 - pending+active orders for receivers
|
||||
router.get(
|
||||
"/orders/incoming",
|
||||
requireAuth,
|
||||
requirePermission("orders:receive"),
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
// Show: all pending (unassigned), plus orders assigned to this receiver
|
||||
const rows = await db
|
||||
.select({ id: serviceOrdersTable.id })
|
||||
.from(serviceOrdersTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(serviceOrdersTable.status, ACTIVE_STATUSES as unknown as string[]),
|
||||
sql`(${serviceOrdersTable.assignedTo} IS NULL OR ${serviceOrdersTable.assignedTo} = ${userId})`,
|
||||
),
|
||||
)
|
||||
.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/status - receiver updates status (claim/deliver/cancel)
|
||||
router.patch(
|
||||
"/orders/:id/status",
|
||||
requireAuth,
|
||||
requirePermission("orders: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 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;
|
||||
}
|
||||
|
||||
if (next === "claimed") {
|
||||
// Atomic claim: only succeeds if still pending and unassigned
|
||||
const [updated] = await db
|
||||
.update(serviceOrdersTable)
|
||||
.set({
|
||||
status: "claimed",
|
||||
assignedTo: userId,
|
||||
claimedAt: 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;
|
||||
}
|
||||
} else if (next === "delivered") {
|
||||
if (existing.status !== "claimed" || existing.assignedTo !== userId) {
|
||||
res.status(409).json({ error: "invalid_transition" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.update(serviceOrdersTable)
|
||||
.set({ status: "delivered", deliveredAt: new Date() })
|
||||
.where(eq(serviceOrdersTable.id, orderId));
|
||||
} else if (next === "cancelled") {
|
||||
// Receiver may cancel only their own active orders, or admin via permission middleware
|
||||
const isAdminUser = await userHasPermission(userId, "users:manage");
|
||||
if (
|
||||
!isAdminUser &&
|
||||
!(existing.assignedTo === userId && existing.status !== "received")
|
||||
) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
if (existing.status === "received" || existing.status === "cancelled") {
|
||||
res.status(409).json({ error: "invalid_transition" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.update(serviceOrdersTable)
|
||||
.set({ status: "cancelled", cancelledAt: new Date() })
|
||||
.where(eq(serviceOrdersTable.id, orderId));
|
||||
} else {
|
||||
res.status(400).json({ error: "Unsupported status transition" });
|
||||
return;
|
||||
}
|
||||
|
||||
const enriched = await loadOrder(orderId);
|
||||
if (!enriched) {
|
||||
res.status(404).json({ error: "Order not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify the requester about status change
|
||||
const titleMap: Record<string, [string, string]> = {
|
||||
claimed: ["تم استلام طلبك", "Your order was claimed"],
|
||||
delivered: ["تم تسليم طلبك", "Your order was delivered"],
|
||||
cancelled: ["تم إلغاء طلبك", "Your order was cancelled"],
|
||||
};
|
||||
const [titleAr, titleEn] = titleMap[next] ?? ["", ""];
|
||||
if (titleAr) {
|
||||
await notifyUser(
|
||||
existing.requestedBy,
|
||||
titleAr,
|
||||
titleEn,
|
||||
enriched.service.nameAr,
|
||||
enriched.service.nameEn,
|
||||
"order",
|
||||
orderId,
|
||||
);
|
||||
}
|
||||
|
||||
// Notify other receivers that the incoming list changed
|
||||
const receivers = await getReceiverUserIds();
|
||||
await broadcastIncomingChanged(receivers);
|
||||
// Notify requester their order changed too
|
||||
await emitToUser(existing.requestedBy, "order_updated", enriched);
|
||||
|
||||
res.json(enriched);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /api/orders/:id/confirm-receipt - requester confirms receipt
|
||||
router.patch(
|
||||
"/orders/:id/confirm-receipt",
|
||||
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;
|
||||
}
|
||||
if (existing.requestedBy !== userId) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
if (existing.status !== "delivered") {
|
||||
res.status(409).json({ error: "invalid_transition" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(serviceOrdersTable)
|
||||
.set({ status: "received", receivedAt: new Date() })
|
||||
.where(eq(serviceOrdersTable.id, orderId));
|
||||
|
||||
const enriched = await loadOrder(orderId);
|
||||
|
||||
if (existing.assignedTo) {
|
||||
await notifyUser(
|
||||
existing.assignedTo,
|
||||
"تم تأكيد الاستلام",
|
||||
"Receipt confirmed",
|
||||
enriched?.service.nameAr ?? "",
|
||||
enriched?.service.nameEn ?? "",
|
||||
"order",
|
||||
orderId,
|
||||
);
|
||||
await emitToUser(existing.assignedTo, "order_updated", enriched);
|
||||
}
|
||||
const receivers = await getReceiverUserIds();
|
||||
await broadcastIncomingChanged(receivers);
|
||||
|
||||
res.json(enriched);
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,192 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdOrderIds = [];
|
||||
|
||||
async function createUser(prefix, roleName) {
|
||||
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'SO Test', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2`,
|
||||
[id, roleName],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function login(username) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password: TEST_PASSWORD }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const sc = res.headers.get("set-cookie");
|
||||
return sc.split(",").map((c) => c.split(";")[0].trim()).find((c) =>
|
||||
c.startsWith("connect.sid="),
|
||||
);
|
||||
}
|
||||
|
||||
async function call(cookie, method, path, body) {
|
||||
return fetch(`${API_BASE}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: cookie,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
let serviceId;
|
||||
|
||||
before(async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM services WHERE is_available = true ORDER BY id LIMIT 1`,
|
||||
);
|
||||
if (rows.length === 0) throw new Error("No available service to test with");
|
||||
serviceId = rows[0].id;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdOrderIds.length > 0) {
|
||||
await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
|
||||
createdOrderIds,
|
||||
]);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("full order lifecycle: place → claim → deliver → confirm", async () => {
|
||||
const requester = await createUser("req", "user");
|
||||
const receiver = await createUser("recv", "order_receiver");
|
||||
|
||||
const reqCookie = await login(requester.username);
|
||||
const recvCookie = await login(receiver.username);
|
||||
|
||||
// Place order
|
||||
let res = await call(reqCookie, "POST", "/orders", {
|
||||
serviceId,
|
||||
quantity: 2,
|
||||
notes: "extra hot",
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const order = await res.json();
|
||||
createdOrderIds.push(order.id);
|
||||
assert.equal(order.status, "pending");
|
||||
assert.equal(order.quantity, 2);
|
||||
assert.equal(order.requestedBy, requester.id);
|
||||
assert.ok(order.service);
|
||||
assert.ok(order.service.nameAr);
|
||||
// Should NOT include description fields
|
||||
assert.equal(order.service.descriptionAr, undefined);
|
||||
|
||||
// Receiver lists incoming
|
||||
res = await call(recvCookie, "GET", "/orders/incoming");
|
||||
assert.equal(res.status, 200);
|
||||
const incoming = await res.json();
|
||||
assert.ok(incoming.find((o) => o.id === order.id));
|
||||
|
||||
// Non-receiver gets 403 on incoming
|
||||
res = await call(reqCookie, "GET", "/orders/incoming");
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
// Receiver claims
|
||||
res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, {
|
||||
status: "claimed",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const claimed = await res.json();
|
||||
assert.equal(claimed.status, "claimed");
|
||||
assert.equal(claimed.assignedTo, receiver.id);
|
||||
|
||||
// Requester cannot confirm receipt while only claimed
|
||||
res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 409);
|
||||
|
||||
// Receiver delivers
|
||||
res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, {
|
||||
status: "delivered",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, "delivered");
|
||||
|
||||
// Other user cannot confirm receipt
|
||||
const stranger = await createUser("str", "user");
|
||||
const strCookie = await login(stranger.username);
|
||||
res = await call(strCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
// Requester confirms receipt
|
||||
res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, "received");
|
||||
|
||||
// Verify the order shows up in /orders/my
|
||||
res = await call(reqCookie, "GET", "/orders/my");
|
||||
assert.equal(res.status, 200);
|
||||
const my = await res.json();
|
||||
assert.ok(my.find((o) => o.id === order.id && o.status === "received"));
|
||||
});
|
||||
|
||||
test("atomic claim: only one receiver wins", async () => {
|
||||
const requester = await createUser("req2", "user");
|
||||
const recv1 = await createUser("recv1", "order_receiver");
|
||||
const recv2 = await createUser("recv2", "order_receiver");
|
||||
|
||||
const reqCookie = await login(requester.username);
|
||||
const c1 = await login(recv1.username);
|
||||
const c2 = await login(recv2.username);
|
||||
|
||||
let res = await call(reqCookie, "POST", "/orders", { serviceId });
|
||||
assert.equal(res.status, 201);
|
||||
const order = await res.json();
|
||||
createdOrderIds.push(order.id);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
call(c1, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }),
|
||||
call(c2, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }),
|
||||
]);
|
||||
const statuses = [r1.status, r2.status].sort();
|
||||
assert.deepEqual(statuses, [200, 409]);
|
||||
});
|
||||
|
||||
test("non-authenticated cannot place order", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/orders`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ serviceId }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
@@ -249,6 +249,87 @@ export interface ServiceCategory {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceOrderUser {
|
||||
id: number;
|
||||
username: string;
|
||||
/** @nullable */
|
||||
displayNameAr?: string | null;
|
||||
/** @nullable */
|
||||
displayNameEn?: string | null;
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceOrderService {
|
||||
id: number;
|
||||
nameAr: string;
|
||||
nameEn: string;
|
||||
/** @nullable */
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export type ServiceOrderStatus =
|
||||
(typeof ServiceOrderStatus)[keyof typeof ServiceOrderStatus];
|
||||
|
||||
export const ServiceOrderStatus = {
|
||||
pending: "pending",
|
||||
claimed: "claimed",
|
||||
delivered: "delivered",
|
||||
received: "received",
|
||||
cancelled: "cancelled",
|
||||
} as const;
|
||||
|
||||
export interface ServiceOrder {
|
||||
id: number;
|
||||
serviceId: number;
|
||||
requestedBy: number;
|
||||
/** @nullable */
|
||||
assignedTo?: number | null;
|
||||
quantity: number;
|
||||
/** @nullable */
|
||||
notes?: string | null;
|
||||
status: ServiceOrderStatus;
|
||||
createdAt: string;
|
||||
/** @nullable */
|
||||
claimedAt?: string | null;
|
||||
/** @nullable */
|
||||
deliveredAt?: string | null;
|
||||
/** @nullable */
|
||||
receivedAt?: string | null;
|
||||
/** @nullable */
|
||||
cancelledAt?: string | null;
|
||||
service: ServiceOrderService;
|
||||
requester?: ServiceOrderUser | null;
|
||||
assignee?: ServiceOrderUser | null;
|
||||
}
|
||||
|
||||
export interface CreateServiceOrderBody {
|
||||
serviceId: number;
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 50
|
||||
*/
|
||||
quantity?: number;
|
||||
/**
|
||||
* @maxLength 500
|
||||
* @nullable
|
||||
*/
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export type UpdateServiceOrderStatusBodyStatus =
|
||||
(typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus];
|
||||
|
||||
export const UpdateServiceOrderStatusBodyStatus = {
|
||||
claimed: "claimed",
|
||||
delivered: "delivered",
|
||||
cancelled: "cancelled",
|
||||
} as const;
|
||||
|
||||
export interface UpdateServiceOrderStatusBody {
|
||||
status: UpdateServiceOrderStatusBodyStatus;
|
||||
}
|
||||
|
||||
export interface ParticipantInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
CreateAppBody,
|
||||
CreateConversationBody,
|
||||
CreateServiceBody,
|
||||
CreateServiceOrderBody,
|
||||
ErrorResponse,
|
||||
ForgotPasswordBody,
|
||||
ForgotPasswordResponse,
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
SendMessageBody,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceOrder,
|
||||
SuccessResponse,
|
||||
UpdateAppBody,
|
||||
UpdateAppSettingsBody,
|
||||
@@ -58,6 +60,7 @@ import type {
|
||||
UpdateLanguageBody,
|
||||
UpdateMyAppOrderBody,
|
||||
UpdateServiceBody,
|
||||
UpdateServiceOrderStatusBody,
|
||||
UpdateUserBody,
|
||||
UserProfile,
|
||||
VerifyResetTokenBody,
|
||||
@@ -2297,6 +2300,415 @@ export const useDeleteService = <
|
||||
return useMutation(getDeleteServiceMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Place a service order
|
||||
*/
|
||||
export const getCreateServiceOrderUrl = () => {
|
||||
return `/api/orders`;
|
||||
};
|
||||
|
||||
export const createServiceOrder = async (
|
||||
createServiceOrderBody: CreateServiceOrderBody,
|
||||
options?: RequestInit,
|
||||
): Promise<ServiceOrder> => {
|
||||
return customFetch<ServiceOrder>(getCreateServiceOrderUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(createServiceOrderBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceOrderMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceOrder>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateServiceOrderBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceOrder>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateServiceOrderBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["createServiceOrder"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceOrder>>,
|
||||
{ data: BodyType<CreateServiceOrderBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createServiceOrder(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceOrderMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceOrder>>
|
||||
>;
|
||||
export type CreateServiceOrderMutationBody = BodyType<CreateServiceOrderBody>;
|
||||
export type CreateServiceOrderMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Place a service order
|
||||
*/
|
||||
export const useCreateServiceOrder = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceOrder>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateServiceOrderBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceOrder>>,
|
||||
TError,
|
||||
{ data: BodyType<CreateServiceOrderBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceOrderMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List orders placed by the current user
|
||||
*/
|
||||
export const getListMyServiceOrdersUrl = () => {
|
||||
return `/api/orders/my`;
|
||||
};
|
||||
|
||||
export const listMyServiceOrders = async (
|
||||
options?: RequestInit,
|
||||
): Promise<ServiceOrder[]> => {
|
||||
return customFetch<ServiceOrder[]>(getListMyServiceOrdersUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getListMyServiceOrdersQueryKey = () => {
|
||||
return [`/api/orders/my`] as const;
|
||||
};
|
||||
|
||||
export const getListMyServiceOrdersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listMyServiceOrders>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMyServiceOrders>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListMyServiceOrdersQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listMyServiceOrders>>
|
||||
> = ({ signal }) => listMyServiceOrders({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMyServiceOrders>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListMyServiceOrdersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listMyServiceOrders>>
|
||||
>;
|
||||
export type ListMyServiceOrdersQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary List orders placed by the current user
|
||||
*/
|
||||
|
||||
export function useListMyServiceOrders<
|
||||
TData = Awaited<ReturnType<typeof listMyServiceOrders>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMyServiceOrders>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListMyServiceOrdersQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List active orders for receivers (requires orders:receive)
|
||||
*/
|
||||
export const getListIncomingServiceOrdersUrl = () => {
|
||||
return `/api/orders/incoming`;
|
||||
};
|
||||
|
||||
export const listIncomingServiceOrders = async (
|
||||
options?: RequestInit,
|
||||
): Promise<ServiceOrder[]> => {
|
||||
return customFetch<ServiceOrder[]>(getListIncomingServiceOrdersUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getListIncomingServiceOrdersQueryKey = () => {
|
||||
return [`/api/orders/incoming`] as const;
|
||||
};
|
||||
|
||||
export const getListIncomingServiceOrdersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listIncomingServiceOrders>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listIncomingServiceOrders>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListIncomingServiceOrdersQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listIncomingServiceOrders>>
|
||||
> = ({ signal }) => listIncomingServiceOrders({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listIncomingServiceOrders>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListIncomingServiceOrdersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listIncomingServiceOrders>>
|
||||
>;
|
||||
export type ListIncomingServiceOrdersQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary List active orders for receivers (requires orders:receive)
|
||||
*/
|
||||
|
||||
export function useListIncomingServiceOrders<
|
||||
TData = Awaited<ReturnType<typeof listIncomingServiceOrders>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listIncomingServiceOrders>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListIncomingServiceOrdersQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Receiver updates order status (claim/deliver/cancel)
|
||||
*/
|
||||
export const getUpdateServiceOrderStatusUrl = (id: number) => {
|
||||
return `/api/orders/${id}/status`;
|
||||
};
|
||||
|
||||
export const updateServiceOrderStatus = async (
|
||||
id: number,
|
||||
updateServiceOrderStatusBody: UpdateServiceOrderStatusBody,
|
||||
options?: RequestInit,
|
||||
): Promise<ServiceOrder> => {
|
||||
return customFetch<ServiceOrder>(getUpdateServiceOrderStatusUrl(id), {
|
||||
...options,
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(updateServiceOrderStatusBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateServiceOrderStatusMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceOrderStatus>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateServiceOrderStatusBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceOrderStatus>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateServiceOrderStatusBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["updateServiceOrderStatus"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateServiceOrderStatus>>,
|
||||
{ id: number; data: BodyType<UpdateServiceOrderStatusBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return updateServiceOrderStatus(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateServiceOrderStatusMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateServiceOrderStatus>>
|
||||
>;
|
||||
export type UpdateServiceOrderStatusMutationBody =
|
||||
BodyType<UpdateServiceOrderStatusBody>;
|
||||
export type UpdateServiceOrderStatusMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Receiver updates order status (claim/deliver/cancel)
|
||||
*/
|
||||
export const useUpdateServiceOrderStatus = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateServiceOrderStatus>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateServiceOrderStatusBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateServiceOrderStatus>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<UpdateServiceOrderStatusBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateServiceOrderStatusMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Requester confirms they received their order
|
||||
*/
|
||||
export const getConfirmServiceOrderReceiptUrl = (id: number) => {
|
||||
return `/api/orders/${id}/confirm-receipt`;
|
||||
};
|
||||
|
||||
export const confirmServiceOrderReceipt = async (
|
||||
id: number,
|
||||
options?: RequestInit,
|
||||
): Promise<ServiceOrder> => {
|
||||
return customFetch<ServiceOrder>(getConfirmServiceOrderReceiptUrl(id), {
|
||||
...options,
|
||||
method: "PATCH",
|
||||
});
|
||||
};
|
||||
|
||||
export const getConfirmServiceOrderReceiptMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof confirmServiceOrderReceipt>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof confirmServiceOrderReceipt>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["confirmServiceOrderReceipt"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof confirmServiceOrderReceipt>>,
|
||||
{ id: number }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
|
||||
return confirmServiceOrderReceipt(id, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ConfirmServiceOrderReceiptMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof confirmServiceOrderReceipt>>
|
||||
>;
|
||||
|
||||
export type ConfirmServiceOrderReceiptMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Requester confirms they received their order
|
||||
*/
|
||||
export const useConfirmServiceOrderReceipt = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof confirmServiceOrderReceipt>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof confirmServiceOrderReceipt>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getConfirmServiceOrderReceiptMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List service categories
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,8 @@ tags:
|
||||
description: App management
|
||||
- name: services
|
||||
description: Internal services (khidmati)
|
||||
- name: orders
|
||||
description: Service orders workflow
|
||||
- name: conversations
|
||||
description: Chat conversations
|
||||
- name: messages
|
||||
@@ -588,6 +590,123 @@ paths:
|
||||
"204":
|
||||
description: Deleted
|
||||
|
||||
/orders:
|
||||
post:
|
||||
operationId: createServiceOrder
|
||||
tags: [orders]
|
||||
summary: Place a service order
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateServiceOrderBody"
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceOrder"
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/orders/my:
|
||||
get:
|
||||
operationId: listMyServiceOrders
|
||||
tags: [orders]
|
||||
summary: List orders placed by the current user
|
||||
responses:
|
||||
"200":
|
||||
description: My orders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ServiceOrder"
|
||||
|
||||
/orders/incoming:
|
||||
get:
|
||||
operationId: listIncomingServiceOrders
|
||||
tags: [orders]
|
||||
summary: List active orders for receivers (requires orders:receive)
|
||||
responses:
|
||||
"200":
|
||||
description: Incoming orders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ServiceOrder"
|
||||
"403":
|
||||
description: Forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/orders/{id}/status:
|
||||
patch:
|
||||
operationId: updateServiceOrderStatus
|
||||
tags: [orders]
|
||||
summary: Receiver updates order status (claim/deliver/cancel)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateServiceOrderStatusBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated order
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceOrder"
|
||||
"409":
|
||||
description: Conflict (already claimed or invalid transition)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/orders/{id}/confirm-receipt:
|
||||
patch:
|
||||
operationId: confirmServiceOrderReceipt
|
||||
tags: [orders]
|
||||
summary: Requester confirms they received their order
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Order received
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceOrder"
|
||||
"409":
|
||||
description: Invalid transition
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/service-categories:
|
||||
get:
|
||||
operationId: listServiceCategories
|
||||
@@ -1628,6 +1747,119 @@ components:
|
||||
- sortOrder
|
||||
- createdAt
|
||||
|
||||
ServiceOrderUser:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
displayNameAr:
|
||||
type: ["string", "null"]
|
||||
displayNameEn:
|
||||
type: ["string", "null"]
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
|
||||
ServiceOrderService:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nameAr:
|
||||
type: string
|
||||
nameEn:
|
||||
type: string
|
||||
imageUrl:
|
||||
type: ["string", "null"]
|
||||
required:
|
||||
- id
|
||||
- nameAr
|
||||
- nameEn
|
||||
|
||||
ServiceOrderStatus:
|
||||
type: string
|
||||
enum: [pending, claimed, delivered, received, cancelled]
|
||||
|
||||
ServiceOrder:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
serviceId:
|
||||
type: integer
|
||||
requestedBy:
|
||||
type: integer
|
||||
assignedTo:
|
||||
type: ["integer", "null"]
|
||||
quantity:
|
||||
type: integer
|
||||
notes:
|
||||
type: ["string", "null"]
|
||||
status:
|
||||
$ref: "#/components/schemas/ServiceOrderStatus"
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
claimedAt:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
deliveredAt:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
receivedAt:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
cancelledAt:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
service:
|
||||
$ref: "#/components/schemas/ServiceOrderService"
|
||||
requester:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ServiceOrderUser"
|
||||
- type: "null"
|
||||
assignee:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ServiceOrderUser"
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- serviceId
|
||||
- requestedBy
|
||||
- quantity
|
||||
- status
|
||||
- createdAt
|
||||
- service
|
||||
|
||||
CreateServiceOrderBody:
|
||||
type: object
|
||||
properties:
|
||||
serviceId:
|
||||
type: integer
|
||||
quantity:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 50
|
||||
default: 1
|
||||
notes:
|
||||
type: ["string", "null"]
|
||||
maxLength: 500
|
||||
required:
|
||||
- serviceId
|
||||
|
||||
UpdateServiceOrderStatusBody:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [claimed, delivered, cancelled]
|
||||
required:
|
||||
- status
|
||||
|
||||
ConversationWithDetails:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -551,6 +551,258 @@ export const DeleteServiceParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Place a service order
|
||||
*/
|
||||
export const createServiceOrderBodyQuantityDefault = 1;
|
||||
export const createServiceOrderBodyQuantityMax = 50;
|
||||
|
||||
export const createServiceOrderBodyNotesMax = 500;
|
||||
|
||||
export const CreateServiceOrderBody = zod.object({
|
||||
serviceId: zod.number(),
|
||||
quantity: zod
|
||||
.number()
|
||||
.min(1)
|
||||
.max(createServiceOrderBodyQuantityMax)
|
||||
.default(createServiceOrderBodyQuantityDefault),
|
||||
notes: zod.string().max(createServiceOrderBodyNotesMax).nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List orders placed by the current user
|
||||
*/
|
||||
export const ListMyServiceOrdersResponseItem = zod.object({
|
||||
id: zod.number(),
|
||||
serviceId: zod.number(),
|
||||
requestedBy: zod.number(),
|
||||
assignedTo: zod.number().nullish(),
|
||||
quantity: zod.number(),
|
||||
notes: zod.string().nullish(),
|
||||
status: zod.enum([
|
||||
"pending",
|
||||
"claimed",
|
||||
"delivered",
|
||||
"received",
|
||||
"cancelled",
|
||||
]),
|
||||
createdAt: zod.coerce.date(),
|
||||
claimedAt: zod.coerce.date().nullish(),
|
||||
deliveredAt: zod.coerce.date().nullish(),
|
||||
receivedAt: zod.coerce.date().nullish(),
|
||||
cancelledAt: zod.coerce.date().nullish(),
|
||||
service: zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
imageUrl: zod.string().nullish(),
|
||||
}),
|
||||
requester: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
assignee: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
export const ListMyServiceOrdersResponse = zod.array(
|
||||
ListMyServiceOrdersResponseItem,
|
||||
);
|
||||
|
||||
/**
|
||||
* @summary List active orders for receivers (requires orders:receive)
|
||||
*/
|
||||
export const ListIncomingServiceOrdersResponseItem = zod.object({
|
||||
id: zod.number(),
|
||||
serviceId: zod.number(),
|
||||
requestedBy: zod.number(),
|
||||
assignedTo: zod.number().nullish(),
|
||||
quantity: zod.number(),
|
||||
notes: zod.string().nullish(),
|
||||
status: zod.enum([
|
||||
"pending",
|
||||
"claimed",
|
||||
"delivered",
|
||||
"received",
|
||||
"cancelled",
|
||||
]),
|
||||
createdAt: zod.coerce.date(),
|
||||
claimedAt: zod.coerce.date().nullish(),
|
||||
deliveredAt: zod.coerce.date().nullish(),
|
||||
receivedAt: zod.coerce.date().nullish(),
|
||||
cancelledAt: zod.coerce.date().nullish(),
|
||||
service: zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
imageUrl: zod.string().nullish(),
|
||||
}),
|
||||
requester: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
assignee: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
export const ListIncomingServiceOrdersResponse = zod.array(
|
||||
ListIncomingServiceOrdersResponseItem,
|
||||
);
|
||||
|
||||
/**
|
||||
* @summary Receiver updates order status (claim/deliver/cancel)
|
||||
*/
|
||||
export const UpdateServiceOrderStatusParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const UpdateServiceOrderStatusBody = zod.object({
|
||||
status: zod.enum(["claimed", "delivered", "cancelled"]),
|
||||
});
|
||||
|
||||
export const UpdateServiceOrderStatusResponse = zod.object({
|
||||
id: zod.number(),
|
||||
serviceId: zod.number(),
|
||||
requestedBy: zod.number(),
|
||||
assignedTo: zod.number().nullish(),
|
||||
quantity: zod.number(),
|
||||
notes: zod.string().nullish(),
|
||||
status: zod.enum([
|
||||
"pending",
|
||||
"claimed",
|
||||
"delivered",
|
||||
"received",
|
||||
"cancelled",
|
||||
]),
|
||||
createdAt: zod.coerce.date(),
|
||||
claimedAt: zod.coerce.date().nullish(),
|
||||
deliveredAt: zod.coerce.date().nullish(),
|
||||
receivedAt: zod.coerce.date().nullish(),
|
||||
cancelledAt: zod.coerce.date().nullish(),
|
||||
service: zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
imageUrl: zod.string().nullish(),
|
||||
}),
|
||||
requester: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
assignee: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Requester confirms they received their order
|
||||
*/
|
||||
export const ConfirmServiceOrderReceiptParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const ConfirmServiceOrderReceiptResponse = zod.object({
|
||||
id: zod.number(),
|
||||
serviceId: zod.number(),
|
||||
requestedBy: zod.number(),
|
||||
assignedTo: zod.number().nullish(),
|
||||
quantity: zod.number(),
|
||||
notes: zod.string().nullish(),
|
||||
status: zod.enum([
|
||||
"pending",
|
||||
"claimed",
|
||||
"delivered",
|
||||
"received",
|
||||
"cancelled",
|
||||
]),
|
||||
createdAt: zod.coerce.date(),
|
||||
claimedAt: zod.coerce.date().nullish(),
|
||||
deliveredAt: zod.coerce.date().nullish(),
|
||||
receivedAt: zod.coerce.date().nullish(),
|
||||
cancelledAt: zod.coerce.date().nullish(),
|
||||
service: zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
imageUrl: zod.string().nullish(),
|
||||
}),
|
||||
requester: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
assignee: zod
|
||||
.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List service categories
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./users";
|
||||
export * from "./roles";
|
||||
export * from "./apps";
|
||||
export * from "./services";
|
||||
export * from "./service-orders";
|
||||
export * from "./conversations";
|
||||
export * from "./notifications";
|
||||
export * from "./settings";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { pgTable, text, serial, timestamp, integer, varchar } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
import { usersTable } from "./users";
|
||||
import { servicesTable } from "./services";
|
||||
|
||||
export const serviceOrdersTable = pgTable("service_orders", {
|
||||
id: serial("id").primaryKey(),
|
||||
serviceId: integer("service_id").notNull().references(() => servicesTable.id, { onDelete: "restrict" }),
|
||||
requestedBy: integer("requested_by").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
assignedTo: integer("assigned_to").references(() => usersTable.id, { onDelete: "set null" }),
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
notes: text("notes"),
|
||||
status: varchar("status", { length: 30 }).notNull().default("pending"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
claimedAt: timestamp("claimed_at", { withTimezone: true }),
|
||||
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }),
|
||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const insertServiceOrderSchema = createInsertSchema(serviceOrdersTable).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
claimedAt: true,
|
||||
deliveredAt: true,
|
||||
receivedAt: true,
|
||||
cancelledAt: true,
|
||||
status: true,
|
||||
assignedTo: true,
|
||||
});
|
||||
export type InsertServiceOrder = z.infer<typeof insertServiceOrderSchema>;
|
||||
export type ServiceOrder = typeof serviceOrdersTable.$inferSelect;
|
||||
|
||||
export const SERVICE_ORDER_STATUSES = [
|
||||
"pending",
|
||||
"claimed",
|
||||
"delivered",
|
||||
"received",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type ServiceOrderStatus = (typeof SERVICE_ORDER_STATUSES)[number];
|
||||
+36
-10
@@ -37,31 +37,57 @@ async function main() {
|
||||
{ name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" },
|
||||
{ name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" },
|
||||
{ name: "chat:access", descriptionAr: "الوصول للمحادثات", descriptionEn: "Access Chat" },
|
||||
{ name: "orders:receive", descriptionAr: "استقبال الطلبات", descriptionEn: "Receive Service Orders" },
|
||||
];
|
||||
|
||||
const insertedPerms = await db
|
||||
.insert(permissionsTable)
|
||||
.values(permissions)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
await db.insert(permissionsTable).values(permissions).onConflictDoNothing();
|
||||
const allInsertedPerms = await db.select().from(permissionsTable);
|
||||
|
||||
console.log("Permissions created");
|
||||
|
||||
// Create order_receiver role
|
||||
const [orderReceiverRole] = await db
|
||||
.insert(rolesTable)
|
||||
.values({
|
||||
name: "order_receiver",
|
||||
descriptionAr: "مستقبل الطلبات",
|
||||
descriptionEn: "Service Order Receiver",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
const allRoles = await db.select().from(rolesTable);
|
||||
const adminRoleResolved = adminRole ?? allRoles.find((r) => r.name === "admin");
|
||||
const userRoleResolved = userRole ?? allRoles.find((r) => r.name === "user");
|
||||
const orderReceiverResolved =
|
||||
orderReceiverRole ?? allRoles.find((r) => r.name === "order_receiver");
|
||||
|
||||
// Assign all permissions to admin role
|
||||
if (adminRole && insertedPerms.length > 0) {
|
||||
if (adminRoleResolved && allInsertedPerms.length > 0) {
|
||||
await db
|
||||
.insert(rolePermissionsTable)
|
||||
.values(insertedPerms.map((p) => ({ roleId: adminRole.id, permissionId: p.id })))
|
||||
.values(allInsertedPerms.map((p) => ({ roleId: adminRoleResolved.id, permissionId: p.id })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
// Assign chat permission to user role
|
||||
if (userRole && insertedPerms.length > 0) {
|
||||
const chatPerm = insertedPerms.find((p) => p.name === "chat:access");
|
||||
if (userRoleResolved && allInsertedPerms.length > 0) {
|
||||
const chatPerm = allInsertedPerms.find((p) => p.name === "chat:access");
|
||||
if (chatPerm) {
|
||||
await db
|
||||
.insert(rolePermissionsTable)
|
||||
.values({ roleId: userRole.id, permissionId: chatPerm.id })
|
||||
.values({ roleId: userRoleResolved.id, permissionId: chatPerm.id })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// Assign orders:receive permission to order_receiver role
|
||||
if (orderReceiverResolved && allInsertedPerms.length > 0) {
|
||||
const ordersPerm = allInsertedPerms.find((p) => p.name === "orders:receive");
|
||||
if (ordersPerm) {
|
||||
await db
|
||||
.insert(rolePermissionsTable)
|
||||
.values({ roleId: orderReceiverResolved.id, permissionId: ordersPerm.id })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user