diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 999649da..d1988e49 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -309,9 +309,16 @@ router.patch( } } else if (next === "cancelled") { const isOwner = existing.userId === userId; + const isAssignee = existing.assignedTo === userId; const cancellableByOwner = existing.status === "pending" || existing.status === "received"; - if (!admin && !(isOwner && cancellableByOwner)) { + const cancellableByAssignee = + existing.status === "received" || existing.status === "preparing"; + if ( + !admin && + !(isOwner && cancellableByOwner) && + !(isAssignee && cancellableByAssignee) + ) { res.status(403).json({ error: "Forbidden" }); return; } diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index ed42c81d..0ecd271d 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -1,7 +1,7 @@ import { Router, type IRouter } from "express"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { db } from "@workspace/db"; -import { usersTable } from "@workspace/db"; +import { usersTable, userRolesTable, rolesTable } from "@workspace/db"; import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; import { RegisterBody, @@ -9,6 +9,7 @@ import { UpdateUserParams, UpdateUserBody, DeleteUserParams, + AddUserRoleBody, } from "@workspace/api-zod"; import bcrypt from "bcryptjs"; @@ -150,6 +151,90 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise => { res.json(buildUserProfile(user, roles)); }); +router.post("/users/:id/roles", requireAdmin, async (req, res): Promise => { + const params = GetUserParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const parsed = AddUserRoleBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, params.data.id)); + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + const [role] = await db + .select({ id: rolesTable.id }) + .from(rolesTable) + .where(eq(rolesTable.name, parsed.data.roleName)); + if (!role) { + res.status(404).json({ error: "Role not found" }); + return; + } + + await db + .insert(userRolesTable) + .values({ userId: user.id, roleId: role.id }) + .onConflictDoNothing(); + + const roles = await getUserRoles(user.id); + res.json(buildUserProfile(user, roles)); +}); + +router.delete( + "/users/:id/roles/:roleName", + requireAdmin, + async (req, res): Promise => { + const params = GetUserParams.safeParse({ id: req.params.id }); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const roleName = String(req.params.roleName ?? "").trim(); + if (!roleName) { + res.status(400).json({ error: "roleName required" }); + return; + } + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, params.data.id)); + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + const [role] = await db + .select({ id: rolesTable.id }) + .from(rolesTable) + .where(eq(rolesTable.name, roleName)); + + if (role) { + await db + .delete(userRolesTable) + .where( + and( + eq(userRolesTable.userId, user.id), + eq(userRolesTable.roleId, role.id), + ), + ); + } + + const roles = await getUserRoles(user.id); + res.json(buildUserProfile(user, roles)); + }, +); + router.delete("/users/:id", requireAdmin, async (req, res): Promise => { const params = DeleteUserParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/api-server/tests/service-orders.test.mjs b/artifacts/api-server/tests/service-orders.test.mjs index 2a56e622..171080cf 100644 --- a/artifacts/api-server/tests/service-orders.test.mjs +++ b/artifacts/api-server/tests/service-orders.test.mjs @@ -215,6 +215,29 @@ test("status transitions matrix: receiver flows + owner/admin cancel rules", asy res = await call(ca, "PATCH", `/orders/${o2.id}/status`, { status: "cancelled" }); assert.equal(res.status, 200); + // Assigned receiver CAN cancel their own assigned order while preparing + res = await call(co, "POST", "/orders", { serviceId }); + const o3 = await res.json(); + createdOrderIds.push(o3.id); + res = await call(cr, "PATCH", `/orders/${o3.id}/confirm-receipt`); + assert.equal(res.status, 200); + res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" }); + assert.equal(res.status, 200); + res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "cancelled" }); + assert.equal(res.status, 200); + assert.equal((await res.json()).status, "cancelled"); + + // A different receiver (not the assignee) cannot cancel + res = await call(co, "POST", "/orders", { serviceId }); + const o4 = await res.json(); + createdOrderIds.push(o4.id); + res = await call(cr, "PATCH", `/orders/${o4.id}/confirm-receipt`); + assert.equal(res.status, 200); + const otherRecv = await createUser("recv4", "order_receiver"); + const cor = await login(otherRecv.username); + res = await call(cor, "PATCH", `/orders/${o4.id}/status`, { status: "cancelled" }); + assert.equal(res.status, 403); + // Receiver completes original order res = await call(cr, "PATCH", `/orders/${order.id}/status`, { status: "completed", diff --git a/artifacts/teaboy-os/src/App.tsx b/artifacts/teaboy-os/src/App.tsx index 13d91dbb..3f58cf22 100644 --- a/artifacts/teaboy-os/src/App.tsx +++ b/artifacts/teaboy-os/src/App.tsx @@ -16,6 +16,7 @@ import NotificationsPage from "@/pages/notifications"; import AdminPage from "@/pages/admin"; import NotesPage from "@/pages/notes"; import MyOrdersPage from "@/pages/my-orders"; +import OrdersIncomingPage from "@/pages/orders-incoming"; const queryClient = new QueryClient({ defaultOptions: { @@ -46,6 +47,7 @@ function Router() { + diff --git a/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts b/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts index 92734325..28360f44 100644 --- a/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/teaboy-os/src/hooks/use-notifications-socket.ts @@ -5,6 +5,7 @@ import { getListNotificationsQueryKey, getGetHomeStatsQueryKey, getListMyServiceOrdersQueryKey, + getListIncomingServiceOrdersQueryKey, } from "@workspace/api-client-react"; import { useAuth } from "@/contexts/AuthContext"; @@ -28,6 +29,9 @@ export function useNotificationsSocket() { queryClient.invalidateQueries({ queryKey: getListMyServiceOrdersQueryKey(), }); + queryClient.invalidateQueries({ + queryKey: getListIncomingServiceOrdersQueryKey(), + }); } }); @@ -35,6 +39,15 @@ export function useNotificationsSocket() { queryClient.invalidateQueries({ queryKey: getListMyServiceOrdersQueryKey(), }); + queryClient.invalidateQueries({ + queryKey: getListIncomingServiceOrdersQueryKey(), + }); + }); + + socket.on("order_incoming_changed", () => { + queryClient.invalidateQueries({ + queryKey: getListIncomingServiceOrdersQueryKey(), + }); }); return () => { diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 60d66120..5cb198cb 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -140,6 +140,32 @@ "completed": "مكتمل", "cancelled": "ملغى" }, + "incomingOrders": { + "title": "الطلبات الواردة", + "linkLabel": "الطلبات الواردة", + "subtitleUnclaimed": "طلبات بانتظار الاستلام", + "subtitleMine": "طلباتي الجارية", + "empty": "ما في طلبات حالياً", + "emptyHint": "ستظهر هنا الطلبات الجديدة فور وصولها.", + "loadError": "تعذّر تحميل الطلبات الواردة.", + "retry": "إعادة المحاولة", + "noAccess": "ليس لديك صلاحية لاستلام الطلبات.", + "back": "رجوع", + "ageJustNow": "الآن", + "ageMinutes": "منذ {{count}} د", + "ageHours": "منذ {{count}} س", + "ageDays": "منذ {{count}} يوم", + "from": "من", + "confirmReceipt": "استلام الطلب", + "confirming": "جارٍ التأكيد...", + "markPreparing": "بدء التحضير", + "markCompleted": "إنجاز", + "cancel": "إلغاء", + "alreadyClaimed": "استلمه شخص آخر قبلك", + "actionFailed": "تعذّر تنفيذ الإجراء", + "claimed": "تم استلام الطلب", + "marked": "تم تحديث الحالة" + }, "chat": { "title": "المحادثات", "newMessage": "رسالة جديدة", @@ -285,6 +311,7 @@ "roles": "الصلاحيات", "active": "نشط", "issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور", + "orderReceiverRole": "مستلم الطلبات", "resetLinkModal": { "title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}", "description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index ca335335..f9cbc8a6 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -140,6 +140,32 @@ "completed": "Completed", "cancelled": "Cancelled" }, + "incomingOrders": { + "title": "Incoming Orders", + "linkLabel": "Incoming", + "subtitleUnclaimed": "Awaiting receiver", + "subtitleMine": "My active orders", + "empty": "No incoming orders right now", + "emptyHint": "New orders will show up here as they arrive.", + "loadError": "Could not load incoming orders.", + "retry": "Retry", + "noAccess": "You don't have permission to receive orders.", + "back": "Back", + "ageJustNow": "just now", + "ageMinutes": "{{count}}m ago", + "ageHours": "{{count}}h ago", + "ageDays": "{{count}}d ago", + "from": "From", + "confirmReceipt": "Confirm Receipt", + "confirming": "Confirming...", + "markPreparing": "Mark Preparing", + "markCompleted": "Mark Completed", + "cancel": "Cancel", + "alreadyClaimed": "Already claimed by someone else", + "actionFailed": "Could not complete the action", + "claimed": "Order received", + "marked": "Status updated" + }, "chat": { "title": "Chat", "newMessage": "New Message", @@ -282,6 +308,7 @@ "roles": "Roles", "active": "Active", "issueResetLink": "Issue password reset link", + "orderReceiverRole": "Order Receiver", "resetLinkModal": { "title": "Password reset link for {{username}}", "description": "Share this one-time link with the user. It can only be used once.", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index bde241d1..b4169dda 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -17,6 +17,8 @@ import { useCreateUser, useUpdateUser, useDeleteUser, + useAddUserRole, + useRemoveUserRole, useGetAppSettings, getGetAppSettingsQueryKey, useUpdateAppSettings, @@ -187,6 +189,8 @@ export default function AdminPage() { const createUser = useCreateUser(); const updateUser = useUpdateUser(); const deleteUser = useDeleteUser(); + const addUserRole = useAddUserRole(); + const removeUserRole = useRemoveUserRole(); const issueResetLink = useAdminIssueResetLink(); const [resetLinkUser, setResetLinkUser] = useState<{ username: string; url: string; expiresAt: string } | null>(null); @@ -700,6 +704,36 @@ export default function AdminPage() {
+ { diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx index a3a90257..4ae5e6ec 100644 --- a/artifacts/teaboy-os/src/pages/home.tsx +++ b/artifacts/teaboy-os/src/pages/home.tsx @@ -19,6 +19,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { Bell, + Inbox, LogOut, Globe, Grid2X2, @@ -408,6 +409,8 @@ export default function HomePage() { : (user?.displayNameEn ?? user?.username ?? ""); const isAdmin = user?.roles?.includes("admin") ?? false; + const canReceiveOrders = + isAdmin || (user?.roles?.includes("order_receiver") ?? false); const handleLogout = () => { logout.mutate(undefined, { @@ -473,6 +476,15 @@ export default function HomePage() { > + {canReceiveOrders && ( + + )} + )} + + {isMine && order.status === "received" && ( + + )} + + {isMine && order.status === "preparing" && ( + + )} + + {isMine && + (order.status === "received" || order.status === "preparing") && ( + + )} +
+ + ); +} + +export default function OrdersIncomingPage() { + const { t, i18n } = useTranslation(); + const [, setLocation] = useLocation(); + const { user } = useAuth(); + const lang = i18n.language; + const isRtl = lang === "ar"; + const BackIcon = isRtl ? ArrowRight : ArrowLeft; + + const allowed = userCanReceiveOrders(user?.roles); + + const { data: orders, isLoading, isError, refetch } = useListIncomingServiceOrders({ + query: { + queryKey: getListIncomingServiceOrdersQueryKey(), + enabled: !!user && allowed, + }, + }); + + const meId = user?.id ?? -1; + const sorted = (orders ?? []) + .slice() + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + const mine = sorted.filter((o) => o.assignedTo === meId); + const unclaimed = sorted.filter((o) => o.status === "pending" && !o.assignedTo); + + return ( +
+
+ +
+ +

+ {t("incomingOrders.title")} +

+
+
+ +
+ {!allowed ? ( +
+ +
+ {t("incomingOrders.noAccess")} +
+
+ ) : isLoading ? ( +
+ {t("common.loading")} +
+ ) : isError ? ( +
+
+ {t("incomingOrders.loadError")} +
+ +
+ ) : mine.length === 0 && unclaimed.length === 0 ? ( +
+ +
+ {t("incomingOrders.empty")} +
+
+ {t("incomingOrders.emptyHint")} +
+
+ ) : ( +
+ {mine.length > 0 && ( +
+

+ {t("incomingOrders.subtitleMine")} +

+
+ {mine.map((o) => ( + + ))} +
+
+ )} + {unclaimed.length > 0 && ( +
+

+ {t("incomingOrders.subtitleUnclaimed")} +

+
+ {unclaimed.map((o) => ( + + ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 5199d4b8..1817d6d1 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -138,6 +138,11 @@ export interface UpdateUserBody { preferredLanguage?: string; } +export interface AddUserRoleBody { + /** @minLength 1 */ + roleName: string; +} + export interface App { id: number; slug: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index ab24beea..31d917df 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -18,6 +18,7 @@ import type { import type { AddParticipantsBody, + AddUserRoleBody, AdminAppOpensByApp, AdminAppOpensByUser, AdminResetLinkResponse, @@ -4378,6 +4379,178 @@ export const useDeleteUser = < return useMutation(getDeleteUserMutationOptions(options)); }; +/** + * @summary Idempotently add a role to a user (admin) + */ +export const getAddUserRoleUrl = (id: number) => { + return `/api/users/${id}/roles`; +}; + +export const addUserRole = async ( + id: number, + addUserRoleBody: AddUserRoleBody, + options?: RequestInit, +): Promise => { + return customFetch(getAddUserRoleUrl(id), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(addUserRoleBody), + }); +}; + +export const getAddUserRoleMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["addUserRole"]; + 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>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return addUserRole(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type AddUserRoleMutationResult = NonNullable< + Awaited> +>; +export type AddUserRoleMutationBody = BodyType; +export type AddUserRoleMutationError = ErrorType; + +/** + * @summary Idempotently add a role to a user (admin) + */ +export const useAddUserRole = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getAddUserRoleMutationOptions(options)); +}; + +/** + * @summary Idempotently remove a role from a user (admin) + */ +export const getRemoveUserRoleUrl = (id: number, roleName: string) => { + return `/api/users/${id}/roles/${roleName}`; +}; + +export const removeUserRole = async ( + id: number, + roleName: string, + options?: RequestInit, +): Promise => { + return customFetch(getRemoveUserRoleUrl(id, roleName), { + ...options, + method: "DELETE", + }); +}; + +export const getRemoveUserRoleMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; roleName: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; roleName: string }, + TContext +> => { + const mutationKey = ["removeUserRole"]; + 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>, + { id: number; roleName: string } + > = (props) => { + const { id, roleName } = props ?? {}; + + return removeUserRole(id, roleName, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RemoveUserRoleMutationResult = NonNullable< + Awaited> +>; + +export type RemoveUserRoleMutationError = ErrorType; + +/** + * @summary Idempotently remove a role from a user (admin) + */ +export const useRemoveUserRole = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; roleName: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; roleName: string }, + TContext +> => { + return useMutation(getRemoveUserRoleMutationOptions(options)); +}; + /** * @summary Get home screen stats */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 51448ef5..40933811 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -26,6 +26,8 @@ tags: description: Notifications - name: users description: User management + - name: roles + description: Role assignment - name: stats description: Dashboard stats - name: storage @@ -1123,6 +1125,73 @@ paths: "204": description: Deleted + /users/{id}/roles: + post: + operationId: addUserRole + tags: [roles] + summary: Idempotently add a role to a user (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddUserRoleBody" + responses: + "200": + description: Role added (or already present) + content: + application/json: + schema: + $ref: "#/components/schemas/UserProfile" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: User or role not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /users/{id}/roles/{roleName}: + delete: + operationId: removeUserRole + tags: [roles] + summary: Idempotently remove a role from a user (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: roleName + in: path + required: true + schema: + type: string + responses: + "200": + description: Role removed (or already absent) + content: + application/json: + schema: + $ref: "#/components/schemas/UserProfile" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # Stats /stats/home: get: @@ -1551,6 +1620,15 @@ components: preferredLanguage: type: string + AddUserRoleBody: + type: object + properties: + roleName: + type: string + minLength: 1 + required: + - roleName + App: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 8b95004c..9a1fa971 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1405,6 +1405,69 @@ export const DeleteUserParams = zod.object({ id: zod.coerce.number(), }); +/** + * @summary Idempotently add a role to a user (admin) + */ +export const AddUserRoleParams = zod.object({ + id: zod.coerce.number(), +}); + +export const AddUserRoleBody = zod.object({ + roleName: zod.string().min(1), +}); + +export const AddUserRoleResponse = zod.object({ + id: zod.number(), + username: zod.string(), + email: zod.string(), + displayNameAr: zod.string().nullish(), + displayNameEn: zod.string().nullish(), + preferredLanguage: zod.string(), + clockStyle: zod + .union([ + zod + .enum(["full", "digital", "digital-no-seconds", "analog", "minimal"]) + .describe("Per-user home-screen clock style preference"), + zod.null(), + ]) + .optional(), + clockHour12: zod.boolean().nullish(), + avatarUrl: zod.string().nullish(), + isActive: zod.boolean(), + roles: zod.array(zod.string()), + createdAt: zod.coerce.date(), +}); + +/** + * @summary Idempotently remove a role from a user (admin) + */ +export const RemoveUserRoleParams = zod.object({ + id: zod.coerce.number(), + roleName: zod.coerce.string(), +}); + +export const RemoveUserRoleResponse = zod.object({ + id: zod.number(), + username: zod.string(), + email: zod.string(), + displayNameAr: zod.string().nullish(), + displayNameEn: zod.string().nullish(), + preferredLanguage: zod.string(), + clockStyle: zod + .union([ + zod + .enum(["full", "digital", "digital-no-seconds", "analog", "minimal"]) + .describe("Per-user home-screen clock style preference"), + zod.null(), + ]) + .optional(), + clockHour12: zod.boolean().nullish(), + avatarUrl: zod.string().nullish(), + isActive: zod.boolean(), + roles: zod.array(zod.string()), + createdAt: zod.coerce.date(), +}); + /** * @summary Get home screen stats */