feat(org-chart): add core cards and fixed-layout integration
This commit is contained in:
1 parent
d18e3c3380
commit
61c6f8d1f1
161 files changed
+13044
-1
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { selectAffiliationSummary, selectMajorOrganizationTotals, selectOrganizationPositionCounts, selectStatusGroups } from './content-selectors.js';
|
||||
import { calculateSummary } from './summary.js';
|
||||
import type { OrgChartDataset } from './types.js';
|
||||
|
||||
function fixture(): OrgChartDataset {
|
||||
return {
|
||||
schemaVersion: '0.1-draft',
|
||||
organizations: [
|
||||
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'leader', displayOrder: 0 },
|
||||
{ id: 'team', parentId: 'root', name: '가상 팀', type: 'team', leaderId: null, displayOrder: 1 },
|
||||
{ id: 'empty', parentId: 'root', name: '빈 가상 팀', type: 'team', leaderId: null, displayOrder: 2 },
|
||||
],
|
||||
employees: [
|
||||
{ id: 'leader', organizationId: 'root', affiliationId: 'first', name: '가상 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
|
||||
{ id: 'sent', organizationId: 'team', affiliationId: 'second', name: '가상 나', position: '책임', jobTitle: null, profileImage: null, workStatus: 'dispatched' },
|
||||
{ id: 'leave', organizationId: 'team', affiliationId: 'first', name: '가상 다', position: '책임', jobTitle: null, profileImage: null, workStatus: 'onLeave' },
|
||||
],
|
||||
affiliations: [{ id: 'first', name: '가상 관계사 가' }, { id: 'second', name: '가상 관계사 나' }, { id: 'zero', name: '가상 관계사 다' }],
|
||||
metadata: { asOfDate: '2026-10-12', dataVersion: 'selector-test' },
|
||||
};
|
||||
}
|
||||
|
||||
test('position matrix uses direct primary membership, includes leaders and all work statuses', () => {
|
||||
const data = fixture();
|
||||
const matrix = selectOrganizationPositionCounts(data);
|
||||
const summary = calculateSummary(data);
|
||||
assert.deepEqual([...matrix.get('root')!], [['대표', 1]]);
|
||||
assert.deepEqual([...matrix.get('team')!], [['책임', 2]]);
|
||||
assert.equal(matrix.get('empty')!.size, 0);
|
||||
assert.equal(matrix.get('root')!.get('책임'), undefined);
|
||||
assert.equal(summary.raw.byOrganization.root!.inclusive, 3);
|
||||
for (const organization of data.organizations) {
|
||||
assert.equal([...matrix.get(organization.id)!.values()].reduce((sum, count) => sum + count, 0), summary.raw.byOrganization[organization.id]!.direct);
|
||||
}
|
||||
for (const [position, count] of Object.entries(summary.raw.byPosition)) {
|
||||
assert.equal([...matrix.values()].reduce((sum, row) => sum + (row.get(position) ?? 0), 0), count);
|
||||
}
|
||||
assert.equal([...matrix.values()].flatMap(row => [...row.values()]).reduce((sum, count) => sum + count, 0), summary.raw.total);
|
||||
assert.equal(summary.aggregation.status, 'unavailable');
|
||||
});
|
||||
|
||||
test('duplicate IDs cannot inflate matrix or status groups and original Employee references survive', () => {
|
||||
const data = fixture();
|
||||
data.employees.push({ ...data.employees[1]!, organizationId: 'root', position: '대표' });
|
||||
const summary = calculateSummary(data);
|
||||
const matrix = selectOrganizationPositionCounts(data);
|
||||
assert.equal(matrix.get('team')!.get('책임'), 2);
|
||||
assert.equal(matrix.get('root')!.get('대표'), 1);
|
||||
const groups = selectStatusGroups(data, summary);
|
||||
assert.equal(groups[0]!.employees.length, 1);
|
||||
assert.strictEqual(groups[0]!.employees[0], data.employees[1]);
|
||||
assert.strictEqual(groups[1]!.employees[0], data.employees[2]);
|
||||
assert.equal(summary.raw.total, 3);
|
||||
});
|
||||
|
||||
test('opaque prototype-like organization IDs and position strings remain ordinary matrix keys', () => {
|
||||
const data = fixture();
|
||||
data.organizations[1]!.id = '__proto__';
|
||||
data.employees[1]!.organizationId = '__proto__';
|
||||
data.employees[2]!.organizationId = '__proto__';
|
||||
data.employees[1]!.position = 'constructor';
|
||||
data.employees[2]!.position = '__proto__';
|
||||
const row = selectOrganizationPositionCounts(data).get('__proto__')!;
|
||||
assert.equal(row.get('constructor'), 1);
|
||||
assert.equal(row.get('__proto__'), 1);
|
||||
});
|
||||
|
||||
test('affiliation summary preserves declared order, zero entries and original affiliation objects', () => {
|
||||
const data = fixture();
|
||||
const summary = calculateSummary(data);
|
||||
const rows = selectAffiliationSummary(data, summary);
|
||||
assert.deepEqual(rows.map(row => [row.affiliation.id, row.count]), [['first', 2], ['second', 1], ['zero', 0]]);
|
||||
assert.strictEqual(rows[2]!.affiliation, data.affiliations[2]);
|
||||
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
|
||||
});
|
||||
|
||||
test('status flags independently hide groups without changing Raw Count or affiliation totals', () => {
|
||||
const data = fixture();
|
||||
const summary = calculateSummary(data);
|
||||
data.presentation = {
|
||||
title: '가상 조직도', description: null, hubText: null, organizationOrder: [],
|
||||
groups: { showEmployees: false, showSummary: false, showAffiliations: false, showDispatched: false, showOnLeave: true },
|
||||
};
|
||||
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['onLeave']);
|
||||
data.presentation.groups.showDispatched = true;
|
||||
data.presentation.groups.showOnLeave = false;
|
||||
assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['dispatched']);
|
||||
data.presentation.groups.showDispatched = false;
|
||||
assert.deepEqual(selectStatusGroups(data, summary), []);
|
||||
assert.deepEqual(calculateSummary(data), summary);
|
||||
assert.equal(selectAffiliationSummary(data, summary).length, 3);
|
||||
});
|
||||
|
||||
test('zero employees retains all organization/affiliation rows and both empty status groups', () => {
|
||||
const data = fixture();
|
||||
data.employees = [];
|
||||
data.organizations[0]!.leaderId = null;
|
||||
const summary = calculateSummary(data);
|
||||
assert.equal(selectOrganizationPositionCounts(data).size, 3);
|
||||
assert.ok([...selectOrganizationPositionCounts(data).values()].every(row => row.size === 0));
|
||||
assert.ok(selectAffiliationSummary(data, summary).every(row => row.count === 0));
|
||||
assert.deepEqual(selectStatusGroups(data, summary).map(group => [group.status, group.count, group.employees.length]), [['dispatched', 0, 0], ['onLeave', 0, 0]]);
|
||||
});
|
||||
|
||||
test('new JSON snapshot updates matrix, affiliations and status groups without a cached count', () => {
|
||||
const data = fixture();
|
||||
const before = calculateSummary(data);
|
||||
data.employees[1]!.organizationId = 'root';
|
||||
data.employees[1]!.position = '수석';
|
||||
data.employees[1]!.affiliationId = 'zero';
|
||||
data.employees[1]!.workStatus = 'active';
|
||||
const after = calculateSummary(data);
|
||||
const matrix = selectOrganizationPositionCounts(data);
|
||||
assert.equal(matrix.get('root')!.get('수석'), 1);
|
||||
assert.equal(matrix.get('team')!.get('책임'), 1);
|
||||
assert.deepEqual(selectAffiliationSummary(data, after).map(row => row.count), [2, 0, 1]);
|
||||
assert.equal(selectStatusGroups(data, after)[0]!.employees.length, 0);
|
||||
assert.equal(before.raw.byStatus.dispatched, 1);
|
||||
assert.equal(after.raw.total, before.raw.total);
|
||||
});
|
||||
|
||||
test('major organization totals: root direct members then inclusive root children, summing to the raw total', () => {
|
||||
const data = fixture();
|
||||
data.organizations.push({ id: 'cell', parentId: 'team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 3 });
|
||||
data.employees.push({ id: 'member', organizationId: 'cell', affiliationId: 'zero', name: '가상 라', position: '선임', jobTitle: null, profileImage: null, workStatus: 'active' });
|
||||
const summary = calculateSummary(data);
|
||||
const rows = selectMajorOrganizationTotals(data, summary);
|
||||
assert.deepEqual(rows.map(row => [row.organization.id, row.scope, row.count]), [['root', 'direct', 1], ['team', 'inclusive', 3], ['empty', 'inclusive', 0]]);
|
||||
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total);
|
||||
assert.equal(rows.some(row => row.organization.id === 'cell'), false);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { normalizeOrgChartDataset } from './normalize.js';
|
||||
import type { Employee, OrgChartDataset, OrgChartSummary, Organization, WorkStatus } from './types.js';
|
||||
|
||||
/** Internal content selectors: these do not expand the package's public data API. */
|
||||
function uniqueEmployees(data: OrgChartDataset): Employee[] {
|
||||
const seen = new Set<string>();
|
||||
return data.employees.filter(employee => {
|
||||
if (seen.has(employee.id)) return false;
|
||||
seen.add(employee.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Literal unique IDs in their primary organization, including leaders and all statuses.
|
||||
* Inclusive organization counts stay in Summary; they must never be summed as a subtotal.
|
||||
* Map keys safely retain opaque organization IDs and position names such as __proto__.
|
||||
*/
|
||||
export function selectOrganizationPositionCounts(data: OrgChartDataset): ReadonlyMap<string, ReadonlyMap<string, number>> {
|
||||
const counts = new Map<string, Map<string, number>>();
|
||||
for (const organization of data.organizations) counts.set(organization.id, new Map());
|
||||
for (const employee of uniqueEmployees(data)) {
|
||||
const row = counts.get(employee.organizationId);
|
||||
if (row) row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function selectAffiliationSummary(data: OrgChartDataset, summary: OrgChartSummary) {
|
||||
// Include all declared affiliations, including zero, without recounting employees.
|
||||
return data.affiliations.map(affiliation => ({
|
||||
affiliation,
|
||||
count: summary.raw.byAffiliation[affiliation.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface MajorOrganizationTotal {
|
||||
organization: Organization;
|
||||
/** 'direct' for a root's own members (e.g. its leader); 'inclusive' for a major organization. */
|
||||
scope: 'direct' | 'inclusive';
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Compact totals for the read-only composition: each root's direct members, then
|
||||
* each major (root child) organization including its cells/teams. Primary
|
||||
* membership is single, so the rows are disjoint and add up to the raw total. */
|
||||
export function selectMajorOrganizationTotals(data: OrgChartDataset, summary: OrgChartSummary): MajorOrganizationTotal[] {
|
||||
const rows: MajorOrganizationTotal[] = [];
|
||||
for (const root of normalizeOrgChartDataset(data).roots) {
|
||||
const counts = summary.raw.byOrganization[root.organization.id];
|
||||
if (!root.children.length) {
|
||||
rows.push({ organization: root.organization, scope: 'inclusive', count: counts?.inclusive ?? 0 });
|
||||
continue;
|
||||
}
|
||||
if (counts?.direct) rows.push({ organization: root.organization, scope: 'direct', count: counts.direct });
|
||||
for (const child of root.children) {
|
||||
rows.push({ organization: child.organization, scope: 'inclusive', count: summary.raw.byOrganization[child.organization.id]?.inclusive ?? 0 });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface StatusContentGroup {
|
||||
status: Extract<WorkStatus, 'dispatched' | 'onLeave'>;
|
||||
employees: readonly Employee[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function selectStatusGroups(data: OrgChartDataset, summary: OrgChartSummary): StatusContentGroup[] {
|
||||
const employees = uniqueEmployees(data);
|
||||
const groups: StatusContentGroup[] = [];
|
||||
if (data.presentation?.groups.showDispatched !== false) {
|
||||
groups.push({ status: 'dispatched', employees: employees.filter(employee => employee.workStatus === 'dispatched'), count: summary.raw.byStatus.dispatched });
|
||||
}
|
||||
if (data.presentation?.groups.showOnLeave !== false) {
|
||||
groups.push({ status: 'onLeave', employees: employees.filter(employee => employee.workStatus === 'onLeave'), count: summary.raw.byStatus.onLeave });
|
||||
}
|
||||
// Cards retain original Employee references. A second display never changes Summary.
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
calculateSummary, JsonOrgChartDataSource, normalizeOrgChartDataset, OrgChartSourceError,
|
||||
snapshotOrgChartDataset, validateOrgChartDataset, type ApprovedAggregationPolicy, type OrgChartDataset,
|
||||
} from './index.js';
|
||||
|
||||
function fixture(): OrgChartDataset {
|
||||
return {
|
||||
schemaVersion: '0.1-draft',
|
||||
organizations: [
|
||||
{ id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'a', displayOrder: 0 },
|
||||
{ id: 'team-a', parentId: 'root', name: '가상 기획팀', type: 'team', leaderId: 'b', displayOrder: 2 },
|
||||
{ id: 'team-b', parentId: 'root', name: '가상 운영팀', type: 'team', leaderId: null, displayOrder: 1 },
|
||||
],
|
||||
employees: [
|
||||
{ id: 'a', organizationId: 'root', affiliationId: 'aff-a', name: '가상직원 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' },
|
||||
{ id: 'b', organizationId: 'team-a', affiliationId: 'aff-a', name: '가상직원 나', position: '책임', jobTitle: '팀장', profileImage: null, workStatus: 'active' },
|
||||
{ id: 'c', organizationId: 'team-a', affiliationId: 'aff-b', name: '가상직원 다', position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'dispatched' },
|
||||
{ id: 'd', organizationId: 'team-b', affiliationId: 'aff-b', name: '가상직원 라', position: '선임', jobTitle: null, profileImage: 'https://example.invalid/virtual.png', workStatus: 'onLeave' },
|
||||
],
|
||||
affiliations: [{ id: 'aff-a', name: '가상 관계사 A' }, { id: 'aff-b', name: '가상 관계사 B' }],
|
||||
metadata: { asOfDate: '2026-10-12', dataVersion: 'test-1' },
|
||||
};
|
||||
}
|
||||
function codes(input: unknown): string[] {
|
||||
const result = validateOrgChartDataset(input);
|
||||
assert.equal(result.valid, false);
|
||||
return result.valid ? [] : result.issues.map(issue => issue.code);
|
||||
}
|
||||
const policy: ApprovedAggregationPolicy = {
|
||||
policyVersion: 'approved-test-1', includedStatuses: ['active', 'dispatched'], activeStatuses: ['active'],
|
||||
includeLeaders: true, membershipMode: 'primaryOnly',
|
||||
};
|
||||
|
||||
test('minimal v0.1-draft dataset validates and owns an immutable copy', () => {
|
||||
const original = fixture();
|
||||
const result = validateOrgChartDataset(original);
|
||||
assert.equal(result.valid, true);
|
||||
if (!result.valid) return;
|
||||
original.organizations[0]!.name = 'Changed';
|
||||
assert.equal(result.data.organizations[0]!.name, '가상 본부');
|
||||
assert.ok(Object.isFrozen(result.data.employees[0]));
|
||||
});
|
||||
test('version is required at top level; compatibility metadata mirror must agree', () => {
|
||||
const original = fixture() as unknown as Record<string, unknown>;
|
||||
delete original.schemaVersion;
|
||||
assert.ok(codes(original).includes('SCHEMA_VERSION'));
|
||||
const incompatible = fixture();
|
||||
(incompatible.metadata as unknown as Record<string, unknown>).schemaVersion = 'future';
|
||||
assert.ok(codes(incompatible).includes('SCHEMA_VERSION'));
|
||||
});
|
||||
test('required fields, nullability, whitespace and unknown fields are rejected', () => {
|
||||
const original = fixture();
|
||||
original.organizations[0]!.name = ' ';
|
||||
original.employees[0]!.position = null as unknown as string;
|
||||
(original.organizations[1] as unknown as Record<string, unknown>).nodeTemplate = '<script>alert(1)</script>';
|
||||
assert.ok(codes(original).includes('REQUIRED_VALUE'));
|
||||
assert.ok(codes(original).includes('UNKNOWN_FIELD'));
|
||||
});
|
||||
test('dates are real calendar dates and timestamps require valid clock/timezone', () => {
|
||||
for (const date of ['2026-02-30', '2026-13-01', '2026-1-1']) {
|
||||
const original = fixture(); original.metadata.asOfDate = date;
|
||||
assert.ok(codes(original).includes('REQUIRED_VALUE'));
|
||||
}
|
||||
const original = fixture(); original.metadata.updatedAt = '2026-10-12T24:00:00Z';
|
||||
assert.ok(codes(original).includes('REQUIRED_VALUE'));
|
||||
original.metadata.updatedAt = '2026-10-12T11:23:45+09:00';
|
||||
assert.equal(validateOrgChartDataset(original).valid, true);
|
||||
});
|
||||
test('zero employees is valid; zero organizations is a data error', () => {
|
||||
const original = fixture(); original.employees = [];
|
||||
original.organizations.forEach(org => { org.leaderId = null; });
|
||||
assert.equal(validateOrgChartDataset(original).valid, true);
|
||||
original.organizations = [];
|
||||
assert.ok(codes(original).includes('EMPTY_ORGANIZATIONS'));
|
||||
});
|
||||
test('duplicate collection IDs never overwrite an earlier item', () => {
|
||||
const original = fixture(); original.employees.push({ ...original.employees[0]! });
|
||||
assert.ok(codes(original).includes('DUPLICATE_ID'));
|
||||
});
|
||||
test('employee, parent, affiliation and leader references are checked', () => {
|
||||
const original = fixture();
|
||||
original.employees[0]!.organizationId = 'missing';
|
||||
original.employees[1]!.affiliationId = 'missing';
|
||||
original.organizations[2]!.parentId = 'missing';
|
||||
original.organizations[2]!.leaderId = 'missing';
|
||||
const failures = codes(original);
|
||||
for (const code of ['ORGANIZATION_REFERENCE', 'AFFILIATION_REFERENCE', 'PARENT_REFERENCE', 'LEADER_REFERENCE']) assert.ok(failures.includes(code));
|
||||
});
|
||||
test('leader cross-organization membership requires explicit business approval', () => {
|
||||
const original = fixture(); original.organizations[2]!.leaderId = 'b';
|
||||
assert.ok(codes(original).includes('LEADER_APPROVAL_REQUIRED'));
|
||||
});
|
||||
test('self-parent and multi-level cycles are rejected independently of sorting', () => {
|
||||
const original = fixture(); original.organizations[0]!.parentId = 'team-a';
|
||||
assert.ok(codes(original).includes('HIERARCHY_CYCLE'));
|
||||
original.organizations[0]!.parentId = 'root';
|
||||
assert.ok(codes(original).includes('HIERARCHY_CYCLE'));
|
||||
});
|
||||
test('unknown vocabularies are rejected without implicit status mapping', () => {
|
||||
const original = fixture(); original.employees[0]!.workStatus = 'retired' as never;
|
||||
original.organizations[0]!.type = 'unknown' as never;
|
||||
assert.ok(codes(original).includes('INVALID_STATUS'));
|
||||
assert.ok(codes(original).includes('INVALID_TYPE'));
|
||||
});
|
||||
test('relation endpoints, self-relations and duplicate directed relations are checked', () => {
|
||||
const original = fixture();
|
||||
original.relations = [
|
||||
{ id: 'r1', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' },
|
||||
{ id: 'r2', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' },
|
||||
{ id: 'r3', sourceOrganizationId: 'missing', targetOrganizationId: 'missing', type: 'support' },
|
||||
];
|
||||
assert.ok(codes(original).includes('INVALID_RELATION'));
|
||||
assert.ok(codes(original).includes('RELATION_REFERENCE'));
|
||||
});
|
||||
test('markup, executable URLs and identifier control characters are rejected', () => {
|
||||
for (const image of ['javascript:alert(1)', 'data:image/svg+xml,<svg/>', '//host.invalid/x', 'file:///x', 'https://user:password@example.invalid/x', 'images\\x.png']) {
|
||||
const original = fixture(); original.employees[0]!.profileImage = image;
|
||||
assert.ok(codes(original).includes('UNSAFE_INPUT'));
|
||||
}
|
||||
const original = fixture(); original.employees[0]!.name = '<img src=x onerror=alert(1)>';
|
||||
original.organizations[1]!.id += '\u202e';
|
||||
assert.ok(codes(original).includes('UNSAFE_INPUT'));
|
||||
});
|
||||
test('normalization preserves hierarchy, forest roots and deterministic presentation order', () => {
|
||||
const original = fixture();
|
||||
original.organizations.push({ id: 'other-root', parentId: null, name: '추가 본부', type: 'root', leaderId: null, displayOrder: 1 });
|
||||
const natural = normalizeOrgChartDataset(original);
|
||||
assert.deepEqual(natural.roots.map(node => node.organization.id), ['root', 'other-root']);
|
||||
assert.deepEqual(natural.roots[0]!.children.map(node => node.organization.id), ['team-b', 'team-a']);
|
||||
original.presentation = {
|
||||
title: '가상 조직도', description: null, hubText: null,
|
||||
groups: { showEmployees: false, showSummary: true, showDispatched: false, showOnLeave: false, showAffiliations: false },
|
||||
organizationOrder: ['team-a'],
|
||||
};
|
||||
assert.equal(validateOrgChartDataset(original).valid, true);
|
||||
assert.deepEqual(normalizeOrgChartDataset(original).roots[0]!.children.map(node => node.organization.id), ['team-a', 'team-b']);
|
||||
original.presentation.organizationOrder.push('missing');
|
||||
assert.ok(codes(original).includes('PRESENTATION_REFERENCE'));
|
||||
});
|
||||
test('raw summary uses unique IDs, primary membership and position rather than job title', () => {
|
||||
const original = fixture(); const summary = calculateSummary(original);
|
||||
assert.equal(summary.raw.total, 4);
|
||||
assert.deepEqual(summary.raw.byOrganization.root, { direct: 1, inclusive: 4 });
|
||||
assert.deepEqual(summary.raw.byOrganization['team-a'], { direct: 2, inclusive: 2 });
|
||||
assert.equal(summary.raw.byPosition['선임'], 2);
|
||||
assert.equal(summary.raw.byPosition['팀장'], undefined);
|
||||
assert.equal(summary.raw.byAffiliation['aff-b'], 2);
|
||||
assert.deepEqual(summary.raw.byStatus, { active: 2, dispatched: 1, onLeave: 1 });
|
||||
assert.deepEqual(summary.aggregation, { status: 'unavailable', reason: 'policyRequired' });
|
||||
});
|
||||
test('approved filtering changes business counts; raw counts and hidden groups remain unchanged', () => {
|
||||
const original = fixture(); const result = calculateSummary(original, policy);
|
||||
assert.equal(result.raw.total, 4);
|
||||
assert.equal(result.aggregation.status, 'ready');
|
||||
if (result.aggregation.status === 'ready') {
|
||||
assert.equal(result.aggregation.counts.total, 3);
|
||||
assert.equal(result.aggregation.counts.byOrganization.root!.inclusive, 3);
|
||||
assert.equal(result.aggregation.counts.byStatus.onLeave, 0);
|
||||
}
|
||||
original.presentation = {
|
||||
title: '가상 조직도', description: null, hubText: '설명 콘텐츠',
|
||||
groups: { showEmployees: false, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false },
|
||||
organizationOrder: ['team-b'],
|
||||
};
|
||||
original.relations = [{ id: 'rel-a', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'cooperation' }];
|
||||
assert.deepEqual(calculateSummary(original, policy), result);
|
||||
original.employees[2]!.organizationId = 'team-b';
|
||||
original.employees[2]!.workStatus = 'active';
|
||||
original.employees[2]!.affiliationId = 'aff-a';
|
||||
const moved = calculateSummary(original);
|
||||
assert.equal(moved.raw.byOrganization['team-a']!.direct, 1);
|
||||
assert.equal(moved.raw.byOrganization['team-b']!.direct, 2);
|
||||
assert.equal(moved.raw.byStatus.active, 3);
|
||||
assert.equal(moved.raw.byAffiliation['aff-a'], 3);
|
||||
});
|
||||
test('ambiguous or invalid business policy stays unavailable', () => {
|
||||
for (const invalid of [
|
||||
{ ...policy, includeLeaders: false }, { ...policy, membershipMode: 'uniqueEmployee' },
|
||||
{ ...policy, policyVersion: '' }, { ...policy, includedStatuses: ['active', 'active'] },
|
||||
{ ...policy, includedStatuses: ['dispatched'] },
|
||||
]) {
|
||||
const result = calculateSummary(fixture(), invalid as ApprovedAggregationPolicy);
|
||||
assert.deepEqual(result.aggregation, { status: 'unavailable', reason: 'unsupportedPolicy' });
|
||||
}
|
||||
});
|
||||
test('prototype-like opaque IDs and positions cannot corrupt summary records', () => {
|
||||
const original = fixture(); original.employees[0]!.position = '__proto__';
|
||||
assert.equal(calculateSummary(original).raw.byPosition['__proto__'], 1);
|
||||
});
|
||||
test('source captures inline input once and returns owned copies', async () => {
|
||||
const original = fixture(); const source = new JsonOrgChartDataSource(original);
|
||||
original.metadata.dataVersion = 'consumer-mutated';
|
||||
const first = await source.load() as OrgChartDataset;
|
||||
assert.equal(first.metadata.dataVersion, 'test-1');
|
||||
assert.notEqual(first, await source.load());
|
||||
assert.ok(Object.isFrozen(first));
|
||||
const snapshot = snapshotOrgChartDataset(original);
|
||||
assert.notEqual(snapshot, original);
|
||||
});
|
||||
test('URL source passes cancellation signal and captures the response', async () => {
|
||||
const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined;
|
||||
const source = new JsonOrgChartDataSource('./virtual.json', { fetch: (async (_url, options) => {
|
||||
receivedSignal = options?.signal;
|
||||
return new Response(JSON.stringify(fixture()), { status: 200 });
|
||||
}) as typeof fetch });
|
||||
assert.equal(validateOrgChartDataset(await source.load({ signal: controller.signal })).valid, true);
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
});
|
||||
test('default network source loads with a receiver-sensitive browser-native fetch', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async function(this: unknown) {
|
||||
if (this !== globalThis) throw new TypeError('Illegal invocation');
|
||||
return new Response(JSON.stringify(fixture()), { status: 200 });
|
||||
} as typeof fetch;
|
||||
try {
|
||||
const source = new JsonOrgChartDataSource('./virtual.json');
|
||||
assert.equal(validateOrgChartDataset(await source.load()).valid, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
test('source errors distinguish HTTP, parsing, unsafe URL and abort without leaking upstream text', async () => {
|
||||
assert.throws(() => new JsonOrgChartDataSource('javascript:alert(1)'), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_URL');
|
||||
const failed = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { throw new Error('PRIVATE_SERVER_ERROR'); }) as typeof fetch });
|
||||
await assert.rejects(failed.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD' && !error.message.includes('PRIVATE'));
|
||||
const http = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('private', { status: 500 })) as typeof fetch });
|
||||
await assert.rejects(http.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD');
|
||||
const parse = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('{invalid')) as typeof fetch });
|
||||
await assert.rejects(parse.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_PARSE');
|
||||
const controller = new AbortController(); controller.abort();
|
||||
await assert.rejects(failed.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT');
|
||||
});
|
||||
test('late ignored abort response cannot become a successful source result', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => {
|
||||
controller.abort(); return new Response(JSON.stringify(fixture()));
|
||||
}) as typeof fetch });
|
||||
await assert.rejects(source.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT');
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types.js';
|
||||
export { validateOrgChartDataset, isSafeImageUrl } from './validate.js';
|
||||
export { normalizeOrgChartDataset } from './normalize.js';
|
||||
export { calculateSummary } from './summary.js';
|
||||
export { snapshotOrgChartDataset } from './snapshot.js';
|
||||
export { JsonOrgChartDataSource, OrgChartSourceError, type OrgChartSourceErrorCode, type JsonOrgChartDataSourceOptions } from './source.js';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Employee, NormalizedOrgChartDataset, OrganizationTreeNode, OrgChartDataset } from './types.js';
|
||||
|
||||
/** Pure derivation for validated datasets. Multiple roots remain a forest. */
|
||||
export function normalizeOrgChartDataset(data: OrgChartDataset): NormalizedOrgChartDataset {
|
||||
const organizationsById = new Map(data.organizations.map(org => [org.id, org]));
|
||||
const employeesByOrganization = new Map<string, Employee[]>(data.organizations.map(org => [org.id, []]));
|
||||
for (const employee of data.employees) employeesByOrganization.get(employee.organizationId)?.push(employee);
|
||||
const nodes = new Map<string, OrganizationTreeNode>(data.organizations.map(org => [org.id, { organization: org, children: [] }]));
|
||||
const roots: OrganizationTreeNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.organization.parentId === null) roots.push(node);
|
||||
else nodes.get(node.organization.parentId)?.children.push(node);
|
||||
}
|
||||
const order = new Map((data.presentation?.organizationOrder ?? []).map((id, index) => [id, index]));
|
||||
const compare = (a: OrganizationTreeNode, b: OrganizationTreeNode) => {
|
||||
const aOrder = order.get(a.organization.id) ?? Infinity;
|
||||
const bOrder = order.get(b.organization.id) ?? Infinity;
|
||||
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1;
|
||||
const difference = a.organization.displayOrder - b.organization.displayOrder;
|
||||
if (difference) return difference;
|
||||
// Code-unit order is deterministic across environments and independent of locale.
|
||||
return a.organization.id < b.organization.id ? -1 : a.organization.id > b.organization.id ? 1 : 0;
|
||||
};
|
||||
roots.sort(compare);
|
||||
for (const node of nodes.values()) node.children.sort(compare);
|
||||
return { roots, organizationsById, employeesByOrganization };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Own a JSON-like input at the call boundary; never retain consumer objects. */
|
||||
export function snapshotOrgChartDataset<T>(input: T): T {
|
||||
const copy = structuredClone(input);
|
||||
const pending: unknown[] = [copy];
|
||||
const seen = new WeakSet<object>();
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
if (current === null || typeof current !== 'object' || seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const child of Object.values(current)) pending.push(child);
|
||||
Object.freeze(current);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { snapshotOrgChartDataset } from './snapshot.js';
|
||||
import type { OrgChartDataSource } from './types.js';
|
||||
|
||||
export type OrgChartSourceErrorCode = 'SOURCE_LOAD' | 'SOURCE_PARSE' | 'SOURCE_ABORT' | 'SOURCE_URL' | 'SOURCE_SNAPSHOT';
|
||||
export class OrgChartSourceError extends Error {
|
||||
readonly path = 'dataSource';
|
||||
constructor(readonly code: OrgChartSourceErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'OrgChartSourceError';
|
||||
}
|
||||
}
|
||||
export interface JsonOrgChartDataSourceOptions { fetch?: typeof globalThis.fetch }
|
||||
/** Inline JSON is captured at construction; URL sources load a fresh snapshot on each call. */
|
||||
export class JsonOrgChartDataSource implements OrgChartDataSource {
|
||||
private readonly inline: unknown;
|
||||
private readonly url: string | undefined;
|
||||
private readonly fetcher: typeof globalThis.fetch | undefined;
|
||||
constructor(input: unknown, options: JsonOrgChartDataSourceOptions = {}) {
|
||||
// Browser-native fetch may require the global Window receiver. Retaining it as
|
||||
// an instance property must not accidentally call it with this source as `this`.
|
||||
this.fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
||||
if (typeof input === 'string' || input instanceof URL) {
|
||||
const url = String(input);
|
||||
if (!safeSourceUrl(url)) throw new OrgChartSourceError('SOURCE_URL', 'Data source URL must use HTTP(S) or a relative JSON path.');
|
||||
this.url = url;
|
||||
} else {
|
||||
try { this.inline = snapshotOrgChartDataset(input); }
|
||||
catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source input must be cloneable JSON.'); }
|
||||
}
|
||||
}
|
||||
async load(options: { signal?: AbortSignal } = {}): Promise<unknown> {
|
||||
const signal = options.signal;
|
||||
const aborted = () => { if (signal?.aborted) throw new OrgChartSourceError('SOURCE_ABORT', 'Data source request was cancelled.'); };
|
||||
aborted();
|
||||
if (this.url === undefined) return snapshotOrgChartDataset(this.inline);
|
||||
if (!this.fetcher) throw new OrgChartSourceError('SOURCE_LOAD', 'No fetch implementation is available.');
|
||||
let response: Response;
|
||||
try { response = await this.fetcher(this.url, { signal }); }
|
||||
catch {
|
||||
aborted();
|
||||
throw new OrgChartSourceError('SOURCE_LOAD', 'Data source could not be loaded.');
|
||||
}
|
||||
aborted();
|
||||
if (!response.ok) throw new OrgChartSourceError('SOURCE_LOAD', 'Data source returned an unsuccessful response.');
|
||||
let payload: unknown;
|
||||
try { payload = await response.json(); }
|
||||
catch {
|
||||
aborted();
|
||||
throw new OrgChartSourceError('SOURCE_PARSE', 'Data source response is not valid JSON.');
|
||||
}
|
||||
aborted();
|
||||
try { return snapshotOrgChartDataset(payload); }
|
||||
catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source response must be cloneable JSON.'); }
|
||||
}
|
||||
}
|
||||
function safeSourceUrl(value: string): boolean {
|
||||
if (!value.trim() || /[\s\\<>\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value) || value.startsWith('//')) return false;
|
||||
if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(value);
|
||||
try { const parsed = new URL(value); return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password; }
|
||||
catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { normalizeOrgChartDataset } from './normalize.js';
|
||||
import type { ApprovedAggregationPolicy, Employee, OrgChartDataset, OrgChartSummary, SummaryCounts, WorkStatus } from './types.js';
|
||||
|
||||
const statuses: WorkStatus[] = ['active', 'dispatched', 'onLeave'];
|
||||
function supportedPolicy(policy: ApprovedAggregationPolicy): boolean {
|
||||
const validStatuses = (values: unknown): values is WorkStatus[] => Array.isArray(values)
|
||||
&& values.every(value => statuses.includes(value)) && new Set(values).size === values.length;
|
||||
return !!policy && typeof policy.policyVersion === 'string' && !!policy.policyVersion.trim()
|
||||
&& !/[\u0000-\u001f<>]/.test(policy.policyVersion)
|
||||
&& validStatuses(policy.includedStatuses) && validStatuses(policy.activeStatuses)
|
||||
&& policy.activeStatuses.every(status => policy.includedStatuses.includes(status))
|
||||
&& policy.includeLeaders === true && policy.membershipMode === 'primaryOnly';
|
||||
}
|
||||
function count(data: OrgChartDataset, employees: readonly Employee[]): SummaryCounts {
|
||||
const counts: SummaryCounts = {
|
||||
total: 0,
|
||||
byOrganization: Object.create(null) as SummaryCounts['byOrganization'],
|
||||
byPosition: Object.create(null) as SummaryCounts['byPosition'],
|
||||
byAffiliation: Object.create(null) as SummaryCounts['byAffiliation'],
|
||||
byStatus: { active: 0, dispatched: 0, onLeave: 0 },
|
||||
};
|
||||
for (const org of data.organizations) counts.byOrganization[org.id] = { direct: 0, inclusive: 0 };
|
||||
for (const affiliation of data.affiliations) counts.byAffiliation[affiliation.id] = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const employee of employees) {
|
||||
if (seen.has(employee.id)) continue;
|
||||
seen.add(employee.id);
|
||||
counts.total += 1;
|
||||
counts.byOrganization[employee.organizationId]!.direct += 1;
|
||||
counts.byPosition[employee.position] = (counts.byPosition[employee.position] ?? 0) + 1;
|
||||
counts.byAffiliation[employee.affiliationId] = (counts.byAffiliation[employee.affiliationId] ?? 0) + 1;
|
||||
counts.byStatus[employee.workStatus] += 1;
|
||||
}
|
||||
// Single primary membership makes child totals disjoint; compute a postorder iteratively.
|
||||
const stack = [...normalizeOrgChartDataset(data).roots];
|
||||
const traversal = [];
|
||||
while (stack.length) {
|
||||
const node = stack.pop()!;
|
||||
traversal.push(node);
|
||||
stack.push(...node.children);
|
||||
}
|
||||
for (let index = traversal.length - 1; index >= 0; index--) {
|
||||
const node = traversal[index]!;
|
||||
counts.byOrganization[node.organization.id]!.inclusive = counts.byOrganization[node.organization.id]!.direct
|
||||
+ node.children.reduce((sum, child) => sum + counts.byOrganization[child.organization.id]!.inclusive, 0);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
/** Raw counts are literal unique employee IDs, independent of presentation or business totals. */
|
||||
export function calculateSummary(data: OrgChartDataset, policy?: ApprovedAggregationPolicy): OrgChartSummary {
|
||||
const raw = count(data, data.employees);
|
||||
if (!policy) return { raw, aggregation: { status: 'unavailable', reason: 'policyRequired' } };
|
||||
if (!supportedPolicy(policy)) return { raw, aggregation: { status: 'unavailable', reason: 'unsupportedPolicy' } };
|
||||
const selected = data.employees.filter(employee => policy.includedStatuses.includes(employee.workStatus));
|
||||
return { raw, aggregation: { status: 'ready', policyVersion: policy.policyVersion, counts: count(data, selected) } };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export const SCHEMA_VERSION = '0.1-draft' as const;
|
||||
export type ID = string;
|
||||
export type OrganizationType = 'root' | 'division' | 'office' | 'div' | 'cell' | 'team';
|
||||
export type WorkStatus = 'active' | 'dispatched' | 'onLeave';
|
||||
export type RelationType = 'cooperation' | 'support';
|
||||
|
||||
export interface Organization {
|
||||
id: ID;
|
||||
parentId: ID | null;
|
||||
name: string;
|
||||
type: OrganizationType;
|
||||
leaderId: ID | null;
|
||||
displayOrder: number;
|
||||
}
|
||||
export interface Employee {
|
||||
id: ID;
|
||||
organizationId: ID;
|
||||
affiliationId: ID;
|
||||
name: string;
|
||||
position: string;
|
||||
jobTitle: string | null;
|
||||
profileImage: string | null;
|
||||
workStatus: WorkStatus;
|
||||
}
|
||||
export interface Affiliation { id: ID; name: string }
|
||||
export interface OrganizationRelation {
|
||||
id: ID;
|
||||
sourceOrganizationId: ID;
|
||||
targetOrganizationId: ID;
|
||||
type: RelationType;
|
||||
}
|
||||
export interface PresentationConfig {
|
||||
title: string;
|
||||
description: string | null;
|
||||
hubText: string | null;
|
||||
groups: {
|
||||
showEmployees: boolean;
|
||||
showSummary: boolean;
|
||||
showDispatched: boolean;
|
||||
showOnLeave: boolean;
|
||||
showAffiliations: boolean;
|
||||
};
|
||||
organizationOrder: ID[];
|
||||
}
|
||||
export interface Metadata {
|
||||
/** Compatibility mirror; the top-level schemaVersion is authoritative. */
|
||||
schemaVersion?: typeof SCHEMA_VERSION;
|
||||
asOfDate: string;
|
||||
dataVersion: string;
|
||||
updatedAt?: string | null;
|
||||
updateNote?: string | null;
|
||||
}
|
||||
export interface OrgChartDataset {
|
||||
schemaVersion: typeof SCHEMA_VERSION;
|
||||
organizations: Organization[];
|
||||
employees: Employee[];
|
||||
affiliations: Affiliation[];
|
||||
relations?: OrganizationRelation[];
|
||||
presentation?: PresentationConfig;
|
||||
metadata: Metadata;
|
||||
}
|
||||
export interface OrgChartDataSource {
|
||||
load(options?: { signal?: AbortSignal }): Promise<unknown>;
|
||||
}
|
||||
export interface ValidationIssue { code: string; path: string; message: string }
|
||||
export type ValidationResult =
|
||||
| { valid: true; data: OrgChartDataset }
|
||||
| { valid: false; issues: ValidationIssue[] };
|
||||
export interface OrganizationTreeNode {
|
||||
organization: Organization;
|
||||
children: OrganizationTreeNode[];
|
||||
}
|
||||
export interface NormalizedOrgChartDataset {
|
||||
roots: OrganizationTreeNode[];
|
||||
organizationsById: ReadonlyMap<ID, Organization>;
|
||||
employeesByOrganization: ReadonlyMap<ID, readonly Employee[]>;
|
||||
}
|
||||
export interface SummaryCounts {
|
||||
total: number;
|
||||
byOrganization: Record<ID, { direct: number; inclusive: number }>;
|
||||
byPosition: Record<string, number>;
|
||||
byAffiliation: Record<ID, number>;
|
||||
byStatus: Record<WorkStatus, number>;
|
||||
}
|
||||
export interface ApprovedAggregationPolicy {
|
||||
policyVersion: string;
|
||||
includedStatuses: WorkStatus[];
|
||||
activeStatuses: WorkStatus[];
|
||||
includeLeaders: boolean;
|
||||
membershipMode: 'primaryOnly' | 'uniqueEmployee';
|
||||
}
|
||||
export type AggregationPolicy = ApprovedAggregationPolicy;
|
||||
export type AggregationResult =
|
||||
| { status: 'unavailable'; reason: 'policyRequired' | 'unsupportedPolicy' }
|
||||
| { status: 'ready'; policyVersion: string; counts: SummaryCounts };
|
||||
export interface OrgChartSummary { raw: SummaryCounts; aggregation: AggregationResult }
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SCHEMA_VERSION, type OrgChartDataset, type ValidationIssue, type ValidationResult } from './types.js';
|
||||
import { snapshotOrgChartDataset } from './snapshot.js';
|
||||
|
||||
const organizationTypes = ['root', 'division', 'office', 'div', 'cell', 'team'];
|
||||
const statuses = ['active', 'dispatched', 'onLeave'];
|
||||
const relationTypes = ['cooperation', 'support'];
|
||||
const unsafeText = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]|<\/?[a-z][^>]*>|(?:javascript|vbscript)\s*:/i;
|
||||
type RecordValue = Record<string, unknown>;
|
||||
function record(input: unknown): input is RecordValue {
|
||||
return !!input && typeof input === 'object' && !Array.isArray(input)
|
||||
&& (Object.getPrototypeOf(input) === Object.prototype || Object.getPrototypeOf(input) === null);
|
||||
}
|
||||
export function isSafeImageUrl(input: string): boolean {
|
||||
if (!input.trim() || unsafeText.test(input) || /\s|\\/.test(input)) return false;
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(input)) {
|
||||
try { const url = new URL(input); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password; }
|
||||
catch { return false; }
|
||||
}
|
||||
// Protocol-relative URLs inherit network origins; require an explicit HTTP(S) URL instead.
|
||||
return !input.startsWith('//') && !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(input);
|
||||
}
|
||||
function validDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const parsed = new Date(value);
|
||||
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
function validTimestamp(value: string): boolean {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(value);
|
||||
return !!match && validDate(match[1]!) && Number(match[2]) < 24 && Number(match[3]) < 60
|
||||
&& Number(match[4]) < 60 && (!match[6] || Number(match[6]) < 24) && (!match[7] || Number(match[7]) < 60)
|
||||
&& Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
/** Strict contract validation. Messages contain no input values or server details. */
|
||||
export function validateOrgChartDataset(input: unknown): ValidationResult {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const issue = (code: string, path: string, message: string) => issues.push({ code, path, message });
|
||||
const object = (value: unknown, path: string, keys: string[]): value is RecordValue => {
|
||||
if (!record(value)) { issue('REQUIRED_VALUE', path, 'Expected a plain JSON object.'); return false; }
|
||||
for (const key of Object.keys(value)) if (!keys.includes(key)) issue('UNKNOWN_FIELD', `${path}.${key}`, 'Field is not part of this contract.');
|
||||
return true;
|
||||
};
|
||||
const text = (value: unknown, path: string, nullable = false): boolean => {
|
||||
if (nullable && value === null) return true;
|
||||
if (typeof value !== 'string' || !value.trim()) { issue('REQUIRED_VALUE', path, 'Expected a non-empty text value.'); return false; }
|
||||
if (unsafeText.test(value)) { issue('UNSAFE_INPUT', path, 'Text contains unsupported executable markup or control characters.'); return false; }
|
||||
return true;
|
||||
};
|
||||
const choice = (value: unknown, path: string, allowed: string[], code: string) => {
|
||||
if (typeof value !== 'string' || !allowed.includes(value)) issue(code, path, 'Value is outside the supported contract vocabulary.');
|
||||
};
|
||||
const collection = (value: unknown, path: string, keys: string[], inspect: (item: RecordValue, path: string) => void, optional = false) => {
|
||||
if (optional && value === undefined) return;
|
||||
if (!Array.isArray(value)) { issue('REQUIRED_VALUE', path, 'Expected an array.'); return; }
|
||||
const ids = new Set<string>();
|
||||
value.forEach((item, index) => {
|
||||
const location = `${path}[${index}]`;
|
||||
if (!object(item, location, keys)) return;
|
||||
if (text(item.id, `${location}.id`)) {
|
||||
if (ids.has(item.id as string)) issue('DUPLICATE_ID', `${location}.id`, 'ID must be unique within its collection.');
|
||||
ids.add(item.id as string);
|
||||
}
|
||||
inspect(item, location);
|
||||
});
|
||||
};
|
||||
if (!object(input, '$', ['schemaVersion', 'organizations', 'employees', 'affiliations', 'relations', 'presentation', 'metadata'])) return { valid: false, issues };
|
||||
if (input.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.schemaVersion', 'Unsupported or missing schema version.');
|
||||
collection(input.organizations, '$.organizations', ['id', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => {
|
||||
text(org.parentId, `${path}.parentId`, true); text(org.name, `${path}.name`); text(org.leaderId, `${path}.leaderId`, true);
|
||||
choice(org.type, `${path}.type`, organizationTypes, 'INVALID_TYPE');
|
||||
if (!Number.isSafeInteger(org.displayOrder) || (org.displayOrder as number) < 0) issue('REQUIRED_VALUE', `${path}.displayOrder`, 'Expected a non-negative safe integer.');
|
||||
});
|
||||
if (Array.isArray(input.organizations) && input.organizations.length === 0) issue('EMPTY_ORGANIZATIONS', '$.organizations', 'At least one organization is required.');
|
||||
collection(input.employees, '$.employees', ['id', 'organizationId', 'affiliationId', 'name', 'position', 'jobTitle', 'profileImage', 'workStatus'], (employee, path) => {
|
||||
for (const key of ['organizationId', 'affiliationId', 'name', 'position']) text(employee[key], `${path}.${key}`);
|
||||
text(employee.jobTitle, `${path}.jobTitle`, true);
|
||||
if (text(employee.profileImage, `${path}.profileImage`, true) && typeof employee.profileImage === 'string' && !isSafeImageUrl(employee.profileImage)) issue('UNSAFE_INPUT', `${path}.profileImage`, 'Image URL must use HTTP(S) or a safe relative path.');
|
||||
choice(employee.workStatus, `${path}.workStatus`, statuses, 'INVALID_STATUS');
|
||||
});
|
||||
collection(input.affiliations, '$.affiliations', ['id', 'name'], (affiliation, path) => { text(affiliation.name, `${path}.name`); });
|
||||
collection(input.relations, '$.relations', ['id', 'sourceOrganizationId', 'targetOrganizationId', 'type'], (relation, path) => {
|
||||
text(relation.sourceOrganizationId, `${path}.sourceOrganizationId`); text(relation.targetOrganizationId, `${path}.targetOrganizationId`);
|
||||
choice(relation.type, `${path}.type`, relationTypes, 'INVALID_RELATION');
|
||||
}, true);
|
||||
if (object(input.metadata, '$.metadata', ['schemaVersion', 'asOfDate', 'dataVersion', 'updatedAt', 'updateNote'])) {
|
||||
const meta = input.metadata;
|
||||
if (meta.schemaVersion !== undefined && meta.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.metadata.schemaVersion', 'Compatibility schema version must match the top-level version.');
|
||||
if (text(meta.asOfDate, '$.metadata.asOfDate') && !validDate(meta.asOfDate as string)) issue('REQUIRED_VALUE', '$.metadata.asOfDate', 'Expected a real calendar date in YYYY-MM-DD format.');
|
||||
text(meta.dataVersion, '$.metadata.dataVersion');
|
||||
if (meta.updatedAt !== undefined && text(meta.updatedAt, '$.metadata.updatedAt', true) && typeof meta.updatedAt === 'string' && !validTimestamp(meta.updatedAt)) issue('REQUIRED_VALUE', '$.metadata.updatedAt', 'Expected an RFC 3339 timestamp with timezone.');
|
||||
if (meta.updateNote !== undefined) text(meta.updateNote, '$.metadata.updateNote', true);
|
||||
}
|
||||
if (input.presentation !== undefined && object(input.presentation, '$.presentation', ['title', 'description', 'hubText', 'groups', 'organizationOrder'])) {
|
||||
const presentation = input.presentation;
|
||||
text(presentation.title, '$.presentation.title'); text(presentation.description, '$.presentation.description', true); text(presentation.hubText, '$.presentation.hubText', true);
|
||||
const flags = ['showEmployees', 'showSummary', 'showDispatched', 'showOnLeave', 'showAffiliations'];
|
||||
if (object(presentation.groups, '$.presentation.groups', flags)) for (const flag of flags) if (typeof presentation.groups[flag] !== 'boolean') issue('REQUIRED_VALUE', `$.presentation.groups.${flag}`, 'Expected a boolean.');
|
||||
if (!Array.isArray(presentation.organizationOrder)) issue('REQUIRED_VALUE', '$.presentation.organizationOrder', 'Expected an array of organization IDs.');
|
||||
else presentation.organizationOrder.forEach((id, index) => text(id, `$.presentation.organizationOrder[${index}]`));
|
||||
}
|
||||
if (issues.length) return { valid: false, issues };
|
||||
const data = input as unknown as OrgChartDataset;
|
||||
const organizations = new Map(data.organizations.map(org => [org.id, org]));
|
||||
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
|
||||
const affiliations = new Set(data.affiliations.map(affiliation => affiliation.id));
|
||||
data.organizations.forEach((org, index) => {
|
||||
if (org.parentId !== null && !organizations.has(org.parentId)) issue('PARENT_REFERENCE', `$.organizations[${index}].parentId`, 'Parent organization does not exist.');
|
||||
if (org.leaderId !== null) {
|
||||
const leader = employees.get(org.leaderId);
|
||||
if (!leader) issue('LEADER_REFERENCE', `$.organizations[${index}].leaderId`, 'Leader employee does not exist.');
|
||||
else if (leader.organizationId !== org.id) issue('LEADER_APPROVAL_REQUIRED', `$.organizations[${index}].leaderId`, 'Leader membership outside this organization requires an approved business policy.');
|
||||
}
|
||||
});
|
||||
data.employees.forEach((employee, index) => {
|
||||
if (!organizations.has(employee.organizationId)) issue('ORGANIZATION_REFERENCE', `$.employees[${index}].organizationId`, 'Employee organization does not exist.');
|
||||
if (!affiliations.has(employee.affiliationId)) issue('AFFILIATION_REFERENCE', `$.employees[${index}].affiliationId`, 'Employee affiliation does not exist.');
|
||||
});
|
||||
const checked = new Set<string>();
|
||||
for (const org of data.organizations) {
|
||||
if (checked.has(org.id)) continue;
|
||||
const chain = new Set<string>();
|
||||
let current: typeof org | undefined = org;
|
||||
while (current && !checked.has(current.id)) {
|
||||
if (chain.has(current.id)) { issue('HIERARCHY_CYCLE', '$.organizations', 'Organization parent references contain a cycle.'); break; }
|
||||
chain.add(current.id);
|
||||
current = current.parentId === null ? undefined : organizations.get(current.parentId);
|
||||
}
|
||||
for (const id of chain) checked.add(id);
|
||||
}
|
||||
const relationKeys = new Set<string>();
|
||||
data.relations?.forEach((relation, index) => {
|
||||
if (!organizations.has(relation.sourceOrganizationId) || !organizations.has(relation.targetOrganizationId)) issue('RELATION_REFERENCE', `$.relations[${index}]`, 'Relation endpoint organization does not exist.');
|
||||
const key = JSON.stringify([relation.sourceOrganizationId, relation.targetOrganizationId, relation.type]);
|
||||
if (relation.sourceOrganizationId === relation.targetOrganizationId || relationKeys.has(key)) issue('INVALID_RELATION', `$.relations[${index}]`, 'Self-relations and duplicate directed relations are unsupported.');
|
||||
relationKeys.add(key);
|
||||
});
|
||||
const orderIds = new Set<string>();
|
||||
data.presentation?.organizationOrder.forEach((id, index) => {
|
||||
if (!organizations.has(id) || orderIds.has(id)) issue('PRESENTATION_REFERENCE', `$.presentation.organizationOrder[${index}]`, 'Presentation order requires existing, unique organization IDs.');
|
||||
orderIds.add(id);
|
||||
});
|
||||
if (issues.length) return { valid: false, issues };
|
||||
try { return { valid: true, data: snapshotOrgChartDataset(data) }; }
|
||||
catch { return { valid: false, issues: [{ code: 'UNSAFE_INPUT', path: '$', message: 'Input must be a cloneable JSON dataset.' }] }; }
|
||||
}
|
||||
Reference in new issue
Block a user