Org Chart — read-only reporting hierarchy view

The whole org-chart model reduces to one idea: there is no org-chart collection. The hierarchy is derived in-memory on every request from the existing employees and departments data — specifically from Employee.reportingTo (the self-reference to a line manager). The service joins employees → users → departments, builds a Map keyed by Employee._id, threads directReports arrays via reportingTo, then buckets the resulting trees under their Department. Nothing is stored, nothing is mutated, nothing is dragged-and-saved.

Source: BE src/modules/hr/org-chart · Admin src/modules/hr/org-chart, src/modules/hr/company-profile (company-profile is documented in calendar; its HOD/department editing is the only place the hierarchy inputs are surfaced)


1. Purpose & scope

The org-chart module is a pure read/projection feature. It is responsible for:

  • Exposing three read-only GraphQL queries (orgChartTree, orgChartSubtree, orgChartSearch) that present the company's reporting structure.
  • Building a nested employee tree from Employee.reportingTo, grouped by Department, with each Department's HOD attached.
  • A flat, filterable search over employees (by name / department / position / employment type) for the admin's "search mode".

It explicitly does NOT:

  • Own any schema. No org_charts collection exists. The implementation plan (2026-05-09-org-chart-implementation.md) is explicit: "no new Mongoose schema; the service injects existing EmployeeRepository and DepartmentRepository."
  • Write or mutate the hierarchy. There is no create/update/delete, no reorderNode, no drag-persist mutation. The admin tree is display only — clicking a card navigates to the employee detail page; it does not re-parent anyone.
  • Set reportingTo. The field that drives the whole chart is not editable through any standard GraphQL input (see employee §9 gotcha #2 — reportingTo is on the schema but absent from every create/update/import DTO, written only by a data-fix migration). The org-chart therefore reads a field the app cannot write from the UI.
  • Compute anything HR-functional (no approvals routing, no payroll). The approval engine reads reportingTo independently; the org-chart only visualises it.

2. Data model

2.1 No collection — derived projection only

There is no schema file in org-chart/ (compare the directory listing: only .dto.ts, .service.ts, .resolver.ts, .module.ts, .service.spec.ts — no .schema.ts). The "data model" is two GraphQL @ObjectType projections assembled at query time from:

Read from Collection Fields consumed
EmployeeRepository.aggregate employees _id, reportingTo, departmentId, position, employmentType, joinDate, companyId, deleted
$lookupusers users name, email, avatar (joined via Employee.userId)
$lookupdepartments departments name (joined via Employee.departmentId)
DepartmentRepository.find departments _id, name, hodId

The single source of the hierarchy is Employee.reportingTo (ObjectId → employees, a self-reference to the line manager). See employee §2.1.

2.2 GraphQL projection types (org-chart.dto.ts)

@ObjectType()
export class OrgChartEmployeeNode {
  @Field({ nullable: true }) _id: string;            // Employee._id (string)
  @Field({ nullable: true }) name: string;           // user.name
  @Field({ nullable: true }) position: string;       // employee.position
  @Field({ nullable: true }) department: string;     // dept.name (joined)
  @Field({ nullable: true }) employmentType: string; // employee.employmentType (free string)
  @Field({ nullable: true }) email: string;          // user.email
  @Field(() => Float, { nullable: true }) joinDate: number; // unix ms
  @Field({ nullable: true }) avatar: string;         // user.avatar

  @Field(() => [OrgChartEmployeeNode], { nullable: true, defaultValue: [] })
  directReports: OrgChartEmployeeNode[];             // children, threaded in-memory
}

@ObjectType()
export class OrgChartDepartmentNode {
  @Field({ nullable: true }) _id: string;            // Department._id  (or the literal "unassigned")
  @Field({ nullable: true }) name: string;           // Department.name (or "Unassigned")
  @Field(() => OrgChartEmployeeNode, { nullable: true }) hod: OrgChartEmployeeNode;
  @Field(() => [OrgChartEmployeeNode], { nullable: true, defaultValue: [] })
  members: OrgChartEmployeeNode[];                    // dept's root members (each carries its subtree)
}

@InputType()
export class OrgChartSearchInput {
  @Field({ nullable: true }) @IsOptional() @IsString()  keyword?: string;
  @Field({ nullable: true }) @IsOptional() @IsMongoId() departmentId?: string;
  @Field({ nullable: true }) @IsOptional() @IsString()  position?: string;
  @Field({ nullable: true }) @IsOptional() @IsString()  employmentType?: string;
}

Generated schema (schema.gql):

type OrgChartEmployeeNode {
  _id: String  name: String  position: String  department: String
  employmentType: String  email: String  joinDate: Float  avatar: String
  directReports: [OrgChartEmployeeNode!]
}
type OrgChartDepartmentNode {
  _id: String  name: String  hod: OrgChartEmployeeNode  members: [OrgChartEmployeeNode!]
}
input OrgChartSearchInput { keyword: String  departmentId: String  position: String  employmentType: String }

No sensitive fields are projected. _toNode() deliberately maps only display-safe fields — no salary, no PIN, no statutory IDs. The wider Employee record is never exposed through this surface.

2.3 The hod join — a latent ID mismatch (flag)

OrgChartDepartmentNode.hod is populated in _groupByDepartment by:

hod: dept.hodId ? nodeMap.get(dept.hodId.toString()) ?? null : null,

nodeMap is keyed by Employee._id. But Department.hodId is consumed elsewhere as a User._iddepartment.resolver.ts resolves hod via userSvc.findById(args.hodId), and department.controller.ts does the same. So whether hod ever resolves on the org-chart depends on hodId actually holding an Employee._id, not a User._id. The unit test (org-chart.service.spec.ts "sets hod on department node") only passes because it stores the same id as both the employee _id and the hodId. If hodId is in practice a User._id, the org-chart hod will silently be null. Flag — verify which id hodId holds in your data before relying on hod.


3. API surface

All three queries are @Query only (no mutations). Guarded by @UseGuards(GqlFeatureGuard) + @RequireFeature("HR_MODULE") + @ApGqlAuthorize() at the resolver class level, with @ApGqlAuthorize() re-applied per query (org-chart.resolver.ts). The resolver extends ApBaseResolver<any> but adds no base CRUD.

Operation Type Input Returns Permission
orgChartTree Query [OrgChartDepartmentNode!]! HR_MODULE + auth
orgChartSubtree Query employeeId: String! OrgChartEmployeeNode (nullable) HR_MODULE + auth
orgChartSearch Query input: OrgChartSearchInput! [OrgChartEmployeeNode!]! HR_MODULE + auth
  • orgChartTreegetTree() — full department-bucketed forest.
  • orgChartSubtree(employeeId)getSubtree(employeeId) — the single node (with its threaded subtree) whose Employee._id matches; null if not found. Not consumed by the admin UI (the admin only calls tree and search) — exposed for ESS / future use per the plan.
  • orgChartSearch(input)search(input) — a flat list (each node has empty directReports), used for the admin's filter mode.

There is no REST controller in this module.


4. Business rules & derivation logic

This is the core of the module. There are three algorithms; all live in org-chart.service.ts.

4.1 getTree() — build the full forest (2 DB round-trips)

async getTree(): Promise<OrgChartDepartmentNode[]> {
  const companyId = new Types.ObjectId(this.contextSvc.companyId);
  const [departments, employees] = await Promise.all([
    this.departmentRepo.find({ companyId } as any),  // DB call 1
    this._fetchAllEmployees(companyId),              // DB call 2 (aggregate w/ user+dept lookups)
  ]);
  const nodeMap = this.buildNodeMap(employees);      // Map<Employee._id, node>
  this.buildNestedTree(employees, nodeMap);          // mutate: thread directReports
  return this._groupByDepartment(departments, employees, nodeMap);
}

The whole tree costs exactly two queries (departments + one employee aggregation); all threading is in-memory.

4.2 _fetchAllEmployees() — the employee aggregation

this.employeeRepo.aggregate([
  { $match: { companyId, deleted: { $ne: true } } },   // tenant scope + exclude soft-deleted
  { $lookup: { from: "users",       localField: "userId",       foreignField: "_id", as: "user" } },
  { $unwind: { path: "$user", preserveNullAndEmptyArrays: true } },
  { $lookup: { from: "departments", localField: "departmentId", foreignField: "_id", as: "dept" } },
  { $unwind: { path: "$dept", preserveNullAndEmptyArrays: true } },
]);
  • Tenant boundary: companyId from ApContextService (multi-tenant — see multi-tenancy).
  • Soft-delete: deleted: { $ne: true } — deleted employees never appear (consistent with employee lifecycle).
  • preserveNullAndEmptyArrays: true keeps employees with no user/department joined (so an employee with no department still flows to the "Unassigned" bucket).

4.3 buildNodeMap() + buildNestedTree() — hierarchy derivation from reportingTo

This is how nodes/edges are derived (the brief's question). There are no stored edges; an edge is child.reportingTo === parent._id.

buildNodeMap(employees): Map<string, OrgChartEmployeeNode> {
  const map = new Map();
  for (const e of employees) map.set(e._id.toString(), this._toNode(e)); // node per employee, directReports=[]
  return map;
}

buildNestedTree(employees, nodeMap): OrgChartEmployeeNode[] {
  const roots = [];
  for (const e of employees) {
    const node = nodeMap.get(e._id.toString())!;
    if (e.reportingTo) {
      const parent = nodeMap.get(e.reportingTo.toString());
      if (parent) parent.directReports.push(node); // EDGE: attach child under manager
      else        roots.push(node);                // orphan (manager missing) → treated as root
    } else {
      roots.push(node);                            // no manager → root
    }
  }
  return roots;
}

Derivation rules:

  1. One node per non-deleted employee, keyed by Employee._id.
  2. Edge = reportingTo. For each employee, look up its manager node by reportingTo and push the employee into that manager's directReports.
  3. Root if reportingTo is empty (top of the company — typically the founder/CEO).
  4. Orphan-as-root: if reportingTo points to a manager who is not in the map (deleted, cross-company, or a dangling id), the employee is promoted to a root rather than dropped. This is why deleting a manager without re-parenting their reports "orphans" them into roots — see employee §9 gotcha #8 (hasDirectReports exists but is not enforced as a delete guard).
  5. No cycle protection. The algorithm assumes reportingTo forms a DAG/forest. A cycle (A→B→A) would produce two nodes mutually nested; nothing detects or breaks it. Flag — guard reportingTo at write time (the migration that sets it is the only writer today).
  6. Mutation in place: buildNestedTree mutates the node objects in nodeMap (pushes into directReports); getTree/getSubtree read the same nodeMap afterward.

4.4 _groupByDepartment() — department-first hybrid bucketing

After the employee forest is threaded, members are bucketed by department. The subtle rule: a member appears under a department only if it is a root within that department — i.e. it has no manager, or its manager is in a different department (so cross-department reporting lines don't duplicate the subtree).

private _groupByDepartment(departments, employees, nodeMap) {
  const empDeptMap = new Map(employees.map(e => [e._id.toString(), e.departmentId?.toString()]));

  const result = departments.map(dept => {
    const deptId = dept._id.toString();
    const deptEmployees = employees.filter(e => e.departmentId?.toString() === deptId);
    const members = deptEmployees
      .filter(e => {
        if (!e.reportingTo) return true;                              // dept root: no manager
        return empDeptMap.get(e.reportingTo.toString()) !== deptId;   // manager is in another dept → also a dept root
      })
      .map(e => nodeMap.get(e._id.toString())!)
      .filter(Boolean);
    return {
      _id: deptId,
      name: dept.name,
      hod: dept.hodId ? nodeMap.get(dept.hodId.toString()) ?? null : null,
      members,
    };
  });

  const unassigned = employees.filter(e => !e.departmentId).map(e => nodeMap.get(e._id.toString())!).filter(Boolean);
  if (unassigned.length > 0)
    result.push({ _id: "unassigned", name: "Unassigned", hod: null, members: unassigned });

  return result;
}

Bucketing rules:

  1. A member is a department-local root — it shows at the top of its department's tree if it has no manager OR its manager belongs to a different department. Its directReports subtree (already threaded in §4.3) carries the rest, regardless of those reports' own departments. So a report in another department nested under a same-department manager is shown inside that manager's subtree, not as a separate top-level member.
  2. HOD attached from dept.hodId (see §2.3 mismatch caveat).
  3. "Unassigned" bucket: every employee with no departmentId is collected into a synthetic department { _id: "unassigned", name: "Unassigned", hod: null }, appended only if non-empty. Note: these are added flat (all of them, not filtered to roots), so an unassigned report whose manager is also unassigned will appear both as a top-level unassigned member and again nested — minor double-render edge case. Flag.
  4. Empty departments still produce a node (with members: []).
async getSubtree(employeeId) {                       // same build, then pluck one node
  const employees = await this._fetchAllEmployees(companyId);
  const nodeMap = this.buildNodeMap(employees);
  this.buildNestedTree(employees, nodeMap);          // thread the whole forest
  return nodeMap.get(employeeId) ?? null;            // return the requested node + its subtree
}

search() does not build a tree — it runs a separate aggregation with the same user/dept lookups plus a $match from _buildSearchMatch, and maps each row to a flat node (directReports: []):

private _buildSearchMatch(input) {
  const match = {};
  if (input?.keyword)        match["user.name"]    = { $regex: input.keyword, $options: "i" }; // name only
  if (input?.departmentId)   match.departmentId    = new Types.ObjectId(input.departmentId);
  if (input?.position)       match.position        = { $regex: input.position, $options: "i" };
  if (input?.employmentType) match.employmentType  = input.employmentType;                     // exact
  return match;
}
  • keyword matches user.name only (case-insensitive regex) — not email, not position.
  • departmentId is cast to ObjectId; position is regex; employmentType is exact-equality.
  • All filters AND together; empty input returns all employees (flat).

4.6 State machine / side effects / transactionality

N/A — read-only module. No state machine, no GL legs, no stock ledger, no events emitted, no audit (queries are not audited; only mutations carry @AuditMeta, and there are none here), no Mongo transaction (the plan's draft OrgChartService extends AbstractBaseService with a TransactionManager was dropped in the shipped code — the final service is a plain @Injectable() injecting two repos + ApContextService, with forwardRef to break the EmployeeModule/DepartmentModule circular import).


5. Permissions

  • Feature gate: @RequireFeature("HR_MODULE") + GqlFeatureGuard — the tenant's subscription must include HR. See permissions-access and employee §5.
  • Auth: @ApGqlAuthorize() (JWT) — any authenticated user in the company can read the chart (the plan intends both HR admins and ESS employees to view it). No finer RBAC action gate is applied at the resolver.
  • Admin route guard: the Next.js page route (src/pages/hr/org-chart/index.tsx) runs guard.isAuth() + guard.haveModuleAccess('/hr/org-chart', '/select-module') in getServerSideProps, so module-level access control happens at the route, not in a CASL ability.

6. Flows

6.1 View the full org chart (happy path)

Admin opens /hr/org-chart
  → OrgChartContextProvider wraps OrgChartPage
  → page.tsx useEffect → fetchTree()
  → context.fetchTree() → useOrgChartQuery().tree()   (Apollo useLazyQuery, fetchPolicy: 'no-cache')
  → GQL query OrgChartTree { orgChartTree { _id name hod{...} members{...directReports x3} } }
  → OrgChartResolver.getTree → OrgChartService.getTree
       ├─ Promise.all[ departmentRepo.find({companyId}), _fetchAllEmployees(companyId) ]
       ├─ buildNodeMap(employees)         // node per employee
       ├─ buildNestedTree(...)            // thread directReports via reportingTo
       └─ _groupByDepartment(...)         // bucket roots under departments + Unassigned
  ← [OrgChartDepartmentNode] → context.setDepartments(...)
  → OrgChartTreeView renders one react-organizational-chart <Tree> per department
       → EmployeeSubtree recurses employee.directReports

6.2 Search / filter mode

User types in search box / picks department / picks employment type
  → page.tsx 300ms debounce (setTimeout in useEffect over [keyword, departmentId, employmentType])
  → if any filter set: context.search({ keyword, departmentId, employmentType })
       → GQL OrgChartSearch(input) → service.search → aggregate + _buildSearchMatch
       ← flat [OrgChartEmployeeNode] → setSearchResults, setIsSearchActive(true)
  → page renders a flat grid of cards (view toggle hidden while searching)
  → if all filters cleared: context.clearSearch() → isSearchActive=false → back to tree/list

6.3 Unhappy / edge paths

  • orgChartSubtree with unknown idnodeMap.get(id) miss → returns null (resolver field is nullable).
  • Employee with reportingTo pointing at a deleted/missing manager → promoted to a root (§4.3 rule 4), not dropped.
  • Employee with no departmentId → lands in the synthetic "Unassigned" department.
  • Department with hodId that is a User._id (not Employee._id)hod resolves to null silently (§2.3).
  • No employees / no departmentsgetTree returns []; admin shows "No organisation data available."

6.4 "Drag-reorder" — not implemented

The brief asks about drag-reorder. There is no drag-reorder anywhere in the codebase. The tree is rendered by react-organizational-chart's static <Tree>/<TreeNode> (no drag handlers, no DnD library, no mutation). The only interaction on a node is a next/link to /hr/employees/[_id]. To reorder, an operator must change the underlying Employee.reportingTo — and as noted, that field has no editable GraphQL input today (see employee §9 #2). Reordering the chart is currently a data-migration operation, not a UI action. This is the single biggest "rebuild" gap to be aware of: if you want drag-to-reparent, you must (a) add reportingTo to the employee update input, and (b) add a mutation + DnD here.


7. Admin UI

Route: /hr/org-chartsrc/pages/hr/org-chart/index.tsx (wraps OrgChartPage in HRLayout + OrgChartContextProvider, selectedKeys=['org-chart']).

Module: src/modules/hr/org-chart/ — follows the zync-nextjs standard (model → gql/query → context → page → components).

Context (context.tsx)

OrgChartContextProvider exposes useOrgChartState() with:

State / method Purpose
departments the tree result (IOrgChartDepartment[])
searchResults flat search results (IOrgChartEmployee[])
loading spinner flag
isSearchActive toggles between tree/list and the flat search grid
view / setView 'tree' | 'list' toggle
fetchTree() calls q.tree() → sets departments
search(input) calls q.search({variables:{input}}) → sets searchResults + isSearchActive
clearSearch() resets search state

The context is the only consumer of useOrgChartQuery() (per the standard). gql/query.ts exposes tree and search as useLazyQuery hooks (fetchPolicy: 'no-cache', errors → toastSvc.graphQlError). There is no subtree hook in the adminorgChartSubtree is unused on the front end.

Page (page.tsx)

  • ApPageHeader title="Organisation Chart" + a toolbar: ApSearchInput (name), two ApSelectInputs (Department from departments, Employment Type from a hard-coded EMPLOYMENT_TYPE_OPTIONS of FULL_TIME/PART_TIME/CONTRACT/INTERN), a "Clear filters" button when searching, and a Tree/List view toggle when not searching.
  • A stats bar (shown only in tree/list mode, non-loading, non-empty) computing totalEmployees via recursive countAll(members) and totalDepts (excluding the "unassigned" pseudo-department).
  • Content switches: loadingSpin; isSearchActive → flat grid of OrgChartEmployeeNode cards; view==='tree'OrgChartTreeView; else → OrgChartListView.
  • 300ms debounced search effect over [keyword, departmentId, employmentType].

Components

  • OrgChartTreeView.tsx — renders one react-organizational-chart <Tree> per department (gradient DepartmentLabel with a recursive member count badge), recursing directReports via an internal EmployeeSubtree. Imported with next/dynamic (ssr: false) because the chart lib is client-only.
  • OrgChartListView.tsx — Ant Design <Tree> (blockNode, showLine), department rows non-selectable, employees rendered via a titleRender that delegates to OrgChartEmployeeNode compact. Default-expands all departments.
  • OrgChartEmployeeNode.tsx — the employee card (shared by tree, list, and search grid). Avatar image or initials-on-gradient (gradient deterministically chosen from the name's char-code sum), name, position, department badge, employment-type pill (styled via EMPLOYMENT_TYPE_STYLE map). The whole card is a Link href={/hr/employees/${employee._id}}.

Notable UX: debounced live search; tree vs list toggle; view toggle auto-hidden during search; click-through to employee detail; recursive counts in labels/stats. No create/edit/delete, no export, no print, no drag.


8. Dependencies & integrations

Org-chart depends on / calls:

  • EmployeeModuleEmployeeRepository.aggregate (the employee + user + dept lookup). forwardRef to break the circular import with EmployeeModule. See employee.
  • DepartmentModuleDepartmentRepository.find (departments + hodId). forwardRef. See department.
  • ApContextServicecompanyId (tenant scope). See multi-tenancy.
  • AuthModule + SubscriptionModule → guards (auth + HR_MODULE feature gate).

Consumed by: the admin /hr/org-chart page only. getSubtree is exposed for ESS / future use but has no current caller. The module exports: [OrgChartService] so other BE modules could inject it, but none do today.

Events / cron / external: none. No events emitted or consumed, no jobs, no external services.

Relationship to the approval engine: the approval orchestrator also reads Employee.reportingTo (and resolves it to userId) for manager routing — see employee §2.3 and approvals. Org-chart and approvals are independent consumers of the same reportingTo edge; changing the hierarchy affects both.


9. Gotchas & project-specific rules

  1. No schema, no storage. The chart is recomputed from employees/departments on every query. There is nothing to migrate, back-fill, or keep in sync — but also nothing cached (each getTree re-runs the full aggregation + in-memory build). For very large headcounts this is O(N) per request with no pagination.
  2. reportingTo is the only hierarchy source — and it's not UI-editable. The field driving every edge has no GraphQL create/update/import input (employee §9 #2). The chart visualises a structure the app currently can't set from screens; it's populated by migration. This is the key rebuild caveat.
  3. No drag-reorder, no reparent mutation. The tree is static (react-organizational-chart). The brief's "drag-reorder" does not exist. Reparenting = editing reportingTo directly.
  4. hod may silently be null due to the Employee._id vs User._id keying mismatch on Department.hodId (§2.3). The department resolver treats hodId as a User._id; org-chart treats it as an Employee._id.
  5. Orphan-as-root, no cycle guard. Dangling/cross-company reportingTo → promoted to root; a reportingTo cycle is undefined behaviour (no detection). Deleting a manager orphans reports because no delete guard enforces hasDirectReports.
  6. "Unassigned" members are added flat, not root-filtered (§4.4 #3) — a possible double-render if both an employee and its manager are unassigned.
  7. Search keyword matches name only (not email/position), employment-type is exact-match, and search returns a flat list (directReports always empty) — the search grid is intentionally non-hierarchical.
  8. Admin subtree is dead. orgChartSubtree exists in the schema and resolver but the admin never calls it; only tree and search hooks are wired.
  9. fetchPolicy: 'no-cache' on both admin queries — every navigation re-fetches; no Apollo cache reuse (consistent with the no-cache, always-fresh intent of a derived view).
  10. Employment-type options are hard-coded in the admin (FULL_TIME/PART_TIME/CONTRACT/INTERN) and the BE employmentType is a free string (the enum isn't GraphQL-registered — see employee §2.2). Filtering by employmentType relies on the stored values matching those literals exactly; DOMESTIC_SERVANT (a valid EmploymentType) is not offered as a filter.