Department — the organisational grouping for employees
The whole department model reduces to one idea: a
Departmentis a flat, named bucket with one optional "head" (hodId→ aUser). Employees point at it viaEmployee.departmentId; the department itself holds no list of members and no parent. It is a lookup/reference entity — the org-chart, dashboard, and approval-targeting layers read it; the department module itself only does CRUD.
Source: BE src/modules/hr/department · Admin src/modules/department (routed under /hr/department)
1. Purpose & scope
The department module owns the list of departments in a company and CRUD over them. It is responsible for:
- Creating / updating / deleting (soft) departments.
- Assigning an optional Head of Department (
hodId). - Paginated listing + keyword search, and an XLSX report export.
- Providing the department records that other modules group against by
Employee.departmentId.
It explicitly does not:
- Hold a hierarchy. There is no
parentId/ parent-department field — departments are a flat list, not a tree. (The "org chart" hierarchy is built fromEmployee.reportingTo, not from department nesting — see org-chart.) - Hold a cost-centre reference. There is no cost-centre / GL / finance field on the schema despite the module importing
AccountModule(the import is unused by the department code path). See §9. - Store its members. Membership is one-directional: employees reference the department, never the reverse. The "employees in this department" list is computed by querying
employeePage({ departmentId })(admin) or aggregating in org-chart. - Compute anything (headcount, present/absent) — those
DepartmentMetricsare produced by the dashboard, reading the department list + employee/attendance data.
2. Data model
2.1 departments collection
Schema: hr/department/department.schema.ts — class Department extends BaseSchema, decorated @ApSchema({ collection: 'departments' }). Note no hr_ prefix (same as employees; unlike calendar/leave/etc. which use @HrSchema → hr_*). Soft-delete via mongoose-delete (deletedAt, deletedBy).
BaseSchema (core/database/database.scheme.ts) contributes the common envelope on every row: _id, companyId, branchId (both indexed, multi-tenant scoping), documentCode, documentDate, createdAt/By, updatedAt/By, deletedAt/By, deleted, canUpdate/View/Delete/Post, client (default "zerp"), refId/ref2Id. All *Id setters coerce 24-char hex strings to ObjectId via BaseSchema.toObjectId.
| field | type | required | description |
|---|---|---|---|
ref |
string | yes, unique |
Department code / unique identifier. Marked required + unique on the schema. Not in any GraphQL input DTO (see §9) — so it is not user-supplied via the standard create/update path. |
name |
string | — | Display name (e.g. "Finance", "Operations"). The Yup-required field in the admin form, but the schema itself does not mark it required. |
hodId |
ObjectId → users |
— | Head of Department. Points at a User, not an Employee. Resolved by the hod resolve-field via UserService.findById. Coerced to ObjectId by the setter. |
The full schema (verbatim):
// hr/department/department.schema.ts
@ApSchema({ collection: "departments" })
export class Department extends BaseSchema {
@Prop({ unique: true, required: true })
ref: string;
@Prop()
name: string;
@Prop({ set: (v: string) => BaseSchema.toObjectId(v) })
hodId: Types.ObjectId;
}
export const DepartmentSchema = SchemaFactory.createForClass(Department);
DepartmentSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true });That is the entire persisted shape:
ref,name,hodId+ theBaseSchemaenvelope. No parent, no cost-centre, no member list, no status.
2.2 Enums
None. The department schema has no enum fields.
2.3 GraphQL Department object type
The GraphQL Department (department.dto.ts, generated into schema.gql lines 2410–2429) exposes the envelope fields plus:
type Department {
_id: String
companyId: String
branchId: String
ref: String
documentDate: Float
createdAt: Float
updatedAt: Float
createdBy: String
updatedBy: String
canDelete: Boolean
canUpdate: Boolean
canView: Boolean
canPost: Boolean
name: String
hodId: String
hod: User # ← resolve-field: full User behind hodId
}
hod: Useris a resolve-field (@ResolveField hod→userSvc.findById(args.hodId)), so a singledepartmentPage/findOneDepartmentquery returns the head's full user object inline.
2.4 Relationships
User (users) Department (departments) Employee (employees)
_id ◀──── hodId ──────────── hodId (FK, optional)
_id ◀──────────────────────── departmentId (FK, optional)
ref, name
hodId→users(one optional head, a User).Employee.departmentId→departmentsis the only link that ties a person to a department; it is owned by the employee side. The department never stores who belongs to it.- Multi-tenant: scoped by
companyId.branchIdis stamped from the creating user's context (see §4).
3. API surface
GraphQL (department.resolver.ts)
The resolver extends ApBaseResolver<Department>. The whole resolver is guarded by @UseGuards(GqlFeatureGuard) + @RequireFeature('HR_MODULE') + @ApGqlAuthorize() (each mutation/query re-declares @ApGqlAuthorize()). All three mutations carry @AuditMeta (audit-trail snapshots).
| Operation | Type | Input | Returns | Permission / notes |
|---|---|---|---|---|
createDepartment |
Mutation | department: CreateDepartmentInput |
Department |
HR feature gate; Audit CREATE. Stamps branchId from context. |
updateDepartment |
Mutation | id: String, department: UpdateDepartmentInput |
Department |
HR feature gate; Audit UPDATE. |
deleteDepartment |
Mutation | id: String |
Boolean |
Soft-delete; Audit DELETE. Always returns true. |
findOneDepartment |
Query | department: DepartmentQueryInput { _id?, name? } |
Department |
Single lookup by id or name. |
departmentPage |
Query | page: DepartmentPageInput { skip, take, keyword? } |
DepartmentPageResult { totalRecords, data[] } |
Paginated list + keyword search. |
hod |
ResolveField | (parent Department) |
User |
userSvc.findById(hodId). |
Input DTOs (department.dto.ts):
@InputType() class DepartmentCommonInput {
name: string; // @Field({ nullable: true })
hodId: string; // @Field({ nullable: true })
}
@InputType() class CreateDepartmentInput extends DepartmentCommonInput {}
@InputType() class UpdateDepartmentInput extends PartialType(DepartmentCommonInput) {}
@InputType() class DepartmentQueryInput { _id?: string; name?: string; }
@InputType() class DepartmentPageInput { skip: number; take: number; keyword?: string; }Only
nameandhodIdare settable via GraphQL.ref(schema-required + unique) is not in any input — see §9.
REST (department.controller.ts)
| Method | Route | Query | Response | Auth |
|---|---|---|---|---|
GET |
/api/department/download |
downloadType=xlsx + report filters (keyword, …; page/pageSize stripped) |
Streams an XLSX "Department Report" with columns Name, Hod (head's user name), Document Date (DD/MM/YYYY). |
@ApiAuthorize() |
The controller finds all matching departments, then Promise.all-resolves each hodId to a user via userSvc.findById to fill the Hod column. (Contains a leftover console.log("QR:", qr) debug line — see §9.)
4. Business rules & calculations
This is a thin CRUD module — there are no formulas, no state machine, no totals.
4.1 Validation
- Service / schema:
refisunique+requiredat the Mongo level;hodIdcoerced toObjectId. Noclass-validatordecorators on the inputs (name/hodIdare simply nullable). - Admin (Yup):
nameis required ('Please fill in the name');hodis optional. This is the only enforced required field in practice, sincerefis never sent.
4.2 Create
DepartmentService.create (department.service.ts) is the only overridden method:
public async create(model: Department): Promise<Department> {
model.branchId = this.contextSvc.user?.branchId; // stamp branch from caller's context
return await this.departmentRepo.create(model);
}branchIdis always overwritten with the current user's branch — you cannot create a department for another branch via the API.companyIdis applied by the base repository / tenant scoping.- Everything else (
update,delete,findOne,page) is inherited fromAbstractBaseService→AbstractBaseRepository;pageuses thehandlePageFacet/handlePageResultfacet pattern in the schema file.
4.3 Status / lifecycle
There is no status field and no state machine. A department simply exists until soft-deleted:
created ──▶ (editable) ──▶ deleted (soft-delete)
- Deleted:
deleteDepartmentsoft-deletes viamongoose-delete; the row is excluded from all reads (deleted: { $ne: true }in queries). - No referential-integrity guard: deleting a department does not check for or clear
Employee.departmentIdreferences. Employees keep a danglingdepartmentId; the org-chart groups any employee whosedepartmentIdno longer matches a live department under "Unassigned". Flag (§9).
4.4 Side effects on write
- Audit-trail snapshots on create / update / delete (
@AuditMeta module:"department", collection:"departments"). - No events emitted, no GL legs, no other collections written.
4.5 Transactionality
create is a single repo.create (no explicit Mongo session). update/delete are single base-repository operations. Nothing here is multi-document, so no transaction is needed.
5. Permissions
- Feature gate:
@RequireFeature('HR_MODULE')+GqlFeatureGuard— the company's subscription must include the HR module. See subscription-config (referenced the same way as employee §5). - Auth:
@ApGqlAuthorize()(JWT) on the whole resolver and every operation. - RBAC (admin-side): the "Add department" button is gated by
USER_ACCESS.USER_MAINTENANCE.MODULE/ACTIONS.CREATE_DEPARTMENT— note it lives under the User Maintenance permission module, not an HR-specific permission. Row-level edit/delete are gated by the per-rowcanUpdate/canDeleteflags fromBaseSchema. See permissions-access.
6. Flows
6.1 Create / update department (happy path)
Admin /hr/department → "Add department" (perm: USER_MAINTENANCE.CREATE_DEPARTMENT)
→ ApModal → CreateDepartment (Formik)
fields: name (required), HOD (async user select → val.user._id)
→ onSubmit: payload = { name, hodId: user._id } (the `user` object is stripped)
→ GQL createDepartment(department: CreateDepartmentInput) [or updateDepartment(id, ...)]
→ DepartmentResolver.create
→ DepartmentService.create → branchId = ctx.user.branchId → departmentRepo.create
→ audit CREATE snapshot
← Department (with resolved `hod` user)
→ context: toast success, prepend to list + refetch page 1
6.2 List + search
DepartmentPage mount → fetchDepartmentPage({ page, pageSize, keyword })
→ departmentPage({ skip, take, keyword }) → DepartmentResolver.page → repo.page (facet)
← { totalRecords, data[] (each with resolved hod) }
→ ApTable: columns Name (link → /hr/department/[id]), HOD (hod.name), Actions
6.3 Department detail
/hr/department/[id] → DepartmentDetailPage
→ findOneDepartment({ _id }) → header (name, "Led by <hod>", created date) + Overview / HOD-Contact cards
→ fetchDepartmentEmployees(deptId,…) → employeePage({ departmentId, skip, take })
→ table of members (name, email, phone, position, type, joined)
6.4 Download report
"Download Report" (XLSX) → GET /api/department/download?downloadType=xlsx&keyword=…
→ controller.find(qr) → for each: resolve hod via userSvc.findById
→ XLSX rows { Name, Hod, Document Date } → "Department Report" stream
6.5 Unhappy paths
- Missing name → Yup blocks submit client-side (
'Please fill in the name'). The backend would otherwise accept an empty name. - Delete with active members → succeeds (no guard); members'
departmentIdis left dangling and they surface as Unassigned in the org chart. Flag (§9). - Duplicate
ref→ the unique index would reject, but sincerefis never supplied by the API, in practice it is left undefined/empty (potential unique-index pitfall — see §9).
7. Admin UI
| Area | Route | Module |
|---|---|---|
| Department list | /hr/department |
src/modules/department (page.tsx) |
| Department detail + members | /hr/department/[id] |
src/modules/department (detail.tsx) |
Despite living in the
departmentmodule folder, it is routed under/hr/department(all internal links use/hr/department/...).
Context methods (src/modules/department/context.tsx, consumed via useDepartmentState())
fetchDepartmentPage, createDepartment, updateDepartment, deleteDepartment, findOneDepartment, fetchDepartmentEmployees. State: departments, department, loading, totalRecords, modal. GraphQL ops in gql/query.ts: DEPARTMENT_PAGE, CREATE_DEPARTMENT, UPDATE_DEPARTMENT, DELETE_DEPARTMENT, FIND_DEPARTMENT, plus a local EMPLOYEE_PAGE query (members). All useLazyQuery are fetchPolicy: 'no-cache'; errors routed through toastSvc.graphQlError.
List page (page.tsx)
ApTable with columns Name (link → detail), HOD (hod?.name), Actions (delete + edit row icons, gated by canDelete/canUpdate). ApPageHeader with Add department (perm-gated) + Download Report (ApDownloadButton2, PDF placeholder + XLSX department/download). ApSearchInput drives the keyword filter (debounced via filter state effect).
Create/edit form (components/create.tsx)
One Formik form (CreateDepartment) reused for create + update (modal title "<type> Department"):
- name —
ApTextInput, Yup-required. - HOD —
ApSelectInputAsyncover users (fetchUserPage,defaultOptions, label = user email, value =_id). On submit,hodIdis set fromval.user._idand the nesteduserobject is deleted from the payload. (Thecreateable/onCreateOptioninline-create flow is present but commented out.)
Detail page (detail.tsx)
Header (back link, department name, "Led by InfoCards: Overview (Department Name, Head of Department) and HOD Contact (email, phone — only shown if a HOD is set). Then an Employees (N) ApTable (paginated, columns name→/hr/employees/[id], email, phone, position, employment type, joined, view-detail action) fed by fetchDepartmentEmployees.
There are also two reusable selects shipped in the module for other features to pick a department:
components/select.tsxandcomponents/async-select.tsx.
Frontend model (model.ts)
export interface IDepartment {
_id: string; name?: string; hodId?: string; hod?: IUser;
createdAt?: string; canUpdate?: boolean; canDelete?: boolean;
}8. Dependencies & integrations
Department depends on / calls:
UserModule(UserService) — resolveshodId→User(thehodresolve-field and the XLSX report). Hard dependency.SubscriptionModule(feature gate),AuthModule,AuditLogModule,ApConfigModule. Also importsAccountModule(finance) in the module file — unused by the department code path (boilerplate import). Flag (§9).
Consumed by (reading the department / departmentId):
- employee —
Employee.departmentIdis the membership link; the employee form picks a department. - org-chart —
OrgChartService.getTree()loads all departments and groups employees under them bydepartmentId, attaching thehodnode; unmatched employees → "Unassigned". - dashboard —
DepartmentMetrics(headcount / present / on-leave / absent today) computed per department;PayrollCompletionByDepartmentfor timesheet/payroll completion. - training — training assignments can target by
departmentId. - approvals / approval-policy —
resolveByTargetscan target a department.
Events / cron / external: none.
9. Gotchas & project-specific rules
- No hierarchy. There is no parent-department field — departments are a flat list. Any "org tree" comes from
Employee.reportingTo(org-chart), and the only department grouping is the single-level "department → its members" view. If you need nested departments you must add aparentId. - No cost-centre link. Despite the brief's mention and the unused
AccountModuleimport, the schema has no cost-centre / finance reference. Cost allocation, if needed, would have to be added. Confirmed absent indepartment.schema.ts. - HOD is a
User, not anEmployee.hodId → users, resolved withuserSvc.findById. Every other HR "manager"-style link (reportingTo,hrId) is anEmployee._id. The department head is the exception — don't pass anEmployee._id. refis schema-required + unique but never set by the API. No input DTO carriesref, and the service doesn't generate one. New departments are created withrefundefined/empty — relying on the unique index tolerating it (sparse-ish). Creating a second one the same way could collide on the unique index. If you port this, either droprequired:trueonrefor generate it (e.g. like other docs'documentCode/refgeneration). Confirmed: standard create path cannot setref.- Collection name has no
hr_prefix —departments(andemployees) use@ApSchema; calendar/leave/etc. use@HrSchema→hr_*. Don't assume a uniform prefix. - No delete guard for departments with members. Deleting orphans every
Employee.departmentIdpointing at it; those employees appear under "Unassigned" in the org chart. There is no reassignment or block. - Permission is
USER_MAINTENANCE, not HR-specific. The "Add department" button checksUSER_ACCESS.USER_MAINTENANCE.CREATE_DEPARTMENT, even though the feature gate isHR_MODULE. Permission module and feature gate are decoupled here. - Leftover debug log in
department.controller.ts(console.log("QR:", qr)). Harmless but should be removed. - Members are not stored — the "employees in department" list is always a live
employeePage({ departmentId })query; the department row holds no member array or count.