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-1 headshipresolveSupervisor refuses 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.
  2. Head ⇒ memberenrollHead runs on every create/update that names a non-null head: if the head's staff.departmentId differs, it is updated (a move, when they sat elsewhere) and a staff.department_changed event is emitted with asHead: 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.
  3. 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; Staff is hooks/RLS-exempt, so the count spells out organizationId) block deletion. The ERP additionally runs a three-stage confirm ending in a typed delete <name>.
  4. Self never reviews self — consumed by the appraisal chain (resolveChain nulls the subject out) and leave routing (resolveLineManagerId returns 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 changes departmentId (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); StaffRequestsSection drives the feed's filters and pagination server-side.
  • Sidebar: Admin → Departments on departments:manage; PERMISSION_GROUPS gained the matching group for the roles matrix.