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:
riyadhafraa
2026-04-21 18:24:20 +00:00
parent de8333e659
commit cdf5bf4d33
11 changed files with 1713 additions and 11 deletions
+67 -1
View File
@@ -4,8 +4,10 @@ import {
usersTable,
userRolesTable,
rolesTable,
rolePermissionsTable,
permissionsTable,
} from "@workspace/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
declare module "express-session" {
interface SessionData {
@@ -62,6 +64,70 @@ export async function requireAdmin(
next();
}
export function requirePermission(name: string) {
return async function (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const rows = await db
.select({ roleName: rolesTable.name })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.innerJoin(
rolePermissionsTable,
eq(rolePermissionsTable.roleId, rolesTable.id),
)
.innerJoin(
permissionsTable,
eq(rolePermissionsTable.permissionId, permissionsTable.id),
)
.where(
and(
eq(userRolesTable.userId, req.session.userId),
eq(permissionsTable.name, name),
),
);
const isAdmin = rows.some((r) => r.roleName === "admin");
const hasPerm = rows.length > 0;
if (!isAdmin && !hasPerm) {
res.status(403).json({ error: "Forbidden" });
return;
}
next();
};
}
export async function userHasPermission(
userId: number,
name: string,
): Promise<boolean> {
const rows = await db
.select({ roleName: rolesTable.name })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.innerJoin(
rolePermissionsTable,
eq(rolePermissionsTable.roleId, rolesTable.id),
)
.innerJoin(
permissionsTable,
eq(rolePermissionsTable.permissionId, permissionsTable.id),
)
.where(
and(eq(userRolesTable.userId, userId), eq(permissionsTable.name, name)),
);
return rows.length > 0;
}
export async function getUserRoles(userId: number): Promise<string[]> {
const roles = await db
.select({ roleName: rolesTable.name })
+2
View File
@@ -3,6 +3,7 @@ import healthRouter from "./health";
import authRouter from "./auth";
import appsRouter from "./apps";
import servicesRouter from "./services";
import serviceOrdersRouter from "./service-orders";
import conversationsRouter from "./conversations";
import notificationsRouter from "./notifications";
import usersRouter from "./users";
@@ -17,6 +18,7 @@ router.use(healthRouter);
router.use(authRouter);
router.use(appsRouter);
router.use(servicesRouter);
router.use(serviceOrdersRouter);
router.use(conversationsRouter);
router.use(notificationsRouter);
router.use(usersRouter);
@@ -0,0 +1,396 @@
import { Router, type IRouter } from "express";
import { eq, and, desc, sql, isNull, inArray } from "drizzle-orm";
import { db } from "@workspace/db";
import {
serviceOrdersTable,
servicesTable,
usersTable,
notificationsTable,
userRolesTable,
rolesTable,
rolePermissionsTable,
permissionsTable,
} from "@workspace/db";
import { requireAuth, requirePermission, userHasPermission } from "../middlewares/auth";
import {
CreateServiceOrderBody,
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
UpdateServiceOrderStatusBody,
} from "@workspace/api-zod";
const router: IRouter = Router();
const ACTIVE_STATUSES = ["pending", "claimed", "delivered"] as const;
type EnrichedOrder = Awaited<ReturnType<typeof loadOrder>>;
async function loadOrder(id: number) {
const [row] = await db
.select({
id: serviceOrdersTable.id,
serviceId: serviceOrdersTable.serviceId,
requestedBy: serviceOrdersTable.requestedBy,
assignedTo: serviceOrdersTable.assignedTo,
quantity: serviceOrdersTable.quantity,
notes: serviceOrdersTable.notes,
status: serviceOrdersTable.status,
createdAt: serviceOrdersTable.createdAt,
claimedAt: serviceOrdersTable.claimedAt,
deliveredAt: serviceOrdersTable.deliveredAt,
receivedAt: serviceOrdersTable.receivedAt,
cancelledAt: serviceOrdersTable.cancelledAt,
service: {
id: servicesTable.id,
nameAr: servicesTable.nameAr,
nameEn: servicesTable.nameEn,
imageUrl: servicesTable.imageUrl,
},
})
.from(serviceOrdersTable)
.innerJoin(servicesTable, eq(serviceOrdersTable.serviceId, servicesTable.id))
.where(eq(serviceOrdersTable.id, id));
if (!row) return null;
const userIds = [row.requestedBy, ...(row.assignedTo ? [row.assignedTo] : [])];
const userRows = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
avatarUrl: usersTable.avatarUrl,
})
.from(usersTable)
.where(inArray(usersTable.id, userIds));
const userById = new Map(userRows.map((u) => [u.id, u]));
return {
...row,
requester: userById.get(row.requestedBy) ?? null,
assignee: row.assignedTo ? (userById.get(row.assignedTo) ?? null) : null,
};
}
async function getReceiverUserIds(): Promise<number[]> {
// Anyone with `orders:receive` permission OR admin role
const rows = await db
.selectDistinct({ userId: userRolesTable.userId })
.from(userRolesTable)
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
.leftJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, rolesTable.id))
.leftJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
.where(
sql`${rolesTable.name} = 'admin' OR ${permissionsTable.name} = 'orders:receive'`,
);
return rows.map((r) => r.userId);
}
async function emitToUser(userId: number, event: string, payload: unknown) {
const { io } = await import("../index.js");
io.to(`user:${userId}`).emit(event, payload);
}
async function notifyUser(
userId: number,
titleAr: string,
titleEn: string,
bodyAr: string,
bodyEn: string,
type: string,
relatedId: number,
) {
const [notification] = await db
.insert(notificationsTable)
.values({
userId,
titleAr,
titleEn,
bodyAr,
bodyEn,
type,
relatedId,
relatedType: "service_order",
})
.returning();
await emitToUser(userId, "notification_created", notification);
}
async function broadcastIncomingChanged(receiverIds: number[]) {
for (const uid of receiverIds) {
await emitToUser(uid, "order_incoming_changed", { changed: true });
}
}
// POST /api/orders - place a new order
router.post("/orders", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = CreateServiceOrderBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [service] = await db
.select()
.from(servicesTable)
.where(eq(servicesTable.id, parsed.data.serviceId));
if (!service) {
res.status(404).json({ error: "Service not found" });
return;
}
if (!service.isAvailable) {
res.status(400).json({ error: "Service is not available" });
return;
}
const [order] = await db
.insert(serviceOrdersTable)
.values({
serviceId: parsed.data.serviceId,
requestedBy: userId,
quantity: parsed.data.quantity ?? 1,
notes: parsed.data.notes ?? null,
})
.returning();
const enriched = await loadOrder(order.id);
// Notify all receivers
const receivers = await getReceiverUserIds();
const requesterName =
enriched?.requester?.displayNameAr ??
enriched?.requester?.displayNameEn ??
enriched?.requester?.username ??
"";
for (const rid of receivers) {
if (rid === userId) continue;
await notifyUser(
rid,
"طلب خدمة جديد",
"New service order",
`${requesterName} طلب ${service.nameAr}`,
`${requesterName} requested ${service.nameEn}`,
"order",
order.id,
);
}
await broadcastIncomingChanged(receivers);
res.status(201).json(enriched);
});
// GET /api/orders/my - orders placed by the current user
router.get("/orders/my", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const rows = await db
.select({ id: serviceOrdersTable.id })
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.requestedBy, userId))
.orderBy(desc(serviceOrdersTable.createdAt))
.limit(100);
const enriched = await Promise.all(rows.map((r) => loadOrder(r.id)));
res.json(enriched.filter(Boolean));
});
// GET /api/orders/incoming - pending+active orders for receivers
router.get(
"/orders/incoming",
requireAuth,
requirePermission("orders:receive"),
async (req, res): Promise<void> => {
const userId = req.session.userId!;
// Show: all pending (unassigned), plus orders assigned to this receiver
const rows = await db
.select({ id: serviceOrdersTable.id })
.from(serviceOrdersTable)
.where(
and(
inArray(serviceOrdersTable.status, ACTIVE_STATUSES as unknown as string[]),
sql`(${serviceOrdersTable.assignedTo} IS NULL OR ${serviceOrdersTable.assignedTo} = ${userId})`,
),
)
.orderBy(desc(serviceOrdersTable.createdAt))
.limit(200);
const enriched = await Promise.all(rows.map((r) => loadOrder(r.id)));
res.json(enriched.filter(Boolean));
},
);
// PATCH /api/orders/:id/status - receiver updates status (claim/deliver/cancel)
router.patch(
"/orders/:id/status",
requireAuth,
requirePermission("orders:receive"),
async (req, res): Promise<void> => {
const params = ServiceOrderIdParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const parsed = UpdateServiceOrderStatusBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
const orderId = params.data.id;
const next = parsed.data.status;
const [existing] = await db
.select()
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.id, orderId));
if (!existing) {
res.status(404).json({ error: "Order not found" });
return;
}
if (next === "claimed") {
// Atomic claim: only succeeds if still pending and unassigned
const [updated] = await db
.update(serviceOrdersTable)
.set({
status: "claimed",
assignedTo: userId,
claimedAt: new Date(),
})
.where(
and(
eq(serviceOrdersTable.id, orderId),
eq(serviceOrdersTable.status, "pending"),
isNull(serviceOrdersTable.assignedTo),
),
)
.returning();
if (!updated) {
res.status(409).json({ error: "already_claimed" });
return;
}
} else if (next === "delivered") {
if (existing.status !== "claimed" || existing.assignedTo !== userId) {
res.status(409).json({ error: "invalid_transition" });
return;
}
await db
.update(serviceOrdersTable)
.set({ status: "delivered", deliveredAt: new Date() })
.where(eq(serviceOrdersTable.id, orderId));
} else if (next === "cancelled") {
// Receiver may cancel only their own active orders, or admin via permission middleware
const isAdminUser = await userHasPermission(userId, "users:manage");
if (
!isAdminUser &&
!(existing.assignedTo === userId && existing.status !== "received")
) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (existing.status === "received" || existing.status === "cancelled") {
res.status(409).json({ error: "invalid_transition" });
return;
}
await db
.update(serviceOrdersTable)
.set({ status: "cancelled", cancelledAt: new Date() })
.where(eq(serviceOrdersTable.id, orderId));
} else {
res.status(400).json({ error: "Unsupported status transition" });
return;
}
const enriched = await loadOrder(orderId);
if (!enriched) {
res.status(404).json({ error: "Order not found" });
return;
}
// Notify the requester about status change
const titleMap: Record<string, [string, string]> = {
claimed: ["تم استلام طلبك", "Your order was claimed"],
delivered: ["تم تسليم طلبك", "Your order was delivered"],
cancelled: ["تم إلغاء طلبك", "Your order was cancelled"],
};
const [titleAr, titleEn] = titleMap[next] ?? ["", ""];
if (titleAr) {
await notifyUser(
existing.requestedBy,
titleAr,
titleEn,
enriched.service.nameAr,
enriched.service.nameEn,
"order",
orderId,
);
}
// Notify other receivers that the incoming list changed
const receivers = await getReceiverUserIds();
await broadcastIncomingChanged(receivers);
// Notify requester their order changed too
await emitToUser(existing.requestedBy, "order_updated", enriched);
res.json(enriched);
},
);
// PATCH /api/orders/:id/confirm-receipt - requester confirms receipt
router.patch(
"/orders/:id/confirm-receipt",
requireAuth,
async (req, res): Promise<void> => {
const params = ServiceOrderIdParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const userId = req.session.userId!;
const orderId = params.data.id;
const [existing] = await db
.select()
.from(serviceOrdersTable)
.where(eq(serviceOrdersTable.id, orderId));
if (!existing) {
res.status(404).json({ error: "Order not found" });
return;
}
if (existing.requestedBy !== userId) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (existing.status !== "delivered") {
res.status(409).json({ error: "invalid_transition" });
return;
}
await db
.update(serviceOrdersTable)
.set({ status: "received", receivedAt: new Date() })
.where(eq(serviceOrdersTable.id, orderId));
const enriched = await loadOrder(orderId);
if (existing.assignedTo) {
await notifyUser(
existing.assignedTo,
"تم تأكيد الاستلام",
"Receipt confirmed",
enriched?.service.nameAr ?? "",
enriched?.service.nameEn ?? "",
"order",
orderId,
);
await emitToUser(existing.assignedTo, "order_updated", enriched);
}
const receivers = await getReceiverUserIds();
await broadcastIncomingChanged(receivers);
res.json(enriched);
},
);
export default router;
@@ -0,0 +1,192 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdOrderIds = [];
async function createUser(prefix, roleName) {
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'SO Test', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2`,
[id, roleName],
);
return { id, username };
}
async function login(username) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: TEST_PASSWORD }),
});
assert.equal(res.status, 200);
const sc = res.headers.get("set-cookie");
return sc.split(",").map((c) => c.split(";")[0].trim()).find((c) =>
c.startsWith("connect.sid="),
);
}
async function call(cookie, method, path, body) {
return fetch(`${API_BASE}/api${path}`, {
method,
headers: {
"Content-Type": "application/json",
Cookie: cookie,
},
body: body ? JSON.stringify(body) : undefined,
});
}
let serviceId;
before(async () => {
const { rows } = await pool.query(
`SELECT id FROM services WHERE is_available = true ORDER BY id LIMIT 1`,
);
if (rows.length === 0) throw new Error("No available service to test with");
serviceId = rows[0].id;
});
after(async () => {
if (createdOrderIds.length > 0) {
await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
createdOrderIds,
]);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]);
}
await pool.end();
});
test("full order lifecycle: place → claim → deliver → confirm", async () => {
const requester = await createUser("req", "user");
const receiver = await createUser("recv", "order_receiver");
const reqCookie = await login(requester.username);
const recvCookie = await login(receiver.username);
// Place order
let res = await call(reqCookie, "POST", "/orders", {
serviceId,
quantity: 2,
notes: "extra hot",
});
assert.equal(res.status, 201);
const order = await res.json();
createdOrderIds.push(order.id);
assert.equal(order.status, "pending");
assert.equal(order.quantity, 2);
assert.equal(order.requestedBy, requester.id);
assert.ok(order.service);
assert.ok(order.service.nameAr);
// Should NOT include description fields
assert.equal(order.service.descriptionAr, undefined);
// Receiver lists incoming
res = await call(recvCookie, "GET", "/orders/incoming");
assert.equal(res.status, 200);
const incoming = await res.json();
assert.ok(incoming.find((o) => o.id === order.id));
// Non-receiver gets 403 on incoming
res = await call(reqCookie, "GET", "/orders/incoming");
assert.equal(res.status, 403);
// Receiver claims
res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, {
status: "claimed",
});
assert.equal(res.status, 200);
const claimed = await res.json();
assert.equal(claimed.status, "claimed");
assert.equal(claimed.assignedTo, receiver.id);
// Requester cannot confirm receipt while only claimed
res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
assert.equal(res.status, 409);
// Receiver delivers
res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, {
status: "delivered",
});
assert.equal(res.status, 200);
assert.equal((await res.json()).status, "delivered");
// Other user cannot confirm receipt
const stranger = await createUser("str", "user");
const strCookie = await login(stranger.username);
res = await call(strCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
assert.equal(res.status, 403);
// Requester confirms receipt
res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
assert.equal(res.status, 200);
assert.equal((await res.json()).status, "received");
// Verify the order shows up in /orders/my
res = await call(reqCookie, "GET", "/orders/my");
assert.equal(res.status, 200);
const my = await res.json();
assert.ok(my.find((o) => o.id === order.id && o.status === "received"));
});
test("atomic claim: only one receiver wins", async () => {
const requester = await createUser("req2", "user");
const recv1 = await createUser("recv1", "order_receiver");
const recv2 = await createUser("recv2", "order_receiver");
const reqCookie = await login(requester.username);
const c1 = await login(recv1.username);
const c2 = await login(recv2.username);
let res = await call(reqCookie, "POST", "/orders", { serviceId });
assert.equal(res.status, 201);
const order = await res.json();
createdOrderIds.push(order.id);
const [r1, r2] = await Promise.all([
call(c1, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }),
call(c2, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }),
]);
const statuses = [r1.status, r2.status].sort();
assert.deepEqual(statuses, [200, 409]);
});
test("non-authenticated cannot place order", async () => {
const res = await fetch(`${API_BASE}/api/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceId }),
});
assert.equal(res.status, 401);
});
@@ -249,6 +249,87 @@ export interface ServiceCategory {
createdAt: string;
}
export interface ServiceOrderUser {
id: number;
username: string;
/** @nullable */
displayNameAr?: string | null;
/** @nullable */
displayNameEn?: string | null;
/** @nullable */
avatarUrl?: string | null;
}
export interface ServiceOrderService {
id: number;
nameAr: string;
nameEn: string;
/** @nullable */
imageUrl?: string | null;
}
export type ServiceOrderStatus =
(typeof ServiceOrderStatus)[keyof typeof ServiceOrderStatus];
export const ServiceOrderStatus = {
pending: "pending",
claimed: "claimed",
delivered: "delivered",
received: "received",
cancelled: "cancelled",
} as const;
export interface ServiceOrder {
id: number;
serviceId: number;
requestedBy: 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;
service: ServiceOrderService;
requester?: ServiceOrderUser | null;
assignee?: ServiceOrderUser | null;
}
export interface CreateServiceOrderBody {
serviceId: number;
/**
* @minimum 1
* @maximum 50
*/
quantity?: number;
/**
* @maxLength 500
* @nullable
*/
notes?: string | null;
}
export type UpdateServiceOrderStatusBodyStatus =
(typeof UpdateServiceOrderStatusBodyStatus)[keyof typeof UpdateServiceOrderStatusBodyStatus];
export const UpdateServiceOrderStatusBodyStatus = {
claimed: "claimed",
delivered: "delivered",
cancelled: "cancelled",
} as const;
export interface UpdateServiceOrderStatusBody {
status: UpdateServiceOrderStatusBodyStatus;
}
export interface ParticipantInfo {
id: number;
username: string;
+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
*/
+232
View File
@@ -16,6 +16,8 @@ tags:
description: App management
- name: services
description: Internal services (khidmati)
- name: orders
description: Service orders workflow
- name: conversations
description: Chat conversations
- name: messages
@@ -588,6 +590,123 @@ paths:
"204":
description: Deleted
/orders:
post:
operationId: createServiceOrder
tags: [orders]
summary: Place a service order
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateServiceOrderBody"
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceOrder"
"400":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/orders/my:
get:
operationId: listMyServiceOrders
tags: [orders]
summary: List orders placed by the current user
responses:
"200":
description: My orders
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ServiceOrder"
/orders/incoming:
get:
operationId: listIncomingServiceOrders
tags: [orders]
summary: List active orders for receivers (requires orders:receive)
responses:
"200":
description: Incoming orders
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ServiceOrder"
"403":
description: Forbidden
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/orders/{id}/status:
patch:
operationId: updateServiceOrderStatus
tags: [orders]
summary: Receiver updates order status (claim/deliver/cancel)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateServiceOrderStatusBody"
responses:
"200":
description: Updated order
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceOrder"
"409":
description: Conflict (already claimed or invalid transition)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/orders/{id}/confirm-receipt:
patch:
operationId: confirmServiceOrderReceipt
tags: [orders]
summary: Requester confirms they received their order
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Order received
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceOrder"
"409":
description: Invalid transition
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/service-categories:
get:
operationId: listServiceCategories
@@ -1628,6 +1747,119 @@ components:
- sortOrder
- createdAt
ServiceOrderUser:
type: object
properties:
id:
type: integer
username:
type: string
displayNameAr:
type: ["string", "null"]
displayNameEn:
type: ["string", "null"]
avatarUrl:
type: ["string", "null"]
required:
- id
- username
ServiceOrderService:
type: object
properties:
id:
type: integer
nameAr:
type: string
nameEn:
type: string
imageUrl:
type: ["string", "null"]
required:
- id
- nameAr
- nameEn
ServiceOrderStatus:
type: string
enum: [pending, claimed, delivered, received, cancelled]
ServiceOrder:
type: object
properties:
id:
type: integer
serviceId:
type: integer
requestedBy:
type: integer
assignedTo:
type: ["integer", "null"]
quantity:
type: integer
notes:
type: ["string", "null"]
status:
$ref: "#/components/schemas/ServiceOrderStatus"
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"]
format: date-time
service:
$ref: "#/components/schemas/ServiceOrderService"
requester:
oneOf:
- $ref: "#/components/schemas/ServiceOrderUser"
- type: "null"
assignee:
oneOf:
- $ref: "#/components/schemas/ServiceOrderUser"
- type: "null"
required:
- id
- serviceId
- requestedBy
- quantity
- status
- createdAt
- service
CreateServiceOrderBody:
type: object
properties:
serviceId:
type: integer
quantity:
type: integer
minimum: 1
maximum: 50
default: 1
notes:
type: ["string", "null"]
maxLength: 500
required:
- serviceId
UpdateServiceOrderStatusBody:
type: object
properties:
status:
type: string
enum: [claimed, delivered, cancelled]
required:
- status
ConversationWithDetails:
type: object
properties:
+252
View File
@@ -551,6 +551,258 @@ export const DeleteServiceParams = zod.object({
id: zod.coerce.number(),
});
/**
* @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(),
});
/**
* @summary List orders placed by the current user
*/
export const ListMyServiceOrdersResponseItem = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"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(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
nameEn: zod.string(),
imageUrl: zod.string().nullish(),
}),
requester: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
assignee: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
});
export const ListMyServiceOrdersResponse = zod.array(
ListMyServiceOrdersResponseItem,
);
/**
* @summary List active orders for receivers (requires orders:receive)
*/
export const ListIncomingServiceOrdersResponseItem = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"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(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
nameEn: zod.string(),
imageUrl: zod.string().nullish(),
}),
requester: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
assignee: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
});
export const ListIncomingServiceOrdersResponse = zod.array(
ListIncomingServiceOrdersResponseItem,
);
/**
* @summary Receiver updates order status (claim/deliver/cancel)
*/
export const UpdateServiceOrderStatusParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateServiceOrderStatusBody = zod.object({
status: zod.enum(["claimed", "delivered", "cancelled"]),
});
export const UpdateServiceOrderStatusResponse = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"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(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
nameEn: zod.string(),
imageUrl: zod.string().nullish(),
}),
requester: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
assignee: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
});
/**
* @summary Requester confirms they received their order
*/
export const ConfirmServiceOrderReceiptParams = zod.object({
id: zod.coerce.number(),
});
export const ConfirmServiceOrderReceiptResponse = zod.object({
id: zod.number(),
serviceId: zod.number(),
requestedBy: zod.number(),
assignedTo: zod.number().nullish(),
quantity: zod.number(),
notes: zod.string().nullish(),
status: zod.enum([
"pending",
"claimed",
"delivered",
"received",
"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(),
service: zod.object({
id: zod.number(),
nameAr: zod.string(),
nameEn: zod.string(),
imageUrl: zod.string().nullish(),
}),
requester: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
assignee: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullish(),
displayNameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
}),
zod.null(),
])
.optional(),
});
/**
* @summary List service categories
*/
+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];
+36 -10
View File
@@ -37,31 +37,57 @@ async function main() {
{ name: "services:manage", descriptionAr: "إدارة الخدمات", descriptionEn: "Manage Services" },
{ name: "users:manage", descriptionAr: "إدارة المستخدمين", descriptionEn: "Manage Users" },
{ name: "chat:access", descriptionAr: "الوصول للمحادثات", descriptionEn: "Access Chat" },
{ name: "orders:receive", descriptionAr: "استقبال الطلبات", descriptionEn: "Receive Service Orders" },
];
const insertedPerms = await db
.insert(permissionsTable)
.values(permissions)
.onConflictDoNothing()
.returning();
await db.insert(permissionsTable).values(permissions).onConflictDoNothing();
const allInsertedPerms = await db.select().from(permissionsTable);
console.log("Permissions created");
// Create order_receiver role
const [orderReceiverRole] = await db
.insert(rolesTable)
.values({
name: "order_receiver",
descriptionAr: "مستقبل الطلبات",
descriptionEn: "Service Order Receiver",
})
.onConflictDoNothing()
.returning();
const allRoles = await db.select().from(rolesTable);
const adminRoleResolved = adminRole ?? allRoles.find((r) => r.name === "admin");
const userRoleResolved = userRole ?? allRoles.find((r) => r.name === "user");
const orderReceiverResolved =
orderReceiverRole ?? allRoles.find((r) => r.name === "order_receiver");
// Assign all permissions to admin role
if (adminRole && insertedPerms.length > 0) {
if (adminRoleResolved && allInsertedPerms.length > 0) {
await db
.insert(rolePermissionsTable)
.values(insertedPerms.map((p) => ({ roleId: adminRole.id, permissionId: p.id })))
.values(allInsertedPerms.map((p) => ({ roleId: adminRoleResolved.id, permissionId: p.id })))
.onConflictDoNothing();
}
// Assign chat permission to user role
if (userRole && insertedPerms.length > 0) {
const chatPerm = insertedPerms.find((p) => p.name === "chat:access");
if (userRoleResolved && allInsertedPerms.length > 0) {
const chatPerm = allInsertedPerms.find((p) => p.name === "chat:access");
if (chatPerm) {
await db
.insert(rolePermissionsTable)
.values({ roleId: userRole.id, permissionId: chatPerm.id })
.values({ roleId: userRoleResolved.id, permissionId: chatPerm.id })
.onConflictDoNothing();
}
}
// Assign orders:receive permission to order_receiver role
if (orderReceiverResolved && allInsertedPerms.length > 0) {
const ordersPerm = allInsertedPerms.find((p) => p.name === "orders:receive");
if (ordersPerm) {
await db
.insert(rolePermissionsTable)
.values({ roleId: orderReceiverResolved.id, permissionId: ordersPerm.id })
.onConflictDoNothing();
}
}