Department — the organisational grouping for employees

The whole department model reduces to one idea: a Department is a flat, named bucket with one optional "head" (hodId → a User). Employees point at it via Employee.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 from Employee.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 DepartmentMetrics are 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 @HrSchemahr_*). 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 + the BaseSchema envelope. 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: User is a resolve-field (@ResolveField hoduserSvc.findById(args.hodId)), so a single departmentPage/findOneDepartment query 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
  • hodIdusers (one optional head, a User).
  • Employee.departmentIddepartments is 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. branchId is 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 name and hodId are 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: ref is unique + required at the Mongo level; hodId coerced to ObjectId. No class-validator decorators on the inputs (name/hodId are simply nullable).
  • Admin (Yup): name is required ('Please fill in the name'); hod is optional. This is the only enforced required field in practice, since ref is 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);
}
  • branchId is always overwritten with the current user's branch — you cannot create a department for another branch via the API.
  • companyId is applied by the base repository / tenant scoping.
  • Everything else (update, delete, findOne, page) is inherited from AbstractBaseServiceAbstractBaseRepository; page uses the handlePageFacet / handlePageResult facet 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: deleteDepartment soft-deletes via mongoose-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.departmentId references. Employees keep a dangling departmentId; the org-chart groups any employee whose departmentId no 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-row canUpdate / canDelete flags from BaseSchema. 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
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' departmentId is left dangling and they surface as Unassigned in the org chart. Flag (§9).
  • Duplicate ref → the unique index would reject, but since ref is 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 department module 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"):

  • nameApTextInput, Yup-required.
  • HODApSelectInputAsync over users (fetchUserPage, defaultOptions, label = user email, value = _id). On submit, hodId is set from val.user._id and the nested user object is deleted from the payload. (The createable / onCreateOption inline-create flow is present but commented out.)

Detail page (detail.tsx)

Header (back link, department name, "Led by ", created-date pill, HOD summary). Two 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.tsx and components/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) — resolves hodIdUser (the hod resolve-field and the XLSX report). Hard dependency.
  • SubscriptionModule (feature gate), AuthModule, AuditLogModule, ApConfigModule. Also imports AccountModule (finance) in the module file — unused by the department code path (boilerplate import). Flag (§9).

Consumed by (reading the department / departmentId):

  • employeeEmployee.departmentId is the membership link; the employee form picks a department.
  • org-chartOrgChartService.getTree() loads all departments and groups employees under them by departmentId, attaching the hod node; unmatched employees → "Unassigned".
  • dashboardDepartmentMetrics (headcount / present / on-leave / absent today) computed per department; PayrollCompletionByDepartment for timesheet/payroll completion.
  • training — training assignments can target by departmentId.
  • approvals / approval-policy — resolveByTargets can target a department.

Events / cron / external: none.


9. Gotchas & project-specific rules

  1. 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 a parentId.
  2. No cost-centre link. Despite the brief's mention and the unused AccountModule import, the schema has no cost-centre / finance reference. Cost allocation, if needed, would have to be added. Confirmed absent in department.schema.ts.
  3. HOD is a User, not an Employee. hodId → users, resolved with userSvc.findById. Every other HR "manager"-style link (reportingTo, hrId) is an Employee._id. The department head is the exception — don't pass an Employee._id.
  4. ref is schema-required + unique but never set by the API. No input DTO carries ref, and the service doesn't generate one. New departments are created with ref undefined/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 drop required:true on ref or generate it (e.g. like other docs' documentCode/ref generation). Confirmed: standard create path cannot set ref.
  5. Collection name has no hr_ prefixdepartments (and employees) use @ApSchema; calendar/leave/etc. use @HrSchemahr_*. Don't assume a uniform prefix.
  6. No delete guard for departments with members. Deleting orphans every Employee.departmentId pointing at it; those employees appear under "Unassigned" in the org chart. There is no reassignment or block.
  7. Permission is USER_MAINTENANCE, not HR-specific. The "Add department" button checks USER_ACCESS.USER_MAINTENANCE.CREATE_DEPARTMENT, even though the feature gate is HR_MODULE. Permission module and feature gate are decoupled here.
  8. Leftover debug log in department.controller.ts (console.log("QR:", qr)). Harmless but should be removed.
  9. 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.