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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user