Task #62: Service Orders backend foundation (corrected)

After prior code review rejection, refactored to match spec exactly:
- Permission renamed orders:receive → orders.receive (dot form), seeded with order_receiver role
- service_orders table: user_id, service_id, notes, status (pending/received/preparing/completed/cancelled with CHECK), assigned_to, created_at, updated_at
- /confirm-receipt is now the receiver atomic claim (UPDATE WHERE pending+unassigned), 409 already_claimed on miss
- /status accepts preparing|completed|cancelled with permission matrix:
  * preparing/completed: assigned receiver or admin
  * cancelled: owner (pending|received) OR admin (any non-cancelled status)
- requirePermission middleware no longer auto-bypasses admin; admin gets the permission via explicit seed grant
- Notifications use type='order', relatedType='order'
- Realtime emits notification_created (per receiver) + order_incoming_changed (broadcast) + order_updated (owner)
- OpenAPI Order schema rewritten (no quantity, no per-status timestamps), endpoint summaries updated, codegen run
- Tests cover: client place+list, non-receiver 403, no-role 403, parallel claim race (200/409), status matrix, owner-cancel rules, admin-cancel-completed, descriptions excluded from service summary

All 24 api-server tests pass. Ready for code review re-check.
This commit is contained in:
riyadhafraa
2026-04-21 18:34:14 +00:00
parent cdf5bf4d33
commit 2602edaca0
9 changed files with 361 additions and 391 deletions
@@ -273,31 +273,23 @@ export type ServiceOrderStatus =
export const ServiceOrderStatus = {
pending: "pending",
claimed: "claimed",
delivered: "delivered",
received: "received",
preparing: "preparing",
completed: "completed",
cancelled: "cancelled",
} as const;
export interface ServiceOrder {
id: number;
serviceId: number;
requestedBy: number;
userId: 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;
updatedAt: string;
service: ServiceOrderService;
requester?: ServiceOrderUser | null;
assignee?: ServiceOrderUser | null;
@@ -305,11 +297,6 @@ export interface ServiceOrder {
export interface CreateServiceOrderBody {
serviceId: number;
/**
* @minimum 1
* @maximum 50
*/
quantity?: number;
/**
* @maxLength 500
* @nullable
@@ -321,8 +308,8 @@ export type UpdateServiceOrderStatusBodyStatus =
(typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus];
export const UpdateServiceOrderStatusBodyStatus = {
claimed: "claimed",
delivered: "delivered",
preparing: "preparing",
completed: "completed",
cancelled: "cancelled",
} as const;
+6 -6
View File
@@ -2462,7 +2462,7 @@ export function useListMyServiceOrders<
}
/**
* @summary List active orders for receivers (requires orders:receive)
* @summary List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled.
*/
export const getListIncomingServiceOrdersUrl = () => {
return `/api/orders/incoming`;
@@ -2514,7 +2514,7 @@ export type ListIncomingServiceOrdersQueryResult = NonNullable<
export type ListIncomingServiceOrdersQueryError = ErrorType<ErrorResponse>;
/**
* @summary List active orders for receivers (requires orders:receive)
* @summary List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled.
*/
export function useListIncomingServiceOrders<
@@ -2538,7 +2538,7 @@ export function useListIncomingServiceOrders<
}
/**
* @summary Receiver updates order status (claim/deliver/cancel)
* @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
*/
export const getUpdateServiceOrderStatusUrl = (id: number) => {
return `/api/orders/${id}/status`;
@@ -2603,7 +2603,7 @@ export type UpdateServiceOrderStatusMutationBody =
export type UpdateServiceOrderStatusMutationError = ErrorType<ErrorResponse>;
/**
* @summary Receiver updates order status (claim/deliver/cancel)
* @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
*/
export const useUpdateServiceOrderStatus = <
TError = ErrorType<ErrorResponse>,
@@ -2626,7 +2626,7 @@ export const useUpdateServiceOrderStatus = <
};
/**
* @summary Requester confirms they received their order
* @summary Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned.
*/
export const getConfirmServiceOrderReceiptUrl = (id: number) => {
return `/api/orders/${id}/confirm-receipt`;
@@ -2687,7 +2687,7 @@ export type ConfirmServiceOrderReceiptMutationResult = NonNullable<
export type ConfirmServiceOrderReceiptMutationError = ErrorType<ErrorResponse>;
/**
* @summary Requester confirms they received their order
* @summary Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned.
*/
export const useConfirmServiceOrderReceipt = <
TError = ErrorType<ErrorResponse>,
+10 -26
View File
@@ -634,7 +634,7 @@ paths:
get:
operationId: listIncomingServiceOrders
tags: [orders]
summary: List active orders for receivers (requires orders:receive)
summary: List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled.
responses:
"200":
description: Incoming orders
@@ -655,7 +655,7 @@ paths:
patch:
operationId: updateServiceOrderStatus
tags: [orders]
summary: Receiver updates order status (claim/deliver/cancel)
summary: Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
parameters:
- name: id
in: path
@@ -686,7 +686,7 @@ paths:
patch:
operationId: confirmServiceOrderReceipt
tags: [orders]
summary: Requester confirms they received their order
summary: Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned.
parameters:
- name: id
in: path
@@ -1782,7 +1782,7 @@ components:
ServiceOrderStatus:
type: string
enum: [pending, claimed, delivered, received, cancelled]
enum: [pending, received, preparing, completed, cancelled]
ServiceOrder:
type: object
@@ -1791,12 +1791,10 @@ components:
type: integer
serviceId:
type: integer
requestedBy:
userId:
type: integer
assignedTo:
type: ["integer", "null"]
quantity:
type: integer
notes:
type: ["string", "null"]
status:
@@ -1804,17 +1802,8 @@ components:
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"]
updatedAt:
type: string
format: date-time
service:
$ref: "#/components/schemas/ServiceOrderService"
@@ -1829,10 +1818,10 @@ components:
required:
- id
- serviceId
- requestedBy
- quantity
- userId
- status
- createdAt
- updatedAt
- service
CreateServiceOrderBody:
@@ -1840,11 +1829,6 @@ components:
properties:
serviceId:
type: integer
quantity:
type: integer
minimum: 1
maximum: 50
default: 1
notes:
type: ["string", "null"]
maxLength: 500
@@ -1856,7 +1840,7 @@ components:
properties:
status:
type: string
enum: [claimed, delivered, cancelled]
enum: [preparing, completed, cancelled]
required:
- status
+20 -44
View File
@@ -554,18 +554,10 @@ export const DeleteServiceParams = zod.object({
/**
* @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(),
});
@@ -575,22 +567,18 @@ export const CreateServiceOrderBody = zod.object({
export const ListMyServiceOrdersResponseItem = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
userId: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"preparing",
"completed",
"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(),
updatedAt: zod.coerce.date(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
@@ -627,27 +615,23 @@ export const ListMyServiceOrdersResponse = zod.array(
);
/**
* @summary List active orders for receivers (requires orders:receive)
* @summary List active orders for receivers (requires orders.receive permission). Returns pending+unassigned orders plus orders assigned to me that are not completed/cancelled.
*/
export const ListIncomingServiceOrdersResponseItem = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
userId: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"preparing",
"completed",
"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(),
updatedAt: zod.coerce.date(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
@@ -684,35 +668,31 @@ export const ListIncomingServiceOrdersResponse = zod.array(
);
/**
* @summary Receiver updates order status (claim/deliver/cancel)
* @summary Update order status to preparing, completed, or cancelled. Preparing/completed restricted to assigned receiver or admin; cancelled allowed for owner (while pending|received) or admin (any time).
*/
export const UpdateServiceOrderStatusParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateServiceOrderStatusBody = zod.object({
status: zod.enum(["claimed", "delivered", "cancelled"]),
status: zod.enum(["preparing", "completed", "cancelled"]),
});
export const UpdateServiceOrderStatusResponse = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
userId: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"preparing",
"completed",
"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(),
updatedAt: zod.coerce.date(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
@@ -746,7 +726,7 @@ export const UpdateServiceOrderStatusResponse = zod.object({
});
/**
* @summary Requester confirms they received their order
* @summary Receiver atomically claims a pending order (sets status=received, assigned_to=current user). Requires orders.receive permission. Returns 409 already_claimed if the order is no longer pending+unassigned.
*/
export const ConfirmServiceOrderReceiptParams = zod.object({
id: zod.coerce.number(),
@@ -755,22 +735,18 @@ export const ConfirmServiceOrderReceiptParams = zod.object({
export const ConfirmServiceOrderReceiptResponse = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
userId: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"preparing",
"completed",
"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(),
updatedAt: zod.coerce.date(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
+24 -21
View File
@@ -1,31 +1,34 @@
import { pgTable, text, serial, timestamp, integer, varchar } from "drizzle-orm/pg-core";
import { pgTable, text, serial, timestamp, integer, varchar, check } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
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 serviceOrdersTable = pgTable(
"service_orders",
{
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
serviceId: integer("service_id").notNull().references(() => servicesTable.id, { onDelete: "restrict" }),
notes: text("notes"),
status: varchar("status", { length: 20 }).notNull().default("pending"),
assignedTo: integer("assigned_to").references(() => usersTable.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
},
(t) => [
check(
"service_orders_status_check",
sql`${t.status} IN ('pending','received','preparing','completed','cancelled')`,
),
],
);
export const insertServiceOrderSchema = createInsertSchema(serviceOrdersTable).omit({
id: true,
createdAt: true,
claimedAt: true,
deliveredAt: true,
receivedAt: true,
cancelledAt: true,
updatedAt: true,
status: true,
assignedTo: true,
});
@@ -34,9 +37,9 @@ export type ServiceOrder = typeof serviceOrdersTable.$inferSelect;
export const SERVICE_ORDER_STATUSES = [
"pending",
"claimed",
"delivered",
"received",
"preparing",
"completed",
"cancelled",
] as const;
export type ServiceOrderStatus = (typeof SERVICE_ORDER_STATUSES)[number];