Files
HMDesign/packages/org-chart/src/data/validate.ts
T
shkim4 064517ec46 feat(org-chart): implement expandable layout and multi-company navigation
Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract.
2026-10-12 21:59:09 +09:00

147 lines
11 KiB
TypeScript

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', 'companyId', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => {
if (org.companyId !== undefined) text(org.companyId, `${path}.companyId`);
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.' }] }; }
}