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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user