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.
147 lines
11 KiB
TypeScript
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.' }] }; }
|
|
}
|