From e7752b94e61b6f3f550f691e3b390f4dab34af51 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 22:10:17 +0500 Subject: [PATCH] Roles: consolidate the hand-made Manager role; Title Case role names Migration 027 moves live members of the UI-created Manager role onto the seeded hiring_manager (which has carried the same manager_candidates bundle since 024) and soft-deletes it - guarded and idempotent, the seeded role is never matched. Access Control no longer lists candidate: it is the applicant account type every candidate user sits on, managed nowhere near a permission matrix. The row stays in the DB. New lib/format.js formatRole renders snake_case role names and ALL-CAPS status enums as Title Case everywhere users see them - Access Control, Settings user badges and role picker, the topbar profile, Candidates role column and export, and the inbox application-status badges (INTERVIEW -> Interview). Co-Authored-By: Claude Fable 5 --- .../manual/027_consolidate_manager_role.sql | 29 +++++++++++++++++++ frontend/src/app/Topbar.jsx | 3 +- frontend/src/lib/format.js | 11 +++++++ frontend/src/screens/Candidates.jsx | 5 ++-- frontend/src/screens/Inbox.jsx | 5 ++-- frontend/src/screens/Rbac.jsx | 15 ++++++---- frontend/src/screens/Settings.jsx | 11 +++---- 7 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/manual/027_consolidate_manager_role.sql create mode 100644 frontend/src/lib/format.js diff --git a/backend/migrations/manual/027_consolidate_manager_role.sql b/backend/migrations/manual/027_consolidate_manager_role.sql new file mode 100644 index 0000000..f674007 --- /dev/null +++ b/backend/migrations/manual/027_consolidate_manager_role.sql @@ -0,0 +1,29 @@ +-- 027: The hand-made Access Control role "Manager" duplicates the seeded +-- hiring_manager, which has carried the manager_candidates bundle since 024. +-- Consolidate: move its live members onto hiring_manager, then soft-delete +-- it. Both steps are idempotent and guarded; the seeded hiring_manager row +-- is never matched (name check excludes it, and it is is_system). +UPDATE app.users u +SET role_id = hm.id, + updated_at = NOW() +FROM app.roles hm +WHERE hm.role_name = 'hiring_manager' AND hm.is_deleted = FALSE + AND u.role_id IN ( + SELECT r.id FROM app.roles r + WHERE lower(r.role_name) = 'manager' + AND r.role_name <> 'hiring_manager' + AND r.is_deleted = FALSE + ) + AND COALESCE(u.is_deleted, FALSE) = FALSE; + +UPDATE app.roles r +SET is_deleted = TRUE, + is_active = FALSE, + updated_at = NOW() +WHERE lower(r.role_name) = 'manager' + AND r.role_name <> 'hiring_manager' + AND r.is_deleted = FALSE + AND NOT EXISTS ( + SELECT 1 FROM app.users u + WHERE u.role_id = r.id AND COALESCE(u.is_deleted, FALSE) = FALSE + ); diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx index 1256fd2..4d36506 100644 --- a/frontend/src/app/Topbar.jsx +++ b/frontend/src/app/Topbar.jsx @@ -10,6 +10,7 @@ import { useTheme } from '../theme/ThemeProvider' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' +import { formatRole } from '../lib/format' import * as notificationsApi from '../api/notifications' function initialsFromName(name) { @@ -55,7 +56,7 @@ export default function Topbar({ onOpenNav, searchRef }) { const name = user?.name || 'Guest' const email = user?.email || '' - const role = user?.role_name || user?.role || 'Member' + const role = formatRole(user?.role_name || user?.role) || 'Member' function openNotif(n) { if (n.unread) markOne.mutate(n.id) diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js new file mode 100644 index 0000000..97ec6a7 --- /dev/null +++ b/frontend/src/lib/format.js @@ -0,0 +1,11 @@ +/** Display formatting for identifiers stored snake_case in the DB. */ + +/** 'system_administrator' → 'System Administrator', 'INTERVIEW' → 'Interview'. */ +export function formatRole(name) { + const s = String(name || '').trim() + if (!s) return s + return s + .split(/[_\s]+/) + .map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w)) + .join(' ') +} diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 88ba844..f0b6972 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -24,6 +24,7 @@ import CandidateProfile from './CandidateProfile' import { useJobTitles } from './ScoredCandidateProfile' import { qk } from '../lib/queryKeys' import { exportStyledXlsx } from '../lib/exportXlsx' +import { formatRole } from '../lib/format' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' @@ -460,7 +461,7 @@ function RecruiterCandidates() { { header: 'Account', key: 'account', width: 12 }, ], rows: rows.map((c) => ({ - name: c.name, email: c.email, role: c.roleName, + name: c.name, email: c.email, role: formatRole(c.roleName), applied: c.applied ? c.applied.toLocaleDateString() : '', source: c.source, account: c.isActive ? 'Active' : 'Unconfirmed', @@ -574,7 +575,7 @@ function RecruiterCandidates() { Form )} -
{c.roleName ?? '—'}
+
{formatRole(c.roleName) || '—'}
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 226d1fa..988596f 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -26,6 +26,7 @@ import { useAuth } from '../auth/AuthContext' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { exportStyledXlsx } from '../lib/exportXlsx' +import { formatRole } from '../lib/format' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' import * as sheetApi from '../api/sheet' @@ -1327,7 +1328,7 @@ export default function Inbox() {
{i.processing} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( - {i.applicationStatus} + {formatRole(i.applicationStatus)} )} {i.kind === 'form' && i.residingCity && ( {i.residingCity} @@ -1924,7 +1925,7 @@ function ApplicationDetail({ {i.processing}{' '} {i.duplicate && <>Duplicate{' '}} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( - <>{i.applicationStatus}{' '} + <>{formatRole(i.applicationStatus)}{' '} )} {i.resumeStatus} diff --git a/frontend/src/screens/Rbac.jsx b/frontend/src/screens/Rbac.jsx index ecbe1b0..02c1fc5 100644 --- a/frontend/src/screens/Rbac.jsx +++ b/frontend/src/screens/Rbac.jsx @@ -29,6 +29,7 @@ import { useToast } from '../ui/Toast' import { useFormState } from '../components/AuthLayout' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' +import { formatRole } from '../lib/format' import * as rolesApi from '../api/roles' const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)'] @@ -69,7 +70,9 @@ export default function Rbac() { queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []), }) - const roles = rolesQuery.data ?? [] + // `candidate` is the applicant account type, not a staff role — it stays in + // the DB (every candidate user sits on it) but is not managed on this screen. + const roles = (rolesQuery.data ?? []).filter((r) => r.role_name !== 'candidate') const tags = tagsQuery.data ?? [] const bundles = bundlesQuery.data ?? [] @@ -178,7 +181,7 @@ export default function Rbac() {
-
{r.role_name}
+
{formatRole(r.role_name)}
{(r.effective_permissions?.length ?? 0)} permissions {r.is_system ? ' · system' : ''} @@ -199,7 +202,7 @@ export default function Rbac() {
-

{role.role_name}

+

{formatRole(role.role_name)}

{role.description || 'No description'}
@@ -300,7 +303,7 @@ export default function Rbac() { {editing && ( setConfirmDelete(null)} footer={ <> @@ -331,7 +334,7 @@ export default function Rbac() { } >

- {confirmDelete.role_name} will be soft-deleted. Anyone currently holding it keeps the + {formatRole(confirmDelete.role_name)} will be soft-deleted. Anyone currently holding it keeps the account but loses every permission the role granted, so reassign them first.

diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index 0a8161e..2a4fa2f 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -18,6 +18,7 @@ import { useFormState } from '../components/AuthLayout' import { usePermission } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' +import { formatRole } from '../lib/format' import * as rolesApi from '../api/roles' import * as usersApi from '../api/users' import * as orgSettingsApi from '../api/orgSettings' @@ -324,7 +325,7 @@ function Users() {
- {u.role_name || 'No role'} + {formatRole(u.role_name) || 'No role'} {!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'} @@ -425,7 +426,7 @@ function Approvals() { - {u.role_name || 'No role'} + {formatRole(u.role_name) || 'No role'} {u.created_at ? String(u.created_at).slice(0, 10) : '—'}