Task #64: Service Orders — receiver page + admin role toggle

Backend (artifacts/api-server)
- Add admin-only role-toggle endpoints:
  - POST /users/:id/roles { roleName } — idempotent, returns UserProfile
  - DELETE /users/:id/roles/:roleName — idempotent, returns UserProfile
- Allow assigned receiver to cancel their own claimed order
  (received/preparing) in PATCH /orders/:id/status. Owner & admin
  rules unchanged.
- New backend test cases: receiver can cancel own assigned order;
  another receiver gets 403.

API spec / codegen
- Add AddUserRoleBody schema and the two new role endpoints
  under a new "roles" tag in lib/api-spec/openapi.yaml.
- Regenerated api-zod and api-client-react.

Frontend (artifacts/teaboy-os)
- New page src/pages/orders-incoming.tsx at /orders/incoming:
  RBAC-gated (admin || order_receiver), shows "My active orders"
  and "Awaiting receiver" sections, with claim, mark preparing,
  mark completed and cancel buttons. Handles 409 already_claimed.
- Add Inbox button to home top bar, conditional on the same roles.
- Admin users table now has an "Order Receiver" Switch wired to
  the new role-toggle hooks.
- Extend use-notifications-socket to invalidate the incoming-orders
  query on order_incoming_changed and on notification_created with
  type === "order".
- Bilingual locale keys (ar/en) for the new page and admin label.

Tests
- All 25 api-server tests pass (24 existing + 1 new receiver-cancel
  case). All 3 teaboy-os e2e tests pass.

