From fbab7ac5a64fa1fe801d448159d09c7a80f35d4c Mon Sep 17 00:00:00 2001 From: Riyadh Date: Tue, 21 Apr 2026 18:24:20 +0000 Subject: [PATCH] =?UTF-8?q?Service=20Orders=20=E2=80=94=20backend=20founda?= =?UTF-8?q?tion=20(Task=20#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- artifacts/api-server/src/middlewares/auth.ts | 68 ++- artifacts/api-server/src/routes/index.ts | 2 + .../api-server/src/routes/service-orders.ts | 396 +++++++++++++++++ .../api-server/tests/service-orders.test.mjs | 192 ++++++++ .../src/generated/api.schemas.ts | 81 ++++ lib/api-client-react/src/generated/api.ts | 412 ++++++++++++++++++ lib/api-spec/openapi.yaml | 232 ++++++++++ lib/api-zod/src/generated/api.ts | 252 +++++++++++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/service-orders.ts | 42 ++ scripts/src/seed.ts | 46 +- 11 files changed, 1713 insertions(+), 11 deletions(-) create mode 100644 artifacts/api-server/src/routes/service-orders.ts create mode 100644 artifacts/api-server/tests/service-orders.test.mjs create mode 100644 lib/db/src/schema/service-orders.ts diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index 9e72f1a3..54e0347d 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -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 { + 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 { + 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 { const roles = await db .select({ roleName: rolesTable.name }) diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 54e40480..f037c272 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -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); diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts new file mode 100644 index 00000000..6c371053 --- /dev/null +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -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>; + +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 { + // 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 => { + 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 => { + 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 => { + 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 => { + 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 = { + 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 => { + 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; diff --git a/artifacts/api-server/tests/service-orders.test.mjs b/artifacts/api-server/tests/service-orders.test.mjs new file mode 100644 index 00000000..fbadd2d4 --- /dev/null +++ b/artifacts/api-server/tests/service-orders.test.mjs @@ -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); +}); diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 28aa77dd..211546ee 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 4eb7a40e..51252437 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { + return customFetch(getCreateServiceOrderUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createServiceOrderBody), + }); +}; + +export const getCreateServiceOrderMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return createServiceOrder(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateServiceOrderMutationResult = NonNullable< + Awaited> +>; +export type CreateServiceOrderMutationBody = BodyType; +export type CreateServiceOrderMutationError = ErrorType; + +/** + * @summary Place a service order + */ +export const useCreateServiceOrder = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + 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 => { + return customFetch(getListMyServiceOrdersUrl(), { + ...options, + method: "GET", + }); +}; + +export const getListMyServiceOrdersQueryKey = () => { + return [`/api/orders/my`] as const; +}; + +export const getListMyServiceOrdersQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListMyServiceOrdersQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listMyServiceOrders({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListMyServiceOrdersQueryResult = NonNullable< + Awaited> +>; +export type ListMyServiceOrdersQueryError = ErrorType; + +/** + * @summary List orders placed by the current user + */ + +export function useListMyServiceOrders< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListMyServiceOrdersQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + 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 => { + return customFetch(getListIncomingServiceOrdersUrl(), { + ...options, + method: "GET", + }); +}; + +export const getListIncomingServiceOrdersQueryKey = () => { + return [`/api/orders/incoming`] as const; +}; + +export const getListIncomingServiceOrdersQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListIncomingServiceOrdersQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listIncomingServiceOrders({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListIncomingServiceOrdersQueryResult = NonNullable< + Awaited> +>; +export type ListIncomingServiceOrdersQueryError = ErrorType; + +/** + * @summary List active orders for receivers (requires orders:receive) + */ + +export function useListIncomingServiceOrders< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListIncomingServiceOrdersQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + 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 => { + return customFetch(getUpdateServiceOrderStatusUrl(id), { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateServiceOrderStatusBody), + }); +}; + +export const getUpdateServiceOrderStatusMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + 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>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return updateServiceOrderStatus(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateServiceOrderStatusMutationResult = NonNullable< + Awaited> +>; +export type UpdateServiceOrderStatusMutationBody = + BodyType; +export type UpdateServiceOrderStatusMutationError = ErrorType; + +/** + * @summary Receiver updates order status (claim/deliver/cancel) + */ +export const useUpdateServiceOrderStatus = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + 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 => { + return customFetch(getConfirmServiceOrderReceiptUrl(id), { + ...options, + method: "PATCH", + }); +}; + +export const getConfirmServiceOrderReceiptMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + 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>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return confirmServiceOrderReceipt(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ConfirmServiceOrderReceiptMutationResult = NonNullable< + Awaited> +>; + +export type ConfirmServiceOrderReceiptMutationError = ErrorType; + +/** + * @summary Requester confirms they received their order + */ +export const useConfirmServiceOrderReceipt = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getConfirmServiceOrderReceiptMutationOptions(options)); +}; + /** * @summary List service categories */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index c4c3871f..3c380a09 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -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: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 2b69b85b..87ee99f1 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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 */ diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 013e0980..8e8727f2 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -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"; diff --git a/lib/db/src/schema/service-orders.ts b/lib/db/src/schema/service-orders.ts new file mode 100644 index 00000000..a212ed20 --- /dev/null +++ b/lib/db/src/schema/service-orders.ts @@ -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; +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]; diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index ace64608..cfd703a4 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -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(); } }