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 d439cdb4..41ce5b81 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ 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();