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
employeesanddepartmentsdata — specifically fromEmployee.reportingTo(the self-reference to a line manager). The service joins employees → users → departments, builds aMapkeyed byEmployee._id, threadsdirectReportsarrays viareportingTo, then buckets the resulting trees under theirDepartment. 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 byDepartment, with eachDepartment'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_chartscollection exists. The implementation plan (2026-05-09-org-chart-implementation.md) is explicit: "no new Mongoose schema; the service injects existingEmployeeRepositoryandDepartmentRepository." - 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 —reportingTois 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
reportingToindependently; 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 |
$lookup → users |
users |
name, email, avatar (joined via Employee.userId) |
$lookup → departments |
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 widerEmployeerecord 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._id — department.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 |
orgChartTree→getTree()— full department-bucketed forest.orgChartSubtree(employeeId)→getSubtree(employeeId)— the single node (with its threaded subtree) whoseEmployee._idmatches;nullif not found. Not consumed by the admin UI (the admin only callstreeandsearch) — exposed for ESS / future use per the plan.orgChartSearch(input)→search(input)— a flat list (each node has emptydirectReports), 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:
companyIdfromApContextService(multi-tenant — see multi-tenancy). - Soft-delete:
deleted: { $ne: true }— deleted employees never appear (consistent with employee lifecycle). preserveNullAndEmptyArrays: truekeeps 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:
- One node per non-deleted employee, keyed by
Employee._id. - Edge =
reportingTo. For each employee, look up its manager node byreportingToand push the employee into that manager'sdirectReports. - Root if
reportingTois empty (top of the company — typically the founder/CEO). - Orphan-as-root: if
reportingTopoints 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 (hasDirectReportsexists but is not enforced as a delete guard). - No cycle protection. The algorithm assumes
reportingToforms a DAG/forest. A cycle (A→B→A) would produce two nodes mutually nested; nothing detects or breaks it. Flag — guardreportingToat write time (the migration that sets it is the only writer today). - Mutation in place:
buildNestedTreemutates the node objects innodeMap(pushes intodirectReports);getTree/getSubtreeread the samenodeMapafterward.
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:
- A
memberis 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. ItsdirectReportssubtree (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. - HOD attached from
dept.hodId(see §2.3 mismatch caveat). - "Unassigned" bucket: every employee with no
departmentIdis 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. - Empty departments still produce a node (with
members: []).
4.5 getSubtree() and search()
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;
}keywordmatchesuser.nameonly (case-insensitive regex) — not email, not position.departmentIdis cast toObjectId;positionis regex;employmentTypeis 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) runsguard.isAuth()+guard.haveModuleAccess('/hr/org-chart', '/select-module')ingetServerSideProps, 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
orgChartSubtreewith unknown id →nodeMap.get(id)miss → returnsnull(resolver field is nullable).- Employee with
reportingTopointing 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
hodIdthat is aUser._id(notEmployee._id) →hodresolves tonullsilently (§2.3). - No employees / no departments →
getTreereturns[]; 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-chart → src/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 admin — orgChartSubtree is unused on the front end.
Page (page.tsx)
ApPageHeader title="Organisation Chart"+ a toolbar:ApSearchInput(name), twoApSelectInputs (Department fromdepartments, Employment Type from a hard-codedEMPLOYMENT_TYPE_OPTIONSof 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
totalEmployeesvia recursivecountAll(members)andtotalDepts(excluding the"unassigned"pseudo-department). - Content switches:
loading→Spin;isSearchActive→ flat grid ofOrgChartEmployeeNodecards;view==='tree'→OrgChartTreeView; else →OrgChartListView. - 300ms debounced search effect over
[keyword, departmentId, employmentType].
Components
OrgChartTreeView.tsx— renders onereact-organizational-chart<Tree>per department (gradientDepartmentLabelwith a recursive member count badge), recursingdirectReportsvia an internalEmployeeSubtree. Imported withnext/dynamic(ssr: false) because the chart lib is client-only.OrgChartListView.tsx— Ant Design<Tree>(blockNode,showLine), department rows non-selectable, employees rendered via atitleRenderthat delegates toOrgChartEmployeeNode 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 viaEMPLOYMENT_TYPE_STYLEmap). The whole card is aLink 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:
EmployeeModule→EmployeeRepository.aggregate(the employee + user + dept lookup).forwardRefto break the circular import withEmployeeModule. See employee.DepartmentModule→DepartmentRepository.find(departments +hodId).forwardRef. See department.ApContextService→companyId(tenant scope). See multi-tenancy.AuthModule+SubscriptionModule→ guards (auth +HR_MODULEfeature 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
- No schema, no storage. The chart is recomputed from
employees/departmentson every query. There is nothing to migrate, back-fill, or keep in sync — but also nothing cached (eachgetTreere-runs the full aggregation + in-memory build). For very large headcounts this is O(N) per request with no pagination. reportingTois 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.- No drag-reorder, no reparent mutation. The tree is static (
react-organizational-chart). The brief's "drag-reorder" does not exist. Reparenting = editingreportingTodirectly. hodmay silently be null due to theEmployee._idvsUser._idkeying mismatch onDepartment.hodId(§2.3). The department resolver treatshodIdas aUser._id; org-chart treats it as anEmployee._id.- Orphan-as-root, no cycle guard. Dangling/cross-company
reportingTo→ promoted to root; areportingTocycle is undefined behaviour (no detection). Deleting a manager orphans reports because no delete guard enforceshasDirectReports. - "Unassigned" members are added flat, not root-filtered (§4.4 #3) — a possible double-render if both an employee and its manager are unassigned.
- Search keyword matches name only (not email/position), employment-type is exact-match, and search returns a flat list (
directReportsalways empty) — the search grid is intentionally non-hierarchical. - Admin
subtreeis dead.orgChartSubtreeexists in the schema and resolver but the admin never calls it; onlytreeandsearchhooks are wired. 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).- Employment-type options are hard-coded in the admin (FULL_TIME/PART_TIME/CONTRACT/INTERN) and the BE
employmentTypeis a free string (the enum isn't GraphQL-registered — see employee §2.2). Filtering byemploymentTyperelies on the stored values matching those literals exactly;DOMESTIC_SERVANT(a validEmploymentType) is not offered as a filter.