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
+412
View File
@@ -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
*/