Follow-up filed: #67 (notify requester when receiver cancels).
This commit is contained in:
riyadhafraa
2026-04-21 18:58:19 +00:00
parent 41d5c7e757
commit 7ebf59d4b7
14 changed files with 899 additions and 3 deletions
@@ -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;
}
+87 -2
View File
@@ -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<void> => {
res.json(buildUserProfile(user, roles));
});
router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> => {
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<void> => {
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<void> => {
const params = DeleteUserParams.safeParse(req.params);
if (!params.success) {
@@ -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",
+2
View File
@@ -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() {
<Route path="/admin" component={AdminPage} />
<Route path="/notes" component={NotesPage} />
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
@@ -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 () => {
+27
View File
@@ -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": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.",
+27
View File
@@ -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.",
+34
View File
@@ -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() {
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground select-none">
<span>{t("admin.orderReceiverRole")}</span>
<Switch
checked={u.roles?.includes("order_receiver") ?? false}
onCheckedChange={(v) => {
const opts = {
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: getListUsersQueryKey(),
}),
onError: () =>
toast({
title: t("common.error"),
variant: "destructive",
}),
};
if (v) {
addUserRole.mutate(
{ id: u.id, data: { roleName: "order_receiver" } },
opts,
);
} else {
removeUserRole.mutate(
{ id: u.id, roleName: "order_receiver" },
opts,
);
}
}}
/>
</label>
<Switch
checked={u.isActive}
onCheckedChange={(v) => {
+12
View File
@@ -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() {
>
<Globe size={18} />
</button>
{canReceiveOrders && (
<button
onClick={() => setLocation("/orders/incoming")}
aria-label={t("incomingOrders.linkLabel")}
className="p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Inbox size={18} />
</button>
)}
<button
onClick={() => setLocation("/notifications")}
className="relative p-1.5 rounded-lg hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
@@ -0,0 +1,347 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import {
useListIncomingServiceOrders,
getListIncomingServiceOrdersQueryKey,
useConfirmServiceOrderReceipt,
useUpdateServiceOrderStatus,
type ServiceOrder,
} from "@workspace/api-client-react";
import {
ArrowLeft,
ArrowRight,
Inbox,
ImageIcon,
Clock,
CheckCheck,
Flame,
PackageCheck,
XCircle,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { cn } from "@/lib/utils";
export const ORDER_RECEIVER_ROLE = "order_receiver";
export function userCanReceiveOrders(roles: string[] | undefined | null): boolean {
if (!roles) return false;
return roles.includes("admin") || roles.includes(ORDER_RECEIVER_ROLE);
}
function relativeAge(iso: string, t: (k: string, o?: Record<string, unknown>) => string): string {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.max(0, Math.floor(ms / 60000));
if (mins < 1) return t("incomingOrders.ageJustNow");
if (mins < 60) return t("incomingOrders.ageMinutes", { count: mins });
const hours = Math.floor(mins / 60);
if (hours < 24) return t("incomingOrders.ageHours", { count: hours });
const days = Math.floor(hours / 24);
return t("incomingOrders.ageDays", { count: days });
}
function OrderImage({ src, alt }: { src: string | null; alt: string }) {
const [errored, setErrored] = useState(false);
if (!src || errored) {
return (
<div className="w-14 h-14 rounded-lg bg-slate-100 flex items-center justify-center shrink-0">
<ImageIcon size={18} className="text-slate-300" />
</div>
);
}
return (
<img
src={src}
alt={alt}
className="w-14 h-14 rounded-lg object-cover shrink-0"
onError={() => setErrored(true)}
/>
);
}
function StatusIcon({ status }: { status: ServiceOrder["status"] }) {
const cls = "shrink-0";
if (status === "pending") return <Clock size={14} className={cn(cls, "text-slate-500")} />;
if (status === "received") return <CheckCheck size={14} className={cn(cls, "text-sky-600")} />;
if (status === "preparing") return <Flame size={14} className={cn(cls, "text-amber-600")} />;
if (status === "completed") return <PackageCheck size={14} className={cn(cls, "text-emerald-600")} />;
return <XCircle size={14} className={cn(cls, "text-rose-600")} />;
}
function IncomingOrderCard({
order,
meId,
onActionDone,
}: {
order: ServiceOrder;
meId: number;
onActionDone: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { toast } = useToast();
const queryClient = useQueryClient();
const claim = useConfirmServiceOrderReceipt();
const updateStatus = useUpdateServiceOrderStatus();
const isMine = order.assignedTo === meId;
const serviceName = lang === "ar" ? order.service.nameAr : order.service.nameEn;
const requesterName = order.requester
? lang === "ar"
? (order.requester.displayNameAr ?? order.requester.username)
: (order.requester.displayNameEn ?? order.requester.username)
: "";
const img = resolveServiceImageUrl(order.service.imageUrl);
const busy = claim.isPending || updateStatus.isPending;
const invalidate = () => {
queryClient.invalidateQueries({
queryKey: getListIncomingServiceOrdersQueryKey(),
});
onActionDone();
};
const handleConfirmReceipt = () => {
claim.mutate(
{ id: order.id },
{
onSuccess: () => {
toast({ title: t("incomingOrders.claimed") });
invalidate();
},
onError: (err: unknown) => {
const status = (err as { status?: number; response?: { status?: number } } | null)
?.status ?? (err as { response?: { status?: number } } | null)?.response?.status;
if (status === 409) {
toast({
title: t("incomingOrders.alreadyClaimed"),
variant: "destructive",
});
} else {
toast({
title: t("incomingOrders.actionFailed"),
variant: "destructive",
});
}
invalidate();
},
},
);
};
const handleSetStatus = (status: "preparing" | "completed" | "cancelled") => {
updateStatus.mutate(
{ id: order.id, data: { status } },
{
onSuccess: () => {
toast({ title: t("incomingOrders.marked") });
invalidate();
},
onError: () => {
toast({
title: t("incomingOrders.actionFailed"),
variant: "destructive",
});
},
},
);
};
return (
<div className="glass-panel rounded-xl p-3 flex flex-col gap-3">
<div className="flex items-start gap-3">
<OrderImage src={img} alt={serviceName} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<StatusIcon status={order.status} />
<span>{t(`orderStatus.${order.status}`)}</span>
<span aria-hidden="true">·</span>
<span>{relativeAge(order.createdAt, t)}</span>
</div>
<h3 className="text-sm font-semibold text-foreground leading-tight truncate mt-0.5">
{serviceName}
</h3>
<div className="text-xs text-muted-foreground truncate">
{t("incomingOrders.from")}{" "}
<span className="text-foreground/80 font-medium">{requesterName}</span>
</div>
{order.notes && (
<div className="mt-1.5 text-xs text-foreground/80 line-clamp-3 whitespace-pre-wrap">
{order.notes}
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2 justify-end">
{order.status === "pending" && !order.assignedTo && (
<Button size="sm" onClick={handleConfirmReceipt} disabled={busy}>
{claim.isPending && <Loader2 size={14} className="animate-spin me-1.5" />}
{claim.isPending
? t("incomingOrders.confirming")
: t("incomingOrders.confirmReceipt")}
</Button>
)}
{isMine && order.status === "received" && (
<Button
size="sm"
onClick={() => handleSetStatus("preparing")}
disabled={busy}
>
{t("incomingOrders.markPreparing")}
</Button>
)}
{isMine && order.status === "preparing" && (
<Button
size="sm"
onClick={() => handleSetStatus("completed")}
disabled={busy}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{t("incomingOrders.markCompleted")}
</Button>
)}
{isMine &&
(order.status === "received" || order.status === "preparing") && (
<Button
size="sm"
variant="ghost"
className="text-rose-700 hover:text-rose-800 hover:bg-rose-50"
onClick={() => handleSetStatus("cancelled")}
disabled={busy}
>
{t("incomingOrders.cancel")}
</Button>
)}
</div>
</div>
);
}
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 (
<div className="min-h-screen os-bg flex flex-col">
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
<button
onClick={() => setLocation("/")}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("incomingOrders.back")}
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2">
<Inbox size={20} className="text-amber-500" />
<h1 className="text-lg font-semibold text-foreground">
{t("incomingOrders.title")}
</h1>
</div>
</div>
<div className="flex-1 p-4 pb-8 max-w-2xl mx-auto w-full">
{!allowed ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Inbox size={36} className="text-slate-300" />
<div className="text-sm font-medium text-foreground">
{t("incomingOrders.noAccess")}
</div>
</div>
) : isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
{t("common.loading")}
</div>
) : isError ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<div className="text-sm text-rose-700">
{t("incomingOrders.loadError")}
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("incomingOrders.retry")}
</Button>
</div>
) : mine.length === 0 && unclaimed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Inbox size={36} className="text-slate-300" />
<div className="text-sm font-medium text-foreground">
{t("incomingOrders.empty")}
</div>
<div className="text-xs text-muted-foreground">
{t("incomingOrders.emptyHint")}
</div>
</div>
) : (
<div className="flex flex-col gap-6">
{mine.length > 0 && (
<section>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
{t("incomingOrders.subtitleMine")}
</h2>
<div className="flex flex-col gap-2">
{mine.map((o) => (
<IncomingOrderCard
key={o.id}
order={o}
meId={meId}
onActionDone={refetch}
/>
))}
</div>
</section>
)}
{unclaimed.length > 0 && (
<section>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
{t("incomingOrders.subtitleUnclaimed")}
</h2>
<div className="flex flex-col gap-2">
{unclaimed.map((o) => (
<IncomingOrderCard
key={o.id}
order={o}
meId={meId}
onActionDone={refetch}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
</div>
);
}
@@ -138,6 +138,11 @@ export interface UpdateUserBody {
preferredLanguage?: string;
}
export interface AddUserRoleBody {
/** @minLength 1 */
roleName: string;
}
export interface App {
id: number;
slug: string;
+173
View File
@@ -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<UserProfile> => {
return customFetch<UserProfile>(getAddUserRoleUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(addUserRoleBody),
});
};
export const getAddUserRoleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof addUserRole>>,
TError,
{ id: number; data: BodyType<AddUserRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof addUserRole>>,
TError,
{ id: number; data: BodyType<AddUserRoleBody> },
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<ReturnType<typeof addUserRole>>,
{ id: number; data: BodyType<AddUserRoleBody> }
> = (props) => {
const { id, data } = props ?? {};
return addUserRole(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type AddUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof addUserRole>>
>;
export type AddUserRoleMutationBody = BodyType<AddUserRoleBody>;
export type AddUserRoleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Idempotently add a role to a user (admin)
*/
export const useAddUserRole = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof addUserRole>>,
TError,
{ id: number; data: BodyType<AddUserRoleBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof addUserRole>>,
TError,
{ id: number; data: BodyType<AddUserRoleBody> },
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<UserProfile> => {
return customFetch<UserProfile>(getRemoveUserRoleUrl(id, roleName), {
...options,
method: "DELETE",
});
};
export const getRemoveUserRoleMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeUserRole>>,
TError,
{ id: number; roleName: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof removeUserRole>>,
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<ReturnType<typeof removeUserRole>>,
{ id: number; roleName: string }
> = (props) => {
const { id, roleName } = props ?? {};
return removeUserRole(id, roleName, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RemoveUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof removeUserRole>>
>;
export type RemoveUserRoleMutationError = ErrorType<ErrorResponse>;
/**
* @summary Idempotently remove a role from a user (admin)
*/
export const useRemoveUserRole = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeUserRole>>,
TError,
{ id: number; roleName: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof removeUserRole>>,
TError,
{ id: number; roleName: string },
TContext
> => {
return useMutation(getRemoveUserRoleMutationOptions(options));
};
/**
* @summary Get home screen stats
*/
+78
View File
@@ -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:
+63
View File
@@ -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
*/