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:
Riyadh
2026-04-21 18:24:20 +00:00
parent c118490643
commit fbab7ac5a6
11 changed files with 1713 additions and 11 deletions
+1
View File
@@ -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";
+42
View File
@@ -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];