Departments module
The org-structure surface: department CRUD, headship rules, staff membership changes with email fan-out, and the staff-page request feed. Backend split between ethica-api/src/leaves/leave-settings.service.ts (department CRUD — the entity predates the page and its service, DTO and ERP client live in the leave module) and src/staff/ (membership change events, the request feed); ERP under ethica-erp/app/(auth)/u/admin/departments/.
No migrations of its own — departments is migration 0073 (leave module), and the permission is boot-seeded.
Business-facing guide: Departments.
Data model
departments (0073, tenant-scoped + RLS): name (composite-unique per org), supervisorStaffId (nullable FK → staff) = the head, appraisalTemplateId (0080, nullable FK) = the KPI-form assignment. Membership is staff.departmentId — a single column, so membership is 1-1 by construction.
Permission
departments:manage (PERMISSIONS.DEPARTMENTS_MANAGE, group Departments) — seeded to hr via ROLE_SEED (admin short-circuits). Boot seeding is idempotent and back-fills existing roles (syncRolePermissionsFromSeed), so no migration accompanies the key.
The department mutations (POST|PATCH|DELETE staff/leave-settings/departments…) are gated on it; the list read stays on leaves:submit — the staff-edit picker and the leave form need the names. Splitting the key out of leaves:hr_manage is deliberate: departments feed leave and appraisals, so their management desk belongs to neither module.
Headship invariants
All enforced in leave-settings.service.ts:
- 1-1 headship —
resolveSupervisorrefuses a head who already heads another department (the check excludes the department being saved, so re-saving its own head passes). 400 names the other department. - Head ⇒ member —
enrollHeadruns on every create/update that names a non-null head: if the head'sstaff.departmentIddiffers, it is updated (a move, when they sat elsewhere) and astaff.department_changedevent is emitted withasHead: true. Deliberately fires on saves where the seat is unchanged too, so a head predating auto-enrollment is healed by the next save. Being replaced as head never un-files anyone. - Delete requires no ties — a seated head (400
remove the department head first, checked before the headcount so the message names the real obstacle) or any members (400 with the count;Staffis hooks/RLS-exempt, so the count spells outorganizationId) block deletion. The ERP additionally runs a three-stage confirm ending in a typeddelete <name>. - Self never reviews self — consumed by the appraisal chain (
resolveChainnulls the subject out) and leave routing (resolveLineManagerIdreturns null on self ⇒ HR). See Appraisals module.
staff.department_changed
Emitted from two places, one payload (src/events/event-types.ts):
StaffService.updateStaff— any PATCH that actually changesdepartmentId(asHead: false). The ERP's department-page Add staff picker and per-row Move both ride this same audited PATCH.LeaveSettingsService.enrollHead— head auto-enrollment (asHead: true).
Both department names are resolved by the emitter inside the request transaction; StaffDepartmentAlertsListener (@DetachedListener, zero DB reads) only picks the template variant — added / moved / removed / head-appointment (asHead, with self-appointment phrasing when actorName === staffName) — and sends staff-department-change.mail.ts via Resend. Needs RESEND_API_KEY; the send is non-throwing.
Staff request feed
GET /staff/:id/requests (staff:view) — StaffRequestsService, a read-model over the four request tables (leave_requests, cash_advance_requests, payment_voucher_requests, retirement_requests) by requestedByStaffId, deliberately not through their module services (those scope lists to their own viewer roles; this feed's viewer is the HR desk with its own gate).
Query params: page (≥1), limit (1–50, default 10), kinds (csv of the four kinds), from/to (DATEONLY bounds on createdAt); malformed input is a named 400 (parseStaffRequestsQuery). Pagination is global across the merge: each included kind is counted (count fires the tenancy hooks) and fetched up to page × limit rows newest-first (ceiling 200), then merged, sorted and sliced. All four tables are policed, so org scoping is the hooks' job.
ERP surfaces
app/(auth)/u/admin/departments/— list +[id]pages,AddStaffModal(whole-org picker; heads excluded — replace-first),MoveStaffModal,DepartmentFormModal,DeleteDepartmentModal(blocked → warn → typed confirm). Department mutations invalidate the["staff"]query too (enrollment changes staff rows).- The head is not repeated in the
[id]staff table — the head card is their listing. app/(auth)/u/staff/[id]— tabbed staff record (Requests / Appraisals / Permissions / Activity log);StaffRequestsSectiondrives the feed's filters and pagination server-side.- Sidebar: Admin → Departments on
departments:manage;PERMISSION_GROUPSgained the matching group for the roles matrix.
Related
- Departments — operations guide
- Appraisals module — the chain the head participates in.