import { normalizeOrgChartDataset } from './normalize.js'; import { validateOrgChartDataset } from './validate.js'; import type { OrgChartDataset } from './types.js'; /** Source IDs retain their original collection identity after a browser-state move. */ export interface OrgChartMoveRequest { kind: 'employee' | 'organization'; sourceId: string; targetOrganizationId: string; } export type OrgChartMoveFailureCode = | 'EDITING_DISABLED' | 'INVALID_DATA' | 'INVALID_REQUEST' | 'SOURCE_NOT_FOUND' | 'TARGET_NOT_FOUND' | 'COMPANY_UNKNOWN' | 'TARGET_AFFILIATION_MISSING' | 'LEADER_PROTECTED' | 'ROOT_PROTECTED' | 'SELF_TARGET' | 'DESCENDANT_TARGET' | 'SAME_ORGANIZATION'; export interface OrgChartMoveFailure { code: OrgChartMoveFailureCode; message: string; } export type OrgChartMoveValidation = | { allowed: true; previousOrganizationId: string; previousCompanyId: string; companyId: string } | ({ allowed: false } & OrgChartMoveFailure); export type OrgChartMoveResult = | { moved: true; data: OrgChartDataset; request: OrgChartMoveRequest; previousOrganizationId: string } | ({ moved: false } & OrgChartMoveFailure); const messages: Record = { EDITING_DISABLED: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.', INVALID_DATA: '조직 데이터의 관계가 유효하지 않아 이동할 수 없습니다.', INVALID_REQUEST: '이동 요청이 올바르지 않습니다.', SOURCE_NOT_FOUND: '이동할 직원 또는 조직이 존재하지 않습니다.', TARGET_NOT_FOUND: '이동할 대상 조직이 존재하지 않습니다.', COMPANY_UNKNOWN: '회사 소속을 확인할 수 없어 이동할 수 없습니다.', TARGET_AFFILIATION_MISSING: '대상 회사의 소속 정보가 없어 회사 간 이동을 완료할 수 없습니다.', LEADER_PROTECTED: '조직장은 조직장 지정 정책을 먼저 변경해야 이동할 수 있습니다.', ROOT_PROTECTED: '회사 최상위 조직은 이동할 수 없습니다.', SELF_TARGET: '조직을 자기 자신에게 이동할 수 없습니다.', DESCENDANT_TARGET: '하위 조직으로 이동하면 순환 관계가 생겨 이동할 수 없습니다.', SAME_ORGANIZATION: '이미 해당 조직에 속해 있습니다.', }; function deny(code: OrgChartMoveFailureCode): OrgChartMoveValidation { return { allowed: false, code, message: messages[code] }; } /** Validate once for all highlighted drop targets. The closure owns an immutable * dataset snapshot, so later consumer mutations cannot change a drag's policy. * Company is the organization tree's identity, not an employee's affiliation. * Legacy roots may establish it through their leader's affiliation; no arbitrary * fallback company is invented when ownership cannot be established. */ export function createOrgChartMoveValidator(data: OrgChartDataset): (request: OrgChartMoveRequest) => OrgChartMoveValidation { const validation = validateOrgChartDataset(data); if (!validation.valid) return () => deny('INVALID_DATA'); const owned = validation.data; const normalized = normalizeOrgChartDataset(owned); const employees = new Map(owned.employees.map(employee => [employee.id, employee])); const affiliations = new Set(owned.affiliations.map(affiliation => affiliation.id)); const leaders = new Set(owned.organizations.flatMap(org => org.leaderId === null ? [] : [org.leaderId])); const companies = new Map(); const pending = normalized.roots.map(node => ({ node, inherited: undefined as string | undefined })); while (pending.length) { const { node, inherited } = pending.pop()!; const org = node.organization; const company = org.companyId ?? inherited ?? (org.parentId === null && org.leaderId ? employees.get(org.leaderId)?.affiliationId : undefined); if (inherited && company !== inherited) return () => deny('INVALID_DATA'); companies.set(org.id, company); pending.push(...node.children.map(child => ({ node: child, inherited: company }))); } return request => { if (!request || !['employee', 'organization'].includes(request.kind) || typeof request.sourceId !== 'string' || !request.sourceId || typeof request.targetOrganizationId !== 'string' || !request.targetOrganizationId) return deny('INVALID_REQUEST'); const source = request.kind === 'employee' ? employees.get(request.sourceId) : normalized.organizationsById.get(request.sourceId); if (!source) return deny('SOURCE_NOT_FOUND'); const target = normalized.organizationsById.get(request.targetOrganizationId); if (!target) return deny('TARGET_NOT_FOUND'); const sourceOrganization = request.kind === 'employee' ? normalized.organizationsById.get(employees.get(request.sourceId)!.organizationId)! : normalized.organizationsById.get(request.sourceId)!; const previousOrganizationId = request.kind === 'employee' ? sourceOrganization.id : sourceOrganization.parentId; if (request.kind === 'employee') { if (sourceOrganization.id === target.id) return deny('SAME_ORGANIZATION'); if (leaders.has(request.sourceId)) return deny('LEADER_PROTECTED'); } else { if (sourceOrganization.id === target.id) return deny('SELF_TARGET'); if (sourceOrganization.parentId === null || sourceOrganization.type === 'root') return deny('ROOT_PROTECTED'); if (sourceOrganization.parentId === target.id) return deny('SAME_ORGANIZATION'); let ancestor: typeof target | undefined = target; while (ancestor) { if (ancestor.id === sourceOrganization.id) return deny('DESCENDANT_TARGET'); ancestor = ancestor.parentId === null ? undefined : normalized.organizationsById.get(ancestor.parentId); } } const sourceCompany = companies.get(sourceOrganization.id); const targetCompany = companies.get(target.id); if (!sourceCompany || !targetCompany) return deny('COMPANY_UNKNOWN'); if (sourceCompany !== targetCompany && !affiliations.has(targetCompany)) return deny('TARGET_AFFILIATION_MISSING'); return { allowed: true, previousOrganizationId: previousOrganizationId!, previousCompanyId: sourceCompany, companyId: targetCompany }; }; } /** Pure policy check for a single keyboard or pointer move. */ export function validateOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveValidation { return createOrgChartMoveValidator(data)(request); } /** Create a new validated, deeply frozen browser-state dataset. Same-company * moves change membership alone. Cross-company employee moves also change the * existing affiliationId; group moves retarget companyId for its whole subtree * and affiliationId for every subtree employee. No new contract fields are used. * IDs, descendants, leaders, personal data and display ordering are retained. * Failed moves return no replacement data and never alter the input. */ export function applyOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveResult { const policy = validateOrgChartMove(data, request); if (!policy.allowed) return { moved: false, code: policy.code, message: policy.message }; const crossCompany = policy.previousCompanyId !== policy.companyId; let candidate: OrgChartDataset; if (request.kind === 'employee') { candidate = { ...data, employees: data.employees.map(employee => employee.id === request.sourceId ? { ...employee, organizationId: request.targetOrganizationId, ...(crossCompany ? { affiliationId: policy.companyId } : {}) } : employee) }; } else { const subtree = new Set([request.sourceId]); if (crossCompany) { const children = new Map(); for (const org of data.organizations) { if (org.parentId === null) continue; const siblings = children.get(org.parentId) ?? []; siblings.push(org.id); children.set(org.parentId, siblings); } const pending = [request.sourceId]; while (pending.length) for (const id of children.get(pending.pop()!) ?? []) { subtree.add(id); pending.push(id); } } candidate = { ...data, organizations: data.organizations.map(org => org.id === request.sourceId ? { ...org, parentId: request.targetOrganizationId, ...(crossCompany ? { companyId: policy.companyId } : {}) } : crossCompany && subtree.has(org.id) ? { ...org, companyId: policy.companyId } : org), employees: crossCompany ? data.employees.map(employee => subtree.has(employee.organizationId) ? { ...employee, affiliationId: policy.companyId } : employee) : data.employees, }; } const validation = validateOrgChartDataset(candidate); if (!validation.valid) return { moved: false, code: 'INVALID_DATA', message: messages.INVALID_DATA }; return { moved: true, data: validation.data, request: Object.freeze({ ...request }), previousOrganizationId: policy.previousOrganizationId }; }