From 19a20cc5a4090ac58cc8add6164db9fe916efb92 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 27 Apr 2026 10:26:24 +0000 Subject: [PATCH] Let admins create and edit roles from the dashboard (Task #82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed: - Added an `is_system` column to the `roles` table (default 0). The built-in roles (admin, user, order_receiver) are flagged as system roles in the seed and via a one-off UPDATE on the existing dev DB. - Created a new `/api/roles` route module with admin-guarded endpoints: - POST /api/roles – create a role (validates name format, rejects duplicates). - PATCH /api/roles/:id – update bilingual descriptions; name is immutable. - DELETE /api/roles/:id – returns 400 for system roles (also defended by name allowlist), 409 with userCount/groupCount when in use, 204 on success. Cleans up role_permissions in a transaction. GET /api/roles was moved out of groups.ts into the new module. - OpenAPI updated with createRole / updateRole / deleteRole operations, isSystem on the Role schema, and CreateRoleBody / UpdateRoleBody / RoleDeletionConflict schemas. Regenerated api-zod and api-client-react. - Added a "Roles" item under the User Management nav group in the admin dashboard, plus a new RolesPanel with search, create dialog, edit dialog (description-only), and delete dialog with conflict messaging. - Bilingual translations added in en.json and ar.json (admin.nav.roles + admin.roles.*). Notes / deviations: - Also marked `order_receiver` as a system role since it is seeded by the system and required by the orders feature, even though the task brief only called out admin and user. - Verified end-to-end via the testing skill: list, create, edit, delete, duplicate-name validation, and protection of system roles all work. Replit-Task-Id: b1187555-be09-4687-a9ae-83b123d908bd --- artifacts/api-server/src/routes/groups.ts | 13 - artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/roles.ts | 128 +++++++ artifacts/tx-os/public/opengraph.jpg | Bin 25885 -> 25890 bytes artifacts/tx-os/src/locales/ar.json | 25 ++ artifacts/tx-os/src/locales/en.json | 25 ++ artifacts/tx-os/src/pages/admin.tsx | 346 +++++++++++++++++- .../src/generated/api.schemas.ts | 23 ++ lib/api-client-react/src/generated/api.ts | 262 +++++++++++++ lib/api-spec/openapi.yaml | 129 +++++++ lib/api-zod/src/generated/api.ts | 39 ++ lib/db/src/schema/roles.ts | 1 + scripts/src/seed.ts | 5 +- 13 files changed, 981 insertions(+), 17 deletions(-) create mode 100644 artifacts/api-server/src/routes/roles.ts diff --git a/artifacts/api-server/src/routes/groups.ts b/artifacts/api-server/src/routes/groups.ts index eec0c13a..9e1915dd 100644 --- a/artifacts/api-server/src/routes/groups.ts +++ b/artifacts/api-server/src/routes/groups.ts @@ -39,19 +39,6 @@ function serializeGroup(g: typeof groupsTable.$inferSelect) { }; } -router.get("/roles", requireAdmin, async (_req, res): Promise => { - const rows = await db.select().from(rolesTable).orderBy(rolesTable.name); - res.json( - rows.map((r) => ({ - id: r.id, - name: r.name, - descriptionAr: r.descriptionAr, - descriptionEn: r.descriptionEn, - createdAt: r.createdAt, - })), - ); -}); - router.get("/groups", requireAdmin, async (req, res): Promise => { const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; const where = q ? ilike(groupsTable.name, `%${q}%`) : undefined; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 3a3ec3c2..c3dd5fcd 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -12,6 +12,7 @@ import storageRouter from "./storage"; import settingsRouter from "./settings"; import notesRouter from "./notes"; import groupsRouter from "./groups"; +import rolesRouter from "./roles"; const router: IRouter = Router(); @@ -28,5 +29,6 @@ router.use(storageRouter); router.use(settingsRouter); router.use(notesRouter); router.use(groupsRouter); +router.use(rolesRouter); export default router; diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts new file mode 100644 index 00000000..6a6a1e25 --- /dev/null +++ b/artifacts/api-server/src/routes/roles.ts @@ -0,0 +1,128 @@ +import { Router, type IRouter } from "express"; +import { eq, sql } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { + rolesTable, + userRolesTable, + groupRolesTable, + rolePermissionsTable, +} from "@workspace/db"; +import { requireAdmin } from "../middlewares/auth"; +import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function serializeRole(r: typeof rolesTable.$inferSelect) { + return { + id: r.id, + name: r.name, + descriptionAr: r.descriptionAr, + descriptionEn: r.descriptionEn, + isSystem: Boolean(r.isSystem), + createdAt: r.createdAt, + }; +} + +router.get("/roles", requireAdmin, async (_req, res): Promise => { + const rows = await db.select().from(rolesTable).orderBy(rolesTable.name); + res.json(rows.map(serializeRole)); +}); + +router.post("/roles", requireAdmin, async (req, res): Promise => { + const parsed = CreateRoleBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const name = parsed.data.name.trim(); + if (!/^[a-z][a-z0-9_]*$/i.test(name)) { + res.status(400).json({ error: "Role name must start with a letter and contain only letters, numbers, or underscores" }); + return; + } + const existing = await db + .select({ id: rolesTable.id }) + .from(rolesTable) + .where(eq(rolesTable.name, name)); + if (existing.length > 0) { + res.status(409).json({ error: "Role name already taken" }); + return; + } + const [role] = await db + .insert(rolesTable) + .values({ + name, + descriptionAr: parsed.data.descriptionAr ?? null, + descriptionEn: parsed.data.descriptionEn ?? null, + }) + .returning(); + res.status(201).json(serializeRole(role)); +}); + +router.patch("/roles/:id", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const parsed = UpdateRoleBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Role not found" }); + return; + } + const updates: Partial = {}; + if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr; + if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn; + if (Object.keys(updates).length > 0) { + await db.update(rolesTable).set(updates).where(eq(rolesTable.id, id)); + } + const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id)); + res.json(serializeRole(role)); +}); + +router.delete("/roles/:id", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const [existing] = await db.select().from(rolesTable).where(eq(rolesTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Role not found" }); + return; + } + const PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]); + if (existing.isSystem || PROTECTED_NAMES.has(existing.name)) { + res.status(400).json({ error: "Cannot delete a system role" }); + return; + } + const [userCountRow] = await db + .select({ count: sql`count(*)::int` }) + .from(userRolesTable) + .where(eq(userRolesTable.roleId, id)); + const [groupCountRow] = await db + .select({ count: sql`count(*)::int` }) + .from(groupRolesTable) + .where(eq(groupRolesTable.roleId, id)); + const userCount = userCountRow?.count ?? 0; + const groupCount = groupCountRow?.count ?? 0; + if (userCount > 0 || groupCount > 0) { + res.status(409).json({ + error: "Role is in use", + userCount, + groupCount, + }); + return; + } + await db.transaction(async (tx) => { + await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id)); + await tx.delete(rolesTable).where(eq(rolesTable.id, id)); + }); + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index d439cdb48f50da2ba41fafb33b38721de2ccd749..41ce5b81284da418ae31f76573b2fc67cd8c4970 100644 GIT binary patch delta 18890 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�P+HbpJBaxu=qsyQhX{Icke7+ zRSY$Bm|P=>r&+3=R^~*I#m(KZuXuQ%QH4^{C-iwaujBMVC~=XEWp+2OV5y>^L5XEH z*|PMy`f*lxhYWAJszYP^Ha=u4O<=rA(r(K`!LT?zc7ifTq%hU^p%&ACB-)Hpr;KR?#Lx7 zp>0-ed$4UCt?qAfe<;8iQ);k)W=u`2jFbp%s>?m_h3gs^KS-ALqN*&y>h0k9%*8ijM3b-7H7kku350W^B}B?pPP*C^~zTf13C zR=pM;BrQ?mVfP@@Wd~+KT-|Z?|!Y=w)iI_fDA|%(nKNdhmGn^whf9 zqKs_Mk39(3EI1PICS(KZ))wNw#W$7GNmFm$5i9+BYwmFbrT5Krvx8^lXF`X#x{Gf< z#nbwej7o`;g;df35Z7bK3drmLk%qH8SG#X%aNS`^j}IbBjM6OIw*w%jz<;wq`=!3R z-c6803*QQ)wey?6Wy<#P5(V%wC~6fi*9RDrP&9bxc;pR`WeG$;^A-%b81i=f%eN?6 z$Xjqr=CyQV!^yttTl2B;eblS60CFXA)l~GfKdtLj5UqL&7g}6(mOw&3wc-`OEDIdB zqc73Nxq;0Qx+M$?07ER6nNU=R09FY2`P3~(GN3T^%WOhk%OR7i!XdQYLqggotK;nM zBk%dqdf6DrrQo-X{N7Tu5ZcHgLed=>$0`uMHYSr>%20QBYIp5sQkya-h@VRH$99+A z#gkT#ex?W4@)j9Hbvfk@c=r|GRh>HcqT-Z|vBf1QwpAsG$Dr?`xxPU~B~iM(^3Usk S=v$UovJqQ7d!)Jc`u{(~9D?!y delta 18900 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 diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 2692ad0f..cbeb4944 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -356,6 +356,7 @@ "services": "الخدمات", "users": "المستخدمين", "groups": "المجموعات", + "roles": "الأدوار", "userManagement": "إدارة المستخدمين", "settings": "الإعدادات", "menu": "القائمة" @@ -381,6 +382,30 @@ "actions": "إجراءات" } }, + "roles": { + "searchPlaceholder": "ابحث في الأدوار...", + "addRole": "إضافة دور", + "editRole": "تعديل الدور", + "name": "الاسم", + "namePlaceholder": "مثال: content_editor", + "nameHint": "حروف وأرقام وشرطات سفلية فقط. لا يمكن تغييره لاحقاً.", + "descriptionAr": "الوصف (عربي)", + "descriptionEn": "الوصف (إنجليزي)", + "system": "نظامي", + "empty": "لا توجد أدوار بعد.", + "edit": "تعديل الدور", + "delete": "حذف الدور", + "editHint": "لا يمكن تغيير اسم الدور. يمكنك تحديث الأوصاف بالعربية والإنجليزية.", + "deleteTitle": "حذف الدور \"{{name}}\"؟", + "deleteEmptyBody": "هذا الدور غير مُعيَّن لأي مستخدم. هل تريد المتابعة؟", + "deleteConflict": "هذا الدور قيد الاستخدام ولا يمكن حذفه.", + "deleteConflictHint": "أزل هذا الدور من المستخدمين والمجموعات قبل حذفه.", + "deleteConfirm": "حذف", + "usersCount": "{{count}} مستخدم", + "groupsCount": "{{count}} مجموعة", + "errorNameTaken": "يوجد دور بنفس الاسم.", + "errorInvalidName": "يجب أن يبدأ اسم الدور بحرف ويحتوي على حروف وأرقام وشرطات سفلية فقط." + }, "groups": { "searchPlaceholder": "ابحث في المجموعات...", "addGroup": "إضافة مجموعة", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index e4bd556d..88156bb9 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -353,6 +353,7 @@ "services": "Services", "users": "Users", "groups": "Groups", + "roles": "Roles", "userManagement": "User Management", "settings": "Settings", "menu": "Menu" @@ -378,6 +379,30 @@ "actions": "Actions" } }, + "roles": { + "searchPlaceholder": "Search roles...", + "addRole": "Add Role", + "editRole": "Edit Role", + "name": "Name", + "namePlaceholder": "e.g. content_editor", + "nameHint": "Letters, numbers and underscores only. Cannot be changed later.", + "descriptionAr": "Description (Arabic)", + "descriptionEn": "Description (English)", + "system": "System", + "empty": "No roles yet.", + "edit": "Edit role", + "delete": "Delete role", + "editHint": "Role names cannot be changed. You can update the bilingual descriptions.", + "deleteTitle": "Delete role \"{{name}}\"?", + "deleteEmptyBody": "This role isn't assigned to anyone. Continue?", + "deleteConflict": "This role is still in use and cannot be deleted.", + "deleteConflictHint": "Remove this role from the affected users and groups before deleting it.", + "deleteConfirm": "Delete", + "usersCount": "{{count}} users", + "groupsCount": "{{count}} groups", + "errorNameTaken": "A role with that name already exists.", + "errorInvalidName": "Role name must start with a letter and contain only letters, numbers, or underscores." + }, "groups": { "searchPlaceholder": "Search groups...", "addGroup": "Add Group", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 38fc3649..65c578bb 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -40,6 +40,9 @@ import { useDeleteGroup, useListRoles, getListRolesQueryKey, + useCreateRole, + useUpdateRole, + useDeleteRole, ApiError, } from "@workspace/api-client-react"; import type { @@ -72,7 +75,7 @@ import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format"; -type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "settings"; +type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "settings"; function LeaderboardAvatar({ src, @@ -266,6 +269,7 @@ export default function AdminPage() { children: [ { key: "users", label: t("admin.nav.users"), Icon: UsersIcon }, { key: "groups", label: t("admin.nav.groups"), Icon: Shield }, + { key: "roles", label: t("admin.nav.roles"), Icon: KeyRound }, ], }, { key: "settings", label: t("admin.nav.settings"), Icon: Settings }, @@ -278,7 +282,7 @@ export default function AdminPage() { const sectionMatch = hash.match(/section=([a-z-]+)/); if (sectionMatch) { const s = sectionMatch[1] as AdminSection; - if (["dashboard", "apps", "services", "users", "groups", "settings"].includes(s)) { + if (["dashboard", "apps", "services", "users", "groups", "roles", "settings"].includes(s)) { setSection(s); } } @@ -792,6 +796,7 @@ export default function AdminPage() { /> )} {section === "groups" && } + {section === "roles" && } {section === "settings" && (
@@ -2892,3 +2897,340 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
); } + +function RolesPanel() { + const { t } = useTranslation(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } }); + const createRole = useCreateRole(); + const updateRole = useUpdateRole(); + const deleteRole = useDeleteRole(); + const [search, setSearch] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState({ descriptionAr: "", descriptionEn: "" }); + const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null); + const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() }); + + const visibleRoles = (roles ?? []).filter((r) => { + const q = search.trim().toLowerCase(); + if (!q) return true; + return ( + r.name.toLowerCase().includes(q) || + (r.descriptionAr ?? "").toLowerCase().includes(q) || + (r.descriptionEn ?? "").toLowerCase().includes(q) + ); + }); + + const handleCreate = () => { + const name = createForm.name.trim(); + if (!name) return; + createRole.mutate( + { + data: { + name, + descriptionAr: createForm.descriptionAr.trim() || null, + descriptionEn: createForm.descriptionEn.trim() || null, + }, + }, + { + onSuccess: () => { + invalidate(); + setCreateOpen(false); + setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" }); + toast({ title: t("common.success") }); + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409) { + toast({ title: t("admin.roles.errorNameTaken"), variant: "destructive" }); + return; + } + if (err instanceof ApiError && err.status === 400) { + toast({ title: t("admin.roles.errorInvalidName"), variant: "destructive" }); + return; + } + toast({ title: t("common.error"), variant: "destructive" }); + }, + }, + ); + }; + + const beginEdit = (role: { id: number; descriptionAr?: string | null; descriptionEn?: string | null }) => { + setEditingId(role.id); + setEditForm({ + descriptionAr: role.descriptionAr ?? "", + descriptionEn: role.descriptionEn ?? "", + }); + }; + + const handleEdit = () => { + if (editingId == null) return; + updateRole.mutate( + { + id: editingId, + data: { + descriptionAr: editForm.descriptionAr.trim() || null, + descriptionEn: editForm.descriptionEn.trim() || null, + }, + }, + { + onSuccess: () => { + invalidate(); + setEditingId(null); + toast({ title: t("common.success") }); + }, + onError: () => toast({ title: t("common.error"), variant: "destructive" }), + }, + ); + }; + + const handleDelete = () => { + if (!deleteTarget) return; + deleteRole.mutate( + { id: deleteTarget.id }, + { + onSuccess: () => { + invalidate(); + setDeleteTarget(null); + setDeleteConflict(null); + toast({ title: t("common.success") }); + }, + onError: (err: unknown) => { + if (err instanceof ApiError && err.status === 409 && err.data) { + const data = err.data as { userCount: number; groupCount: number }; + setDeleteConflict({ userCount: data.userCount, groupCount: data.groupCount }); + return; + } + toast({ title: t("common.error"), variant: "destructive" }); + setDeleteTarget(null); + }, + }, + ); + }; + + return ( +
+
+ setSearch(e.target.value)} + className="bg-white/70 border-slate-200 flex-1" + data-testid="role-search" + /> + +
+ +
+ {visibleRoles.map((r) => ( +
+
+
+
+ + {r.name} + {r.isSystem && ( + + {t("admin.roles.system")} + + )} +
+ {(r.descriptionAr || r.descriptionEn) && ( +

+ {r.descriptionEn || r.descriptionAr} +

+ )} + {r.descriptionAr && r.descriptionEn && ( +

+ {r.descriptionAr} +

+ )} +
+
+ + {!r.isSystem && ( + + )} +
+
+
+ ))} + {visibleRoles.length === 0 && ( +
+ {t("admin.roles.empty")} +
+ )} +
+ + {createOpen && ( +
+
+

{t("admin.roles.addRole")}

+
+ + setCreateForm({ ...createForm, name: e.target.value })} + className="bg-white/70 border-slate-200" + placeholder={t("admin.roles.namePlaceholder")} + data-testid="role-name-input" + /> +

{t("admin.roles.nameHint")}

+
+
+ + setCreateForm({ ...createForm, descriptionAr: e.target.value })} + className="bg-white/70 border-slate-200" + dir="rtl" + data-testid="role-desc-ar-input" + /> +
+
+ + setCreateForm({ ...createForm, descriptionEn: e.target.value })} + className="bg-white/70 border-slate-200" + dir="ltr" + data-testid="role-desc-en-input" + /> +
+
+ + +
+
+
+ )} + + {editingId != null && ( +
+
+

{t("admin.roles.editRole")}

+

{t("admin.roles.editHint")}

+
+ + setEditForm({ ...editForm, descriptionAr: e.target.value })} + className="bg-white/70 border-slate-200" + dir="rtl" + data-testid="role-edit-desc-ar" + /> +
+
+ + setEditForm({ ...editForm, descriptionEn: e.target.value })} + className="bg-white/70 border-slate-200" + dir="ltr" + data-testid="role-edit-desc-en" + /> +
+
+ + +
+
+
+ )} + + {deleteTarget && ( +
+
+

+ {t("admin.roles.deleteTitle", { name: deleteTarget.name })} +

+ {deleteConflict ? ( + <> +

{t("admin.roles.deleteConflict")}

+
    +
  • {t("admin.roles.usersCount", { count: deleteConflict.userCount })}
  • +
  • {t("admin.roles.groupsCount", { count: deleteConflict.groupCount })}
  • +
+

{t("admin.roles.deleteConflictHint")}

+ + ) : ( +

{t("admin.roles.deleteEmptyBody")}

+ )} +
+ + {!deleteConflict && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 976aa89d..e8b0ca7c 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -176,9 +176,32 @@ export interface Role { descriptionAr?: string | null; /** @nullable */ descriptionEn?: string | null; + isSystem: boolean; createdAt: string; } +export interface CreateRoleBody { + /** @minLength 1 */ + name: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; +} + +export interface UpdateRoleBody { + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; +} + +export interface RoleDeletionConflict { + error: string; + userCount: number; + groupCount: number; +} + export interface Group { id: number; name: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 8b5a4d6e..1614482e 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -30,6 +30,7 @@ import type { CreateAppBody, CreateConversationBody, CreateGroupBody, + CreateRoleBody, CreateServiceBody, CreateServiceOrderBody, CreateUserBody, @@ -56,6 +57,7 @@ import type { RequestUploadUrlResponse, ResetPasswordBody, Role, + RoleDeletionConflict, SendMessageBody, Service, ServiceCategory, @@ -70,6 +72,7 @@ import type { UpdateGroupBody, UpdateLanguageBody, UpdateMyAppOrderBody, + UpdateRoleBody, UpdateServiceBody, UpdateServiceOrderStatusBody, UpdateUserBody, @@ -4741,6 +4744,265 @@ export function useListRoles< return { ...query, queryKey: queryOptions.queryKey }; } +/** + * @summary Create role (admin) + */ +export const getCreateRoleUrl = () => { + return `/api/roles`; +}; + +export const createRole = async ( + createRoleBody: CreateRoleBody, + options?: RequestInit, +): Promise => { + return customFetch(getCreateRoleUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createRoleBody), + }); +}; + +export const getCreateRoleMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["createRole"]; + 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>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return createRole(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateRoleMutationResult = NonNullable< + Awaited> +>; +export type CreateRoleMutationBody = BodyType; +export type CreateRoleMutationError = ErrorType; + +/** + * @summary Create role (admin) + */ +export const useCreateRole = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getCreateRoleMutationOptions(options)); +}; + +/** + * @summary Update role description (admin) + */ +export const getUpdateRoleUrl = (id: number) => { + return `/api/roles/${id}`; +}; + +export const updateRole = async ( + id: number, + updateRoleBody: UpdateRoleBody, + options?: RequestInit, +): Promise => { + return customFetch(getUpdateRoleUrl(id), { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateRoleBody), + }); +}; + +export const getUpdateRoleMutationOptions = < + 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 = ["updateRole"]; + 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 updateRole(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateRoleMutationResult = NonNullable< + Awaited> +>; +export type UpdateRoleMutationBody = BodyType; +export type UpdateRoleMutationError = ErrorType; + +/** + * @summary Update role description (admin) + */ +export const useUpdateRole = < + 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(getUpdateRoleMutationOptions(options)); +}; + +/** + * @summary Delete role (admin) + */ +export const getDeleteRoleUrl = (id: number) => { + return `/api/roles/${id}`; +}; + +export const deleteRole = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getDeleteRoleUrl(id), { + ...options, + method: "DELETE", + }); +}; + +export const getDeleteRoleMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext +> => { + const mutationKey = ["deleteRole"]; + 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 } + > = (props) => { + const { id } = props ?? {}; + + return deleteRole(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteRoleMutationResult = NonNullable< + Awaited> +>; + +export type DeleteRoleMutationError = ErrorType< + ErrorResponse | RoleDeletionConflict +>; + +/** + * @summary Delete role (admin) + */ +export const useDeleteRole = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getDeleteRoleMutationOptions(options)); +}; + /** * @summary List groups (admin) */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 11699739..b215c169 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1258,6 +1258,97 @@ paths: type: array items: $ref: "#/components/schemas/Role" + post: + operationId: createRole + tags: [users] + summary: Create role (admin) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateRoleBody" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Name already taken + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /roles/{id}: + patch: + operationId: updateRole + tags: [users] + summary: Update role description (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRoleBody" + responses: + "200": + description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: deleteRole + tags: [users] + summary: Delete role (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "204": + description: Deleted + "400": + description: Cannot delete a system role + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Role is in use + content: + application/json: + schema: + $ref: "#/components/schemas/RoleDeletionConflict" # Groups (admin) /groups: @@ -1869,14 +1960,52 @@ components: type: ["string", "null"] descriptionEn: type: ["string", "null"] + isSystem: + type: boolean createdAt: type: string format: date-time required: - id - name + - isSystem - createdAt + CreateRoleBody: + type: object + properties: + name: + type: string + minLength: 1 + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + required: + - name + + UpdateRoleBody: + type: object + properties: + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + + RoleDeletionConflict: + type: object + properties: + error: + type: string + userCount: + type: integer + groupCount: + type: integer + required: + - error + - userCount + - groupCount + GroupSummary: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 126ded34..6e4e7ad3 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -1598,10 +1598,49 @@ export const ListRolesResponseItem = zod.object({ name: zod.string(), descriptionAr: zod.string().nullish(), descriptionEn: zod.string().nullish(), + isSystem: zod.boolean(), createdAt: zod.coerce.date(), }); export const ListRolesResponse = zod.array(ListRolesResponseItem); +/** + * @summary Create role (admin) + */ + +export const CreateRoleBody = zod.object({ + name: zod.string().min(1), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), +}); + +/** + * @summary Update role description (admin) + */ +export const UpdateRoleParams = zod.object({ + id: zod.coerce.number(), +}); + +export const UpdateRoleBody = zod.object({ + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), +}); + +export const UpdateRoleResponse = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + isSystem: zod.boolean(), + createdAt: zod.coerce.date(), +}); + +/** + * @summary Delete role (admin) + */ +export const DeleteRoleParams = zod.object({ + id: zod.coerce.number(), +}); + /** * @summary List groups (admin) */ diff --git a/lib/db/src/schema/roles.ts b/lib/db/src/schema/roles.ts index f8131e5b..b7c0bd67 100644 --- a/lib/db/src/schema/roles.ts +++ b/lib/db/src/schema/roles.ts @@ -8,6 +8,7 @@ export const rolesTable = pgTable("roles", { name: varchar("name", { length: 100 }).notNull().unique(), descriptionAr: text("description_ar"), descriptionEn: text("description_en"), + isSystem: integer("is_system").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index 4df24621..1f85905d 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -23,13 +23,13 @@ async function main() { // Create roles const [adminRole] = await db .insert(rolesTable) - .values({ name: "admin", descriptionAr: "مدير النظام", descriptionEn: "System Administrator" }) + .values({ name: "admin", descriptionAr: "مدير النظام", descriptionEn: "System Administrator", isSystem: 1 }) .onConflictDoNothing() .returning(); const [userRole] = await db .insert(rolesTable) - .values({ name: "user", descriptionAr: "مستخدم عادي", descriptionEn: "Regular User" }) + .values({ name: "user", descriptionAr: "مستخدم عادي", descriptionEn: "Regular User", isSystem: 1 }) .onConflictDoNothing() .returning(); @@ -56,6 +56,7 @@ async function main() { name: "order_receiver", descriptionAr: "مستلم الطلبات", descriptionEn: "Order Receiver", + isSystem: 1, }) .onConflictDoNothing() .returning();