From 9fcd6a235a449cfcc3a0d21fd6d244fac08593c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:51:31 +0900 Subject: [PATCH 01/26] test(customer): expose malformed Customer Master hierarchy loss --- frontend/src/customerMasterTree.test.ts | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 frontend/src/customerMasterTree.test.ts diff --git a/frontend/src/customerMasterTree.test.ts b/frontend/src/customerMasterTree.test.ts new file mode 100644 index 000000000..ec5eec814 --- /dev/null +++ b/frontend/src/customerMasterTree.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { CustomerMasterEntity } from "./api"; +import { buildCustomerEntityTree } from "./customerMasterTree"; + +function entity( + corporate_entity_id: string, + entity_name: string, + parent_entity_id: string | null, +): CustomerMasterEntity { + return { + corporate_entity_id, + corporate_entity_code: corporate_entity_id.toUpperCase(), + entity_name, + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id, + }; +} + +describe("buildCustomerEntityTree", () => { + it("keeps self-parent and unavailable-parent entities visible with disclosure", () => { + const forest = buildCustomerEntityTree([ + entity("self", "Self Parent", "self"), + entity("orphan", "Orphan", "missing"), + ]); + + expect(forest.map((node) => [node.entity.corporate_entity_id, node.hierarchyIssue])).toEqual([ + ["orphan", "parent_not_available"], + ["self", "self_parent_ignored"], + ]); + }); + + it("breaks a pure cycle deterministically without dropping either entity", () => { + const alpha = entity("alpha", "Alpha", "beta"); + const beta = entity("beta", "Beta", "alpha"); + + const forward = buildCustomerEntityTree([alpha, beta]); + const reversed = buildCustomerEntityTree([beta, alpha]); + + for (const forest of [forward, reversed]) { + expect(forest).toHaveLength(1); + expect(forest[0].entity.corporate_entity_id).toBe("alpha"); + expect(forest[0].hierarchyIssue).toBe("cycle_parent_ignored"); + expect(forest[0].children.map((node) => node.entity.corporate_entity_id)).toEqual(["beta"]); + } + }); + + it("keeps ordinary parent-child structure deterministic", () => { + const parent = entity("parent", "Parent", null); + const childB = entity("child-b", "Child B", "parent"); + const childA = entity("child-a", "Child A", "parent"); + + const forest = buildCustomerEntityTree([childB, parent, childA]); + + expect(forest).toHaveLength(1); + expect(forest[0].entity.corporate_entity_id).toBe("parent"); + expect(forest[0].hierarchyIssue).toBeNull(); + expect(forest[0].children.map((node) => node.entity.corporate_entity_id)).toEqual([ + "child-a", + "child-b", + ]); + }); +}); From eb37772f322d9147612674deca7fecc8c2caeee9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 22:56:20 +0900 Subject: [PATCH 02/26] fix(customer): preserve malformed hierarchy entities --- frontend/src/customerMasterTree.ts | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 frontend/src/customerMasterTree.ts diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts new file mode 100644 index 000000000..844e78c23 --- /dev/null +++ b/frontend/src/customerMasterTree.ts @@ -0,0 +1,106 @@ +import type { CustomerMasterEntity } from "./api"; + +export type CustomerHierarchyIssue = + | "cycle_parent_ignored" + | "self_parent_ignored" + | "parent_not_available"; + +export interface CustomerEntityTreeNode { + entity: CustomerMasterEntity; + hierarchyIssue: CustomerHierarchyIssue | null; + children: CustomerEntityTreeNode[]; +} + +function compareEntity(left: CustomerMasterEntity, right: CustomerMasterEntity): number { + if (left.entity_name < right.entity_name) return -1; + if (left.entity_name > right.entity_name) return 1; + if (left.corporate_entity_id < right.corporate_entity_id) return -1; + if (left.corporate_entity_id > right.corporate_entity_id) return 1; + return 0; +} + +/** + * Builds the authorized Customer Master hierarchy without hiding malformed records. + * + * Parent pointers are presentation evidence, not permission to discard an otherwise + * authorized entity. Missing parents, self-parent edges, and one deterministic edge + * per pure cycle are therefore omitted from the rendered forest and disclosed on the + * promoted root. No replacement parent or organization is invented. Ordering uses + * code-point comparison rather than runtime locale so repeated renders are stable. + */ +export function buildCustomerEntityTree( + entities: CustomerMasterEntity[], +): CustomerEntityTreeNode[] { + const byId = new Map(entities.map((entity) => [entity.corporate_entity_id, entity])); + const parentById = new Map(); + const issueById = new Map(); + + for (const entity of entities) { + const parentId = entity.parent_entity_id; + if (!parentId) { + parentById.set(entity.corporate_entity_id, null); + } else if (parentId === entity.corporate_entity_id) { + parentById.set(entity.corporate_entity_id, null); + issueById.set(entity.corporate_entity_id, "self_parent_ignored"); + } else if (!byId.has(parentId)) { + parentById.set(entity.corporate_entity_id, null); + issueById.set(entity.corporate_entity_id, "parent_not_available"); + } else { + parentById.set(entity.corporate_entity_id, parentId); + } + } + + const fullyVisited = new Set(); + const orderedEntities = [...entities].sort(compareEntity); + for (const startingEntity of orderedEntities) { + if (fullyVisited.has(startingEntity.corporate_entity_id)) continue; + + const path: string[] = []; + const pathIndex = new Map(); + let currentId: string | null = startingEntity.corporate_entity_id; + + while (currentId && !fullyVisited.has(currentId)) { + const repeatedAt = pathIndex.get(currentId); + if (repeatedAt !== undefined) { + const cycleIds = path.slice(repeatedAt); + const breakEntity = cycleIds + .map((id) => byId.get(id)) + .filter((entity): entity is CustomerMasterEntity => entity !== undefined) + .sort(compareEntity)[0]; + if (breakEntity) { + parentById.set(breakEntity.corporate_entity_id, null); + issueById.set(breakEntity.corporate_entity_id, "cycle_parent_ignored"); + } + break; + } + pathIndex.set(currentId, path.length); + path.push(currentId); + currentId = parentById.get(currentId) ?? null; + } + + for (const id of path) fullyVisited.add(id); + } + + const childrenByParent = new Map(); + const roots: CustomerMasterEntity[] = []; + for (const entity of entities) { + const parentId = parentById.get(entity.corporate_entity_id) ?? null; + if (!parentId) { + roots.push(entity); + continue; + } + const children = childrenByParent.get(parentId) ?? []; + children.push(entity); + childrenByParent.set(parentId, children); + } + + const toNode = (entity: CustomerMasterEntity): CustomerEntityTreeNode => ({ + entity, + hierarchyIssue: issueById.get(entity.corporate_entity_id) ?? null, + children: [...(childrenByParent.get(entity.corporate_entity_id) ?? [])] + .sort(compareEntity) + .map(toNode), + }); + + return roots.sort(compareEntity).map(toNode); +} From 16cf549002012f2d7568803a9cc7e084f44b28c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:01:46 +0900 Subject: [PATCH 03/26] test(customer): require visible hierarchy issue projection --- frontend/src/customerMasterProjection.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 frontend/src/customerMasterProjection.test.ts diff --git a/frontend/src/customerMasterProjection.test.ts b/frontend/src/customerMasterProjection.test.ts new file mode 100644 index 000000000..f24d85aa2 --- /dev/null +++ b/frontend/src/customerMasterProjection.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { CustomerMasterEntity, CustomerMasterResponse } from "./api"; +import { projectCustomerMasterResponse } from "./customerMasterProjection"; + +function entity( + corporate_entity_id: string, + entity_name: string, + parent_entity_id: string | null, +): CustomerMasterEntity { + return { + corporate_entity_id, + corporate_entity_code: corporate_entity_id.toUpperCase(), + entity_name, + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id, + }; +} + +function response(corporate_entities: CustomerMasterEntity[]): CustomerMasterResponse { + return { + corporate_entities, + relationship_network: [], + source_customer_hints: [], + source_author_hints: [], + keymen: [], + }; +} + +describe("projectCustomerMasterResponse", () => { + it("makes ignored hierarchy edges visible without mutating source entities", () => { + const self = entity("self", "Self Parent", "self"); + const orphan = entity("orphan", "Orphan", "missing"); + const raw = response([self, orphan]); + + const projected = projectCustomerMasterResponse(raw); + + expect(self.parent_entity_id).toBe("self"); + expect(self.entity_level_label).toBe("Company"); + expect(projected.corporate_entities).toEqual([ + expect.objectContaining({ + corporate_entity_id: "orphan", + parent_entity_id: null, + entity_level_code: "company", + entity_level_label: "Company · Parent not available in this authorized view", + }), + expect.objectContaining({ + corporate_entity_id: "self", + parent_entity_id: null, + entity_level_code: "company", + entity_level_label: "Company · Self-parent link omitted", + }), + ]); + }); + + it("projects a pure cycle as one deterministic visible root", () => { + const projected = projectCustomerMasterResponse( + response([ + entity("beta", "Beta", "alpha"), + entity("alpha", "Alpha", "beta"), + ]), + ); + + expect(projected.corporate_entities).toEqual([ + expect.objectContaining({ + corporate_entity_id: "alpha", + parent_entity_id: null, + entity_level_label: "Company · Cyclic parent link omitted", + }), + expect.objectContaining({ + corporate_entity_id: "beta", + parent_entity_id: "alpha", + entity_level_label: "Company", + }), + ]); + }); +}); From 8e56cc6cbd1daa613292ef3c643a5c789293459c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:03:49 +0900 Subject: [PATCH 04/26] fix(customer): project malformed hierarchy safely --- frontend/src/api.ts | 1721 +--------------------- frontend/src/apiTransport.ts | 1715 +++++++++++++++++++++ frontend/src/customerMasterProjection.ts | 44 + 3 files changed, 1769 insertions(+), 1711 deletions(-) create mode 100644 frontend/src/apiTransport.ts create mode 100644 frontend/src/customerMasterProjection.ts diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5db021a05..639c7aa62 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,1715 +1,14 @@ -import { config } from "./config"; +export * from "./apiTransport"; -export interface PostSummary { - post_id: string; - post_title: string; - voc_type_code: string; - voc_type_label?: string; - voice_types?: PostVoiceType[]; - visibility_code: string; - visibility_label?: string; - source_stage_code?: string | null; - source_detail_state_code?: string | null; - source_draft_code?: string | null; - source_deleted_flag?: string | null; - source_author_code?: string | null; - source_author_name?: string | null; - source_company_code?: string | null; - source_company_name?: string | null; - source_process_unit_code?: string | null; - source_process_unit_name?: string | null; - source_sales_pool_code?: string | null; - source_sales_pool_name?: string | null; - source_customer_code?: string | null; - source_customer_name?: string | null; - source_project_code?: string | null; - source_project_name?: string | null; - source_system_code?: string | null; - source_record_key?: string | null; - publication_state_code?: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - project_evidence?: ProjectEvidence[]; - created_at: string; -} - -export interface PostVoiceType { - code: string; - label: string; - is_primary: boolean; - truth_status_code: string; - evidence_available: boolean; -} - -export interface PostPage { - posts: PostSummary[]; - total_count: number; - limit: number; - offset: number; - voc_type_options?: PostFilterOption[]; - voice_type_catalog?: PostFilterOption[]; - visibility_options?: PostFilterOption[]; -} - -export interface OperationsDashboardFact { - fact_type_code: string; - fact_type_label: string; - value_text: string; - evidence_text: string; - evidence_post_id: string; -} - -export interface OperationsDashboardCase { - post_id: string; - case_kind_code: string; - case_kind_label: string; - project_name: string | null; - summary_text: string; - evidence_text: string; - evidence_post_id: string; - occurred_at: string; - facts: OperationsDashboardFact[]; -} - -export interface OperationsDashboardResponse { - period_label: string; - total_post_count: number; - total_event_count: number; - external_post_count: number; - external_percent: number; - pending_analysis_count: number; - failed_analysis_count: number; - cases: OperationsDashboardCase[]; -} - -export function fetchOperationsDashboard( - accessToken: string, - periodStart = "", - periodEnd = "", -): Promise { - const query = new URLSearchParams(); - if (periodStart) query.set("period_start", periodStart); - if (periodEnd) query.set("period_end", periodEnd); - const suffix = query.size ? `?${query}` : ""; - return backendFetch(`/api/dashboard${suffix}`, accessToken); -} - -export interface PostFilterOption { - code: string; - label: string; -} - -export type PostSortOrder = "newest" | "oldest" | "title"; - -export interface PostKnownAt { - post_title: string; - post_body: string; - written_at: string; - as_of: string; -} - -export interface PostDetail extends PostSummary { - post_body: string; - occupational_construct_assertions: OccupationalConstructAssertion[]; - occupational_construct_evidence_status: - | "complete" - | "processing" - | "unavailable" - | "setup_required" - | "historical_unavailable"; - known_at?: PostKnownAt; -} - -export interface PostImageContent { - unit_index: number; - mime_type: string; - status_code: string; - extracted_text: string | null; - caption: string | null; - tags: string[]; - regions?: PostImageRegion[]; -} - -export interface PostImageRegion { - region_index: number; - x_ratio: number; - y_ratio: number; - width_ratio: number; - height_ratio: number; - status_code: string; - extracted_text: string | null; - caption: string | null; - tags: string[]; -} - -export interface PostContentResponse { - status?: "ready" | "processing" | "unavailable"; - units: PostContentUnit[]; - images: PostImageContent[]; -} - -export interface PostContentUnit { - unit_index: number; - unit_kind_code: string; - unit_label?: string; - unit_text: string; - indent_level: number; - indent_source_code: "explicit" | "llm" | "unresolved"; - indent_confidence: number; - indent_evidence: string; -} - -export interface Affiliation { - organization_name: string; - corporate_entity_id: string | null; - role_title: string | null; - organization_alias?: string; -} - -export interface Keyman { - person_id: string; - person_name: string; - person_side_code: string; - person_side_label?: string; - mention_context: string | null; - last_known_job_title: string | null; - affiliations: Affiliation[]; -} - -export interface Counterparty { - counterparty_entity_name: string; - relationship_type_code: string; - relationship_label?: string; - verification_status_code: string; - verification_evidence_url: string | null; - verification_evidence_post_id: string | null; - corporate_entity_id: string | null; - organization_alias?: string; -} - -export interface AffiliatePersonRef { - person_id: string; - person_name: string; - person_side_code: string; - person_side_label?: string; -} - -export interface AffiliateNode { - entity_id: string | null; - entity_name: string; - entity_level_code: string | null; - entity_level_label?: string | null; - resolved: boolean; - organization_alias?: string; - people: AffiliatePersonRef[]; - children: AffiliateNode[]; -} - -export interface VocEvidenceCounterparty { - counterparty_entity_name: string; - relationship_type_code: string; - relationship_label: string; - evidence_excerpt: string | null; - verification_status_code?: string; - verification_evidence_url?: string | null; -} - -export interface VocEvidence { - post_id: string; - voc_type_code: string; - voc_type_label: string; - excerpts: string[]; - counterparties: VocEvidenceCounterparty[]; -} - -export type RelatedNodeType = - | "node_person" - | "node_post" - | "node_corporate_entity" - | "node_team"; - -export interface RelatedNode { - node_id: string; - node_type_code: RelatedNodeType | string; - relevance: number; - label?: string; - organization_alias?: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - person_side_code?: string; - person_side_label?: string; - ontology_iri?: string; - ontology_label?: string; -} - -export interface PostRoleResponsibility { - actor_name: string; - responsibility: string; - actor_type_code: string; - affiliated_organization_name: string | null; - catalog_node_id?: string | null; - catalog_node_type_code?: string | null; -} - -export interface PostMajorEventAction { - action_text: string; - requester_actor_name: string | null; - processor_actor_name: string | null; - evidence_text: string; - project_name?: string | null; -} - -export interface PostProjectMention { - project_key: string; - project_name: string; - evidence: string; - confidence: number; - ontology_iri: string; - ontology_label?: string; - extraction_method: string; -} - -export interface ProjectEvidence { - project_key: string; - project_name: string; - evidence: string; - confidence: number | null; - ontology_iri: string; - ontology_label?: string; - extraction_method: string; - resolution_status: string; - provenance: string; -} - -export interface OccupationalConstructAssertion { - construct_iri: string; - construct_family_code: string; - preferred_label: string; - vocabulary_iri: string; - vocabulary_version: string; - evidence_text: string; - truth_status_code: string; - extraction_method: string; - generated_at: string; - unit_index: number; - provenance: string; -} - -export interface PostAiSummary { - post_id: string; - korean_summary: string; - summary_status?: "current" | "stale"; - summary_contract_version?: number | null; - key_events: string[]; - key_event_details?: PostKeyEvent[]; - roles_and_responsibilities: PostRoleResponsibility[]; - major_event_actions?: PostMajorEventAction[]; - project_mentions?: PostProjectMention[]; -} - -export interface PostKeyEvent { - event_text: string; - project_name?: string | null; -} - -export interface FiveW1HValue { - text: string; - source: string; - evidence_text?: string; - ontology_codes: string[]; - ontology_annotations: Record; -} - -export interface FiveW1HSlot { - slot_code: "who" | "what" | "when" | "where" | "why" | "how"; - values: FiveW1HValue[]; - empty_next_action_code: string; -} - -export interface PostFiveW1H { - post_id: string; - slots: FiveW1HSlot[]; -} - -export interface LinkedPostRef { - post_id: string; - post_title: string; - post_body_excerpt?: string | null; - post_body_truncated?: boolean; - interval_relation_code?: string; - interval_relation_label?: string; - interval_is_parent?: boolean; -} - -export interface PostLineage { - post_id: string; - direct: LinkedPostRef[]; - indirect: LinkedPostRef[]; -} - -export interface CitedPostRef { - post_id: string; - post_title: string; - source_post_revision_id?: string | null; - evidence_available_at?: string | null; - knowledge_cutoff?: string | null; - live_changed_after_cutoff?: boolean; - historical_body_unavailable?: boolean; - unavailable_channels?: string[]; - evidence_open_action?: EvidenceOpenAction; -} - -export interface EvidenceOpenAction { - action_kind: "open_cited_content_unit"; - post_id: string; - unit_index: number; -} - -export interface CitedPostEvidenceFact { - kind: string; - text: string; -} - -export interface CitedPostEvidence { - post_id: string; - facts: CitedPostEvidenceFact[]; -} - -export interface ChatAnswer { - post_id: string; - answer_text: string; - cited_post_ids: string[]; - cited_posts?: CitedPostRef[]; - source_post_ids: string[]; -} - -export interface ChatExchange { - question_text: string; - answer_text: string; - cited_post_ids: string[]; - cited_posts?: CitedPostRef[]; -} - -export interface ChatHistory { - post_id: string; - exchanges: ChatExchange[]; -} - -export interface CitedPostImage { - post_id: string; - unit_index: number; - mime_type: string; - status_code: string; - extracted_text: string | null; - caption: string | null; - tags: string[]; -} - -export interface AskAgentResponse { - answer_text: string; - cited_post_ids: string[]; - cited_posts?: CitedPostRef[]; - cited_post_evidence?: CitedPostEvidence[]; - cited_post_images?: CitedPostImage[]; - source_post_ids: string[]; - external_verification_status?: string; - external_claims?: ExternalClaim[]; - next_action?: string; - knowledge_cutoff?: string | null; - grounding_status?: "live_only" | "fully_cutoff_grounded" | "partially_cutoff_grounded"; - limitations?: Array<{ - post_id: string; - limitation_code: "historical_body_unavailable"; - unavailable_channels: string[]; - }>; - lineage_graph?: LineageGraph; - delivery?: { - contract_version: string; - report: { - media_type: string; - body: string; - source_documents: Array<{ - post_id: string; - title: string; - api_path: string; - resource_uri: string; - evidence_facts: CitedPostEvidenceFact[]; - }>; - }; - alert: { - trigger_code: string; - delivery_status_code: string; - eligible: boolean; - watched_resource_uris: string[]; - }; - }; -} - -export interface ExternalClaimEvidence { - title: string; - url: string; - snippet: string; -} - -export interface ExternalClaim { - claim_text: string; - claim_kind: string; - status_code: string; - rationale: string; - source_post_ids: string[]; - evidence: ExternalClaimEvidence[]; -} - -export interface IssueTicket { - issue_ticket_id: string; - post_id: string; - ticket_status_code: string; - ticket_status_label?: string; - ticket_title: string; - assigned_account_id: string | null; - due_date: string | null; - commitment_summary: string | null; - created_at: string; - updated_at: string; -} - -export interface CalendarEntry extends IssueTicket { - post_title: string; -} - -export interface NaruonCalendarEvent { - occurrence_reference: string; - event_reference: string; - source_reference: string; - display_text: string; - starts_at: string; - ends_at: string; - all_day: boolean; - time_zone: string; - status_code: string; - disclosure_code: string; - truth_status_code: string; - observed_at: string; - provider_revision: string; -} - -export interface CalendarResponse { - events: NaruonCalendarEvent[]; - commitments: CalendarEntry[]; - calendar_sources: { - naruon_available: boolean; - naruon_next_action: string | null; - }; -} - -export interface DerivedCommitment { - post_id: string; - has_commitment: boolean; - ticket: IssueTicket | null; -} - -export interface ActivityEvent { - event_id: string; - event_type: string; - actor_account_id: string; - summary: string; -} - -export class BackendError extends Error { - readonly status: number; - - constructor(path: string, status: number, detail?: string) { - const message = - status === 0 - ? "The service is unreachable. Try again later." - : status >= 500 - ? "The service could not complete this request. Try again later." - : detail && detail.trim() - ? detail - : `${path} -> HTTP ${status}`; - super(message); - this.name = "BackendError"; - this.status = status; - } -} - -export function fetchProjectHistory( - accessToken: string, - projectKey: string, - focusPostId: string, - knowledgeCutoff?: string | null, -): Promise { - const query = new URLSearchParams({ focus_post_id: focusPostId }); - if (knowledgeCutoff) query.set("knowledge_cutoff", knowledgeCutoff); - return backendFetch( - `/api/projects/${encodeURIComponent(projectKey)}/history?${query.toString()}`, - accessToken, - ); -} - -async function backendFetch( - path: string, - accessToken: string, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(`${config.backendBaseUrl}${path}`, { - ...init, - headers: { - Authorization: `Bearer ${accessToken}`, - ...(init?.body ? { "Content-Type": "application/json" } : {}), - ...init?.headers, - }, - }); - } catch { - throw new BackendError(path, 0); - } - if (!response.ok) { - let detail: string | undefined; - try { - const body: unknown = await response.json(); - if (body && typeof body === "object" && "detail" in body && typeof body.detail === "string") { - detail = body.detail; - } - } catch { - detail = undefined; - } - throw new BackendError(path, response.status, detail); - } - return response.json() as Promise; -} - -export interface LineageGraphNode { - id: string; - group: string; - label: string; - occurred_at: string; - is_root: boolean; - is_branch_point: boolean; -} - -export interface LineageChannelEvidence { - signal_code: string; - signal_label: string; - score: number; - weight: number; - contribution: number; - rank: number; -} - -export interface LineageRebuildProfile { - reconstruction_version: string; - generated_at: string; - min_fused_score: number; - candidate_window: number; - active_weights: { signal_code: string; signal_weight: number }[]; -} - -export interface LineageGraphEdge { - source: string; - target: string; - fused_score: number; - channel_evidence?: LineageChannelEvidence[]; - interval_relation_code?: string; - interval_relation_label?: string; -} - -export interface LineageGraph { - nodes: LineageGraphNode[]; - edges: LineageGraphEdge[]; - truncated?: boolean; - reconstruction?: LineageRebuildProfile | null; - isolation_reason?: "comparison_candidates_available" | "no_comparison_group" | null; -} - -export function fetchLineageGraph(accessToken: string, postId?: string): Promise { - const query = postId ? `?post_id=${encodeURIComponent(postId)}` : ""; - return backendFetch(`/api/lineage${query}`, accessToken); -} - -export interface CorporateEntityRef { - corporate_entity_id: string; - entity_name: string; -} - -export interface CustomerMasterEntity extends CorporateEntityRef { - corporate_entity_code: string; - entity_level_code: string; - entity_level_label: string; - parent_entity_id: string | null; -} - -export interface CustomerMasterKeymanAffiliation { - organization_name: string; - corporate_entity_id: string | null; - entity_name: string | null; - role_title: string | null; -} - -export interface CustomerMasterKeyman { - person_id: string; - person_name: string; - person_side_code: string; - person_side_label: string; - last_known_job_title: string | null; - affiliations: CustomerMasterKeymanAffiliation[]; -} - -export interface SourceCustomerHint { - customer_code: string | null; - customer_name: string | null; - post_count: number; - related_posts: LinkedPostRef[]; - resolution_status: string; - hint_trust: string; - provenance: string; -} - -export interface SourceAuthorAffiliation { - corporate_entity_id: string; - entity_name: string; - process_unit_code: string | null; - process_unit_name: string | null; -} - -export interface SourceAuthorContext { - author_account_id: string; - account_display_name: string; - source_author_code: string | null; - source_author_name: string | null; - account_affiliations: SourceAuthorAffiliation[]; - resolution_status: string; - provenance: string; -} - -export interface SourceAuthorKeymanHint { - person_id: string; - person_name: string; - person_side_code: string; - last_known_job_title: string | null; - mention_count: number; - provenance: string; -} - -export interface SourceAuthorHint { - author_code: string; - author_name: string | null; - author_account_id: string; - account_display_name: string; - account_affiliations: SourceAuthorAffiliation[]; - post_count: number; - keyman_hints: SourceAuthorKeymanHint[]; - related_posts: LinkedPostRef[]; - resolution_status: string; - provenance: string; -} - -export interface CounterpartyRelationshipRole { - relationship_type_code: string; - relationship_label: string; - post_count: number; -} - -export interface RelationshipNetworkEntry { - counterparty_entity_name: string; - corporate_entity_id: string | null; - total_post_count: number; - relationships: CounterpartyRelationshipRole[]; - multi_role: boolean; -} - -export interface CustomerMasterResponse { - corporate_entities: CustomerMasterEntity[]; - keymen: CustomerMasterKeyman[]; - source_customer_hints: SourceCustomerHint[]; - source_author_hints: SourceAuthorHint[]; - relationship_network: RelationshipNetworkEntry[]; -} - -export interface CurrentUser { - user_account_id: string; - display_name: string; - permission_codes: string[]; - corporate_entities?: CorporateEntityRef[]; - preferred_locale?: string | null; -} - -export function fetchMe(accessToken: string): Promise { - return backendFetch("/api/me", accessToken); -} - -export function setPreferredLocale( - accessToken: string, - preferredLocale: string, -): Promise<{ preferred_locale: string }> { - return backendFetch<{ preferred_locale: string }>("/api/me/preferences", accessToken, { - method: "PATCH", - body: JSON.stringify({ preferred_locale: preferredLocale }), - }); -} +import type { CustomerMasterResponse } from "./apiTransport"; +import { fetchCustomerMaster as fetchCustomerMasterTransport } from "./apiTransport"; +import { projectCustomerMasterResponse } from "./customerMasterProjection"; +/** + * Loads Customer Master through the raw transport, then creates the safe display projection. + * Other API functions remain direct re-exports so this boundary does not absorb unrelated + * domain behavior. + */ export function fetchCustomerMaster(accessToken: string): Promise { - return backendFetch("/api/customer-master", accessToken); -} - -export interface CustomerHintResolution { - corporate_entity_id: string; - entity_name: string; - linked_post_count: number; - verification_evidence_url: string | null; -} - -export function resolveCustomerHint( - accessToken: string, - hintCode: string, -): Promise { - return backendFetch("/api/customer-master/resolve-hint", accessToken, { - method: "POST", - body: JSON.stringify({ hint_code: hintCode }), - }); -} - -export function rebuildLineage(accessToken: string): Promise<{ edge_count: number }> { - return backendFetch("/api/lineage/rebuild", accessToken, { method: "POST" }); -} - -export function fetchPosts( - accessToken: string, - limit?: number, - offset?: number, - search?: string, - vocTypes?: string[], - visibility?: string, - sort?: PostSortOrder, -): Promise { - const params = new URLSearchParams(); - if (limit !== undefined) { - params.set("limit", String(limit)); - params.set("offset", String(offset ?? 0)); - } - if (search?.trim()) { - params.set("search", search.trim()); - } - for (const vocType of vocTypes ?? []) { - params.append("voc_type", vocType); - } - if (visibility) { - params.set("visibility", visibility); - } - if (sort) { - params.set("sort", sort); - } - const query = params.toString(); - return backendFetch(`/api/posts${query ? `?${query}` : ""}`, accessToken).then( - (payload) => - Array.isArray(payload) - ? { posts: payload, total_count: payload.length, limit: limit ?? payload.length, offset: offset ?? 0 } - : payload, - ); -} - -export function fetchPost( - accessToken: string, - postId: string, - asOf?: string, -): Promise { - const query = asOf ? `?as_of=${encodeURIComponent(asOf)}` : ""; - return backendFetch(`/api/posts/${postId}${query}`, accessToken); -} - -export function createPostVoiceAssignment( - accessToken: string, - postId: string, - voiceTypeCode: string, - truthStatusCode: string, -): Promise { - return backendFetch(`/api/posts/${postId}/voice-assignments`, accessToken, { - method: "POST", - body: JSON.stringify({ - voice_type_code: voiceTypeCode, - truth_status_code: truthStatusCode, - evidence_post_id: postId, - }), - }); -} - -export function fetchPostContent(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/content`, accessToken); -} - -export interface PostBookmark { - post_id: string; - bookmarked: boolean; -} - -export function fetchPostBookmark(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/bookmark`, accessToken); -} - -export function setPostBookmark( - accessToken: string, - postId: string, - bookmarked: boolean, -): Promise { - return backendFetch(`/api/posts/${postId}/bookmark`, accessToken, { - method: "POST", - body: JSON.stringify({ bookmarked }), - }); -} - -export function fetchPostKeymen( - accessToken: string, - postId: string, -): Promise<{ keymen: Keyman[]; source_author_context?: SourceAuthorContext | null }> { - return backendFetch(`/api/posts/${postId}/keymen`, accessToken); -} - -export function fetchPostCounterparties( - accessToken: string, - postId: string, -): Promise<{ counterparties: Counterparty[] }> { - return backendFetch(`/api/posts/${postId}/counterparties`, accessToken); -} - -export function fetchPostAffiliateTree( - accessToken: string, - postId: string, -): Promise<{ trees: AffiliateNode[] }> { - return backendFetch(`/api/posts/${postId}/affiliate-tree`, accessToken); -} - -export function fetchPostVocEvidence(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/voc-evidence`, accessToken); -} - -export interface SimilarVocItem { - post_id: string; - post_title: string; - issue_summary: string; - focal_evidence_text: string; - candidate_evidence_text: string; - customer_cohort_text: string | null; - action_history: string[]; - occurred_at: string; -} - -export function fetchSimilarVoc( - accessToken: string, - postId: string, - offset = 0, -): Promise<{ items: SimilarVocItem[]; next_offset: number | null }> { - const query = offset ? `?offset=${offset}` : ""; - return backendFetch(`/api/posts/${postId}/similar-voc${query}`, accessToken); -} - -export interface PersonRoleHistoryEntry { - post_id: string; - post_title: string; - created_at: string; - responsibility: string; - affiliated_organization_name: string | null; -} - -export function fetchRelatedKeymen( - accessToken: string, - personId: string, -): Promise<{ - person_id: string; - person_name: string; - person_side_code: string; - related: RelatedNode[]; - role_history?: PersonRoleHistoryEntry[]; -}> { - return backendFetch(`/api/keymen/${personId}/related`, accessToken); -} - -export function fetchRelatedEntity( - accessToken: string, - entityId: string, -): Promise<{ corporate_entity_id: string; entity_name: string; related: RelatedNode[] }> { - return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken); -} - -export function fetchRelatedTeam( - accessToken: string, - teamId: string, -): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> { - return backendFetch(`/api/teams/${teamId}/related`, accessToken); -} - -export interface OntologyGraphNodePayload { - node_id: string; - node_type_code: string; - ontology_class_iri: string; - display_label: string; - truth_status_code: string | null; - valid_from: string | null; - valid_to: string | null; - recorded_at: string | null; - evidence_count: number; - shape_code: string; -} - -export interface OntologyGraphEdgePayload { - edge_id: string; - source_node_type_code: string; - source_node_id: string; - target_node_type_code: string; - target_node_id: string; - property_code: string; - ontology_property_iri: string; - property_label: string; - truth_status_code: string; - valid_from: string | null; - valid_to: string | null; - recorded_at: string; - provenance_reference: string | null; - evidence_references: string[]; -} - -export interface OntologyExactValueRow { - edge_id: string; - source_node_id: string; - source_label: string; - source_type_code: string; - property_code: string; - property_label: string; - ontology_property_iri: string; - target_node_id: string; - target_label: string; - target_type_code: string; - truth_status_code: string; - recorded_at: string; - valid_from: string; - valid_to: string; - evidence_count: string; - evidence_post_id?: string; -} - -export interface OntologyVoiceAssignmentPayload { - post_id: string; - voice_type_code: string; - voice_type_iri: string; - voice_type_label: string; - is_primary: boolean; - truth_status_code: string; - recorded_at: string; - provenance_reference: string; - evidence_post_id: string | null; -} - -export interface OntologyNeighborhoodPayload { - focus_node_id: string; - focus_node_type_code: string; - truncated: boolean; - next_cursor: string | null; - limitation_code: string | null; - nodes: OntologyGraphNodePayload[]; - edges: OntologyGraphEdgePayload[]; - exact_value_rows: OntologyExactValueRow[]; - voice_assignments?: OntologyVoiceAssignmentPayload[]; - jsonld: Record; -} - -export interface OntologyNeighborhoodQuery { - focusNodeType: string; - focusNodeId: string; - maximumDepth?: number; - maximumNodes?: number; - maximumEdges?: number; - allowedPropertyCodes?: string[]; - knowledgeCutoff?: string; - cursor?: string; -} - -export function fetchOntologyNeighborhood( - accessToken: string, - query: OntologyNeighborhoodQuery, -): Promise { - const params = new URLSearchParams({ - focus_node_type: query.focusNodeType, - focus_node_id: query.focusNodeId, - }); - if (query.maximumDepth != null) params.set("maximum_depth", String(query.maximumDepth)); - if (query.maximumNodes != null) params.set("maximum_nodes", String(query.maximumNodes)); - if (query.maximumEdges != null) params.set("maximum_edges", String(query.maximumEdges)); - if (query.knowledgeCutoff) params.set("knowledge_cutoff", query.knowledgeCutoff); - if (query.cursor) params.set("cursor", query.cursor); - for (const code of query.allowedPropertyCodes ?? []) { - params.append("allowed_property_codes", code); - } - return backendFetch(`/api/ontology/neighborhood?${params.toString()}`, accessToken); -} - -export interface WorkerFunctionConstructPayload { - iri: string; - category: "cognitive" | "affective" | "behavioral"; - label: string; - dimension: string; - theoretical_basis: string; - definition: string; -} - -export interface WorkerFunctionProfilePayload { - function_domain: "data" | "people" | "things"; - function_rank: number; - function_label: string; - cognitive_demands: WorkerFunctionConstructPayload[]; - mental_workload_demands: WorkerFunctionConstructPayload[]; - affective_demands: WorkerFunctionConstructPayload[]; - emotional_labor_demands: WorkerFunctionConstructPayload[]; - behavioral_manifestations: WorkerFunctionConstructPayload[]; - psychomotor_behaviors: WorkerFunctionConstructPayload[]; - interpersonal_behaviors: WorkerFunctionConstructPayload[]; -} - -export interface WorkerFunctionRelationPayload { - source_iri: string; - source_label: string; - predicate_iri: string; - predicate_label: string; - target_iri: string; - target_label: string; - target_category: string; -} - -export interface WorkerFunctionConstructCatalogPayload { - constructs: Partial< - Record<"cognitive" | "affective" | "behavioral", WorkerFunctionConstructPayload[]> - >; - relations: WorkerFunctionRelationPayload[]; -} - -export function fetchWorkerFunctionProfile( - accessToken: string, - domain: string, - rank: number, -): Promise { - return backendFetch(`/api/ontology/worker-functions/${domain}/${rank}`, accessToken); -} - -export function fetchWorkerFunctionConstructCatalog( - accessToken: string, -): Promise { - return backendFetch("/api/ontology/worker-function-constructs", accessToken); -} - -export interface OccupationalConstructSearchHit { - construct_id: string; - construct_iri: string; - construct_family_code: string; - preferred_label: string; - vocabulary_version: string; - supporting_post_id: string; - supporting_post_title: string; - evidence_text: string; - truth_status_code: string; -} - -export interface OccupationalConstructSearchPage { - query: string; - family_code: string | null; - next_cursor: string | null; - hits: OccupationalConstructSearchHit[]; -} - -export interface OccupationalConstructSearchQuery { - query: string; - family?: string; - knowledgeCutoff?: string; - cursor?: string; - limit?: number; -} - -export function fetchOccupationalConstructSearch( - accessToken: string, - query: OccupationalConstructSearchQuery, -): Promise { - const params = new URLSearchParams({ q: query.query }); - if (query.family) params.set("family", query.family); - if (query.knowledgeCutoff) params.set("knowledge_cutoff", query.knowledgeCutoff); - if (query.cursor) params.set("cursor", query.cursor); - if (query.limit != null) params.set("limit", String(query.limit)); - return backendFetch(`/api/occupational-constructs/search?${params.toString()}`, accessToken); -} - -export function extractPostKeymen( - accessToken: string, - postId: string, -): Promise<{ extracted_count: number }> { - return backendFetch(`/api/posts/${postId}/extract-keymen`, accessToken, { method: "POST" }); -} - -export interface VerifiedRelation { - counterparty_entity_name: string; - verification_status_code: string; - verification_evidence_url: string | null; - verification_evidence_post_id: string | null; -} - -export function verifyPostRelations( - accessToken: string, - postId: string, -): Promise<{ verified: VerifiedRelation[] }> { - return backendFetch(`/api/posts/${postId}/verify-relations`, accessToken, { method: "POST" }); -} - -export interface EvaluationResponse { - criterion_code: string; - criterion_label: string | null; - response_category: number; - rubric_version: string; -} - -export interface PostEvaluation { - post_id: string; - rubric_version: string; - responses: EvaluationResponse[]; -} - -export function fetchPostEvaluation(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/evaluation`, accessToken); -} - -export function evaluatePost(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/evaluate`, accessToken, { method: "POST" }); -} - -export interface ReportMember { - post_id: string; - post_title: string; - theta_eap: number; - theta_sd: number; - ticket_due_date?: string | null; - ticket_title?: string | null; - ticket_status_code?: string | null; - ticket_status_label?: string | null; -} - -export interface SelectedReportItem { - item_code: string; - rank: number; - information: number; -} - -export interface LeftoverPair { - pair_kind: "closest" | "farthest" | string; - post_id: string; - post_title: string; - criterion_code: string; - leftover_distance: number; - leftover_residual: number; - observed_response?: number | null; - expected_response?: number | null; - leftover_map_rank?: number | null; - leftover_map_unexplained?: number | null; - leftover_map_cross_share?: number | null; - leftover_map_reconstruction?: number | null; - leftover_map_unexplained_share?: number | null; - leftover_map_explained_share?: number | null; - leftover_map_person_axis_1?: number | null; - leftover_map_person_axis_2?: number | null; - leftover_map_item_axis_1?: number | null; - leftover_map_item_axis_2?: number | null; -} - -export interface LeftoverMapAxis { - axis_index: number; - leftover_singular_value: number; - leftover_share: number; -} - -export interface LeftoverMapCoverage { - map_post_count: number; - scored_post_count: number; - map_item_count: number; - scored_item_count: number; - incomplete_post_count: number; - incomplete_item_count: number; -} - -export interface PeriodGroupReport { - grouping_key: string; - grouping_label?: string; - selected_model: string; - mean_theta: number; - mean_theta_sd: number; - post_count: number; - item_count: number; - fit_converged: boolean; - link_method: string; - anchor_period_code: string | null; - delta_mean_theta: number | null; - members: ReportMember[]; - selected_items: SelectedReportItem[]; - leftover_pairs: LeftoverPair[]; - leftover_map_axes?: LeftoverMapAxis[]; - leftover_map_coverage?: LeftoverMapCoverage | null; -} - -export interface PeriodReports { - grouping_kind: string; - period_code: string; - reports: PeriodGroupReport[]; -} - -export interface PeriodReportSummary { - grouping_key: string; - period_code: string; - selected_model: string; - mean_theta: number; - post_count: number; - link_method: string; - anchor_period_code: string | null; - delta_mean_theta: number | null; - selected_item_code: string | null; - selected_item_information: number | null; -} - -export interface PeriodReportIndex { - grouping_kind: string; - periods: PeriodReportSummary[]; -} - -export interface GroupingComparisonRow { - grouping_kind: string; - grouping_key: string; - grouping_label: string; - mean_theta: number; - post_count: number; - link_method: string; - leftover_pairs?: LeftoverPair[]; -} - -export interface PeriodComparison { - period_code: string; - groupings: GroupingComparisonRow[]; -} - -export function fetchPeriodComparison( - accessToken: string, - periodCode: string, -): Promise { - return backendFetch(`/api/reports/compare/${periodCode}`, accessToken); -} - -export function fetchPeriodReportIndex( - accessToken: string, - groupingKind: string, -): Promise { - return backendFetch(`/api/reports/${groupingKind}`, accessToken); -} - -export function fetchPeriodReports( - accessToken: string, - groupingKind: string, - periodCode: string, -): Promise { - return backendFetch(`/api/reports/${groupingKind}/${periodCode}`, accessToken); -} - -export function rebuildPeriodReports( - accessToken: string, - groupingKind: string, - periodCode: string, -): Promise<{ group_count: number }> { - return backendFetch(`/api/reports/${groupingKind}/${periodCode}/rebuild`, accessToken, { - method: "POST", - }); -} - -export function fetchPostSummary(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/summary`, accessToken); -} - -export function fetchPostFiveW1H(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/five-w1h`, accessToken); -} - -export function fetchPostLineage(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/lineage`, accessToken); -} - -export function fetchPostChat(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/chat`, accessToken); -} - -export function askPostChat(accessToken: string, postId: string, question: string): Promise { - return backendFetch(`/api/posts/${postId}/chat`, accessToken, { - method: "POST", - body: JSON.stringify({ question }), - }); -} - -/** How often the queued Ask job is polled, and for how long overall. - * A live orchestrator answer can take minutes under shared-gateway load, - * so the ceiling is generous; the poll interval keeps the reader's - * "Thinking..." state honest without hammering the backend. */ -const ASK_POLL_INTERVAL_MS = 2000; -// Must exceed the backend's whole pipeline for one job — queue wait plus -// the 600 s job deadline — and the e2e suite's own answer deadline, so a -// stored answer is never abandoned by the client that asked for it. -const ASK_POLL_CEILING_MS = 15 * 60 * 1000; - -interface AskJobStatus { - ask_job_id: string; - job_status_code: "queued" | "running" | "succeeded" | "failed"; - answer?: AskAgentResponse; - failure_detail?: string | null; -} - -export function optionalKnowledgeCutoffIso(value: string): string | undefined { - const input = value.trim(); - if (!input) return undefined; - const parsed = new Date(input); - if (Number.isNaN(parsed.getTime())) { - throw new RangeError("invalid knowledge cutoff"); - } - return parsed.toISOString(); -} - -/** Submit the question as an asynchronous job and poll it to completion. - * The signature and resolved value are unchanged from the old synchronous - * call, so callers (AskAgentPanel) keep their existing pending/complete - * states without modification. */ -export async function askAgent( - accessToken: string, - question: string, - verifyExternal = false, - knowledgeCutoff?: string, -): Promise { - const requestBody: { - question: string; - verify_external: boolean; - knowledge_cutoff?: string; - } = { question, verify_external: verifyExternal }; - if (knowledgeCutoff) requestBody.knowledge_cutoff = knowledgeCutoff; - const submitted = await backendFetch("/api/ask", accessToken, { - method: "POST", - body: JSON.stringify(requestBody), - }); - const deadline = Date.now() + ASK_POLL_CEILING_MS; - for (;;) { - const job = await backendFetch( - `/api/ask/jobs/${submitted.ask_job_id}`, - accessToken, - ); - if (job.job_status_code === "succeeded" && job.answer) { - return job.answer; - } - if (job.job_status_code === "failed") { - throw new Error(job.failure_detail || "Ask Agent could not answer this question."); - } - if (Date.now() > deadline) { - throw new Error("Ask Agent timed out waiting for an answer. Try again."); - } - await new Promise((resolve) => setTimeout(resolve, ASK_POLL_INTERVAL_MS)); - } -} - -export function fetchPostTickets(accessToken: string, postId: string): Promise<{ tickets: IssueTicket[] }> { - return backendFetch(`/api/posts/${postId}/tickets`, accessToken); -} - -export function createPostTicket( - accessToken: string, - postId: string, - ticketTitle: string, - ticketStatusCode: string, - dueDate?: string, -): Promise { - return backendFetch(`/api/posts/${postId}/tickets`, accessToken, { - method: "POST", - body: JSON.stringify({ - ticket_title: ticketTitle, - ticket_status_code: ticketStatusCode, - ...(dueDate ? { due_date: dueDate } : {}), - }), - }); -} - -export function updateTicketStatus( - accessToken: string, - issueTicketId: string, - ticketStatusCode: string, -): Promise { - return backendFetch(`/api/tickets/${issueTicketId}`, accessToken, { - method: "PATCH", - body: JSON.stringify({ ticket_status_code: ticketStatusCode }), - }); -} - -export interface OccupationRatingItem { - element_id: string; - element_name: string; - scale_id: string; - scale_name: string; - minimum_value: string; - maximum_value: string; - category_value: number | null; - data_value: string; - sample_size: number | null; - standard_error: string | null; - lower_ci_bound: string | null; - upper_ci_bound: string | null; - recommend_suppress: boolean | null; - not_relevant: boolean | null; - source_updated_month: string | null; - domain_source_code: string | null; -} - -export interface OccupationRatingProfile { - data_release_code: string; - source_table_code: string; - onetsoc_code: string; - source_available: boolean; - source: { - source_table_name: string; - source_artifact_url: string; - source_artifact_sha256: string; - source_row_count: number; - scale_artifact_url: string | null; - scale_artifact_sha256: string | null; - scale_source_row_count: number | null; - } | null; - items: OccupationRatingItem[]; - next_offset: number | null; -} - -export interface OccupationRatingSource { - data_release_code: string; - release_version: string; - source_publisher_name: string; - source_license_url: string; - source_table_code: string; - source_table_name: string; - source_artifact_url: string; - source_artifact_sha256: string; - source_row_count: number; -} - -export function fetchOccupationRatingSources( - accessToken: string, -): Promise<{ sources: OccupationRatingSource[] }> { - return backendFetch("/api/occupation-rating-sources", accessToken); -} - -export interface RatingSourceOccupation { - onetsoc_code: string; - occupation_title: string; -} - -export function fetchRatingSourceOccupations( - accessToken: string, - dataReleaseCode: string, - sourceTableCode: string, -): Promise<{ - data_release_code: string; - source_table_code: string; - source_available: boolean; - occupations: RatingSourceOccupation[]; -}> { - const params = new URLSearchParams({ - data_release_code: dataReleaseCode, - source_table_code: sourceTableCode, - }); - return backendFetch(`/api/occupation-rating-occupations?${params.toString()}`, accessToken); -} - -export function fetchOccupationRatings( - accessToken: string, - query: { - onetsocCode: string; - dataReleaseCode: string; - sourceTableCode: string; - limit?: number; - offset?: number; - }, -): Promise { - const params = new URLSearchParams({ - data_release_code: query.dataReleaseCode, - source_table_code: query.sourceTableCode, - limit: String(query.limit ?? 100), - offset: String(query.offset ?? 0), - }); - return backendFetch( - `/api/occupations/${encodeURIComponent(query.onetsocCode)}/ratings?${params.toString()}`, - accessToken, - ); -} - -export function fetchPostActivity( - accessToken: string, - postId: string, -): Promise<{ events: ActivityEvent[] }> { - return backendFetch(`/api/posts/${postId}/activity`, accessToken); -} - -export function deriveCommitment(accessToken: string, postId: string): Promise { - return backendFetch(`/api/posts/${postId}/derive-commitment`, accessToken, { method: "POST" }); -} - -export function fetchCalendar(accessToken: string): Promise { - return backendFetch("/api/calendar", accessToken); -} - -export interface AnalysisRunCount { - count_type_code: string; - count_type_label: string; - count_value: number; -} - -/** Registry kinds from `analysis_run.run_kind_code` (migration 0018, extended 0131). */ -export type AnalysisRunKindCode = - | "analysis_run_lineage" - | "analysis_run_report" - | "analysis_run_tepp" - | "analysis_run_topic_lineage"; - -/** Registry statuses from `analysis_run_status_event.status_code`. */ -export type AnalysisRunStatusCode = - | "analysis_status_pending" - | "analysis_status_running" - | "analysis_status_succeeded" - | "analysis_status_failed" - | "analysis_status_cancelled"; - -export interface AnalysisRunStatusEvent { - status_ordinal: number; - status_code: AnalysisRunStatusCode; - status_label: string; - occurred_at: string; - failure_code?: string; -} - -export interface AnalysisRunOutboxDelivery { - delivery_ordinal: number; - delivery_status_code: string; - delivery_status_label: string; - occurred_at: string; -} - -export interface AnalysisRunReconstructedEdge { - parent_post_id: string; - parent_post_title: string; - child_post_id: string; - child_post_title: string; - fused_score: number; -} - -export interface AnalysisRunVisiblePost { - post_id: string; - post_title: string; - updated_at?: string; - live_after_cutoff?: boolean; -} - -export interface AnalysisRunTeppAcceptedReceipt { - remote_run_id: string; - accepted_status_code: "accepted"; - received_at: string; -} - -export interface AnalysisRun { - analysis_run_id: string; - run_kind_code: AnalysisRunKindCode; - run_kind_label: string; - scope_kind_code: string; - scope_kind_label: string; - scope_entity_name?: string; - scope_key?: string; - scope_grouping_key?: string; - status_code: AnalysisRunStatusCode | null; - status_label: string | null; - failure_code?: string; - knowledge_cutoff: string; - requested_at: string; - source_counts: AnalysisRunCount[]; - status_history?: AnalysisRunStatusEvent[]; - outbox_deliveries?: AnalysisRunOutboxDelivery[]; - visible_posts?: AnalysisRunVisiblePost[]; - reconstructed_edges?: AnalysisRunReconstructedEdge[]; - reconstruction_result_sha256?: string; - topic_lineage_result?: Record; - topic_lineage_result_sha256?: string; - tepp_accepted_receipt?: AnalysisRunTeppAcceptedReceipt; - code_revision_sha?: string; - configuration_sha256?: string; -} - -export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { - return backendFetch("/api/analysis-runs", accessToken); -} - -export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { - return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); -} - -export interface CreateAnalysisRunRequest { - run_kind_code?: string; - scope_kind_code?: string; - corporate_entity_id?: string; - knowledge_cutoff?: string; - idempotency_key: string; -} - -export function createAnalysisRun( - accessToken: string, - request: CreateAnalysisRunRequest, -): Promise { - return backendFetch("/api/analysis-runs", accessToken, { - method: "POST", - body: JSON.stringify(request), - }); -} - -export function startAnalysisRun( - accessToken: string, - analysisRunId: string, -): Promise { - return backendFetch(`/api/analysis-runs/${analysisRunId}/start`, accessToken, { - method: "POST", - }); -} - -export interface RankingChannelEvidence { - signal_code: string; - signal_label: string; - channel_rank: number; - weight: number; - contribution: number; - rank: number; -} - -export interface RankedPost { - post_id: string; - post_title: string; - fused_rank: number; - channel_evidence?: RankingChannelEvidence[]; -} - -export interface RankingList { - port: string; - status: "accepted" | "unavailable"; - status_reason: string | null; - rankings: RankedPost[]; -} - -export function fetchRankings(accessToken: string): Promise { - return backendFetch("/api/rankings", accessToken); -} - -export function fetchTenantConfig(accessToken: string): Promise<{ brandName: string }> { - return backendFetch("/api/settings", accessToken); -} - -export function updateTenantConfig( - accessToken: string, - brandName: string, -): Promise<{ brandName: string }> { - return backendFetch("/api/settings", accessToken, { - method: "PATCH", - body: JSON.stringify({ brandName }), - }); + return fetchCustomerMasterTransport(accessToken).then(projectCustomerMasterResponse); } diff --git a/frontend/src/apiTransport.ts b/frontend/src/apiTransport.ts new file mode 100644 index 000000000..5db021a05 --- /dev/null +++ b/frontend/src/apiTransport.ts @@ -0,0 +1,1715 @@ +import { config } from "./config"; + +export interface PostSummary { + post_id: string; + post_title: string; + voc_type_code: string; + voc_type_label?: string; + voice_types?: PostVoiceType[]; + visibility_code: string; + visibility_label?: string; + source_stage_code?: string | null; + source_detail_state_code?: string | null; + source_draft_code?: string | null; + source_deleted_flag?: string | null; + source_author_code?: string | null; + source_author_name?: string | null; + source_company_code?: string | null; + source_company_name?: string | null; + source_process_unit_code?: string | null; + source_process_unit_name?: string | null; + source_sales_pool_code?: string | null; + source_sales_pool_name?: string | null; + source_customer_code?: string | null; + source_customer_name?: string | null; + source_project_code?: string | null; + source_project_name?: string | null; + source_system_code?: string | null; + source_record_key?: string | null; + publication_state_code?: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + project_evidence?: ProjectEvidence[]; + created_at: string; +} + +export interface PostVoiceType { + code: string; + label: string; + is_primary: boolean; + truth_status_code: string; + evidence_available: boolean; +} + +export interface PostPage { + posts: PostSummary[]; + total_count: number; + limit: number; + offset: number; + voc_type_options?: PostFilterOption[]; + voice_type_catalog?: PostFilterOption[]; + visibility_options?: PostFilterOption[]; +} + +export interface OperationsDashboardFact { + fact_type_code: string; + fact_type_label: string; + value_text: string; + evidence_text: string; + evidence_post_id: string; +} + +export interface OperationsDashboardCase { + post_id: string; + case_kind_code: string; + case_kind_label: string; + project_name: string | null; + summary_text: string; + evidence_text: string; + evidence_post_id: string; + occurred_at: string; + facts: OperationsDashboardFact[]; +} + +export interface OperationsDashboardResponse { + period_label: string; + total_post_count: number; + total_event_count: number; + external_post_count: number; + external_percent: number; + pending_analysis_count: number; + failed_analysis_count: number; + cases: OperationsDashboardCase[]; +} + +export function fetchOperationsDashboard( + accessToken: string, + periodStart = "", + periodEnd = "", +): Promise { + const query = new URLSearchParams(); + if (periodStart) query.set("period_start", periodStart); + if (periodEnd) query.set("period_end", periodEnd); + const suffix = query.size ? `?${query}` : ""; + return backendFetch(`/api/dashboard${suffix}`, accessToken); +} + +export interface PostFilterOption { + code: string; + label: string; +} + +export type PostSortOrder = "newest" | "oldest" | "title"; + +export interface PostKnownAt { + post_title: string; + post_body: string; + written_at: string; + as_of: string; +} + +export interface PostDetail extends PostSummary { + post_body: string; + occupational_construct_assertions: OccupationalConstructAssertion[]; + occupational_construct_evidence_status: + | "complete" + | "processing" + | "unavailable" + | "setup_required" + | "historical_unavailable"; + known_at?: PostKnownAt; +} + +export interface PostImageContent { + unit_index: number; + mime_type: string; + status_code: string; + extracted_text: string | null; + caption: string | null; + tags: string[]; + regions?: PostImageRegion[]; +} + +export interface PostImageRegion { + region_index: number; + x_ratio: number; + y_ratio: number; + width_ratio: number; + height_ratio: number; + status_code: string; + extracted_text: string | null; + caption: string | null; + tags: string[]; +} + +export interface PostContentResponse { + status?: "ready" | "processing" | "unavailable"; + units: PostContentUnit[]; + images: PostImageContent[]; +} + +export interface PostContentUnit { + unit_index: number; + unit_kind_code: string; + unit_label?: string; + unit_text: string; + indent_level: number; + indent_source_code: "explicit" | "llm" | "unresolved"; + indent_confidence: number; + indent_evidence: string; +} + +export interface Affiliation { + organization_name: string; + corporate_entity_id: string | null; + role_title: string | null; + organization_alias?: string; +} + +export interface Keyman { + person_id: string; + person_name: string; + person_side_code: string; + person_side_label?: string; + mention_context: string | null; + last_known_job_title: string | null; + affiliations: Affiliation[]; +} + +export interface Counterparty { + counterparty_entity_name: string; + relationship_type_code: string; + relationship_label?: string; + verification_status_code: string; + verification_evidence_url: string | null; + verification_evidence_post_id: string | null; + corporate_entity_id: string | null; + organization_alias?: string; +} + +export interface AffiliatePersonRef { + person_id: string; + person_name: string; + person_side_code: string; + person_side_label?: string; +} + +export interface AffiliateNode { + entity_id: string | null; + entity_name: string; + entity_level_code: string | null; + entity_level_label?: string | null; + resolved: boolean; + organization_alias?: string; + people: AffiliatePersonRef[]; + children: AffiliateNode[]; +} + +export interface VocEvidenceCounterparty { + counterparty_entity_name: string; + relationship_type_code: string; + relationship_label: string; + evidence_excerpt: string | null; + verification_status_code?: string; + verification_evidence_url?: string | null; +} + +export interface VocEvidence { + post_id: string; + voc_type_code: string; + voc_type_label: string; + excerpts: string[]; + counterparties: VocEvidenceCounterparty[]; +} + +export type RelatedNodeType = + | "node_person" + | "node_post" + | "node_corporate_entity" + | "node_team"; + +export interface RelatedNode { + node_id: string; + node_type_code: RelatedNodeType | string; + relevance: number; + label?: string; + organization_alias?: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + person_side_code?: string; + person_side_label?: string; + ontology_iri?: string; + ontology_label?: string; +} + +export interface PostRoleResponsibility { + actor_name: string; + responsibility: string; + actor_type_code: string; + affiliated_organization_name: string | null; + catalog_node_id?: string | null; + catalog_node_type_code?: string | null; +} + +export interface PostMajorEventAction { + action_text: string; + requester_actor_name: string | null; + processor_actor_name: string | null; + evidence_text: string; + project_name?: string | null; +} + +export interface PostProjectMention { + project_key: string; + project_name: string; + evidence: string; + confidence: number; + ontology_iri: string; + ontology_label?: string; + extraction_method: string; +} + +export interface ProjectEvidence { + project_key: string; + project_name: string; + evidence: string; + confidence: number | null; + ontology_iri: string; + ontology_label?: string; + extraction_method: string; + resolution_status: string; + provenance: string; +} + +export interface OccupationalConstructAssertion { + construct_iri: string; + construct_family_code: string; + preferred_label: string; + vocabulary_iri: string; + vocabulary_version: string; + evidence_text: string; + truth_status_code: string; + extraction_method: string; + generated_at: string; + unit_index: number; + provenance: string; +} + +export interface PostAiSummary { + post_id: string; + korean_summary: string; + summary_status?: "current" | "stale"; + summary_contract_version?: number | null; + key_events: string[]; + key_event_details?: PostKeyEvent[]; + roles_and_responsibilities: PostRoleResponsibility[]; + major_event_actions?: PostMajorEventAction[]; + project_mentions?: PostProjectMention[]; +} + +export interface PostKeyEvent { + event_text: string; + project_name?: string | null; +} + +export interface FiveW1HValue { + text: string; + source: string; + evidence_text?: string; + ontology_codes: string[]; + ontology_annotations: Record; +} + +export interface FiveW1HSlot { + slot_code: "who" | "what" | "when" | "where" | "why" | "how"; + values: FiveW1HValue[]; + empty_next_action_code: string; +} + +export interface PostFiveW1H { + post_id: string; + slots: FiveW1HSlot[]; +} + +export interface LinkedPostRef { + post_id: string; + post_title: string; + post_body_excerpt?: string | null; + post_body_truncated?: boolean; + interval_relation_code?: string; + interval_relation_label?: string; + interval_is_parent?: boolean; +} + +export interface PostLineage { + post_id: string; + direct: LinkedPostRef[]; + indirect: LinkedPostRef[]; +} + +export interface CitedPostRef { + post_id: string; + post_title: string; + source_post_revision_id?: string | null; + evidence_available_at?: string | null; + knowledge_cutoff?: string | null; + live_changed_after_cutoff?: boolean; + historical_body_unavailable?: boolean; + unavailable_channels?: string[]; + evidence_open_action?: EvidenceOpenAction; +} + +export interface EvidenceOpenAction { + action_kind: "open_cited_content_unit"; + post_id: string; + unit_index: number; +} + +export interface CitedPostEvidenceFact { + kind: string; + text: string; +} + +export interface CitedPostEvidence { + post_id: string; + facts: CitedPostEvidenceFact[]; +} + +export interface ChatAnswer { + post_id: string; + answer_text: string; + cited_post_ids: string[]; + cited_posts?: CitedPostRef[]; + source_post_ids: string[]; +} + +export interface ChatExchange { + question_text: string; + answer_text: string; + cited_post_ids: string[]; + cited_posts?: CitedPostRef[]; +} + +export interface ChatHistory { + post_id: string; + exchanges: ChatExchange[]; +} + +export interface CitedPostImage { + post_id: string; + unit_index: number; + mime_type: string; + status_code: string; + extracted_text: string | null; + caption: string | null; + tags: string[]; +} + +export interface AskAgentResponse { + answer_text: string; + cited_post_ids: string[]; + cited_posts?: CitedPostRef[]; + cited_post_evidence?: CitedPostEvidence[]; + cited_post_images?: CitedPostImage[]; + source_post_ids: string[]; + external_verification_status?: string; + external_claims?: ExternalClaim[]; + next_action?: string; + knowledge_cutoff?: string | null; + grounding_status?: "live_only" | "fully_cutoff_grounded" | "partially_cutoff_grounded"; + limitations?: Array<{ + post_id: string; + limitation_code: "historical_body_unavailable"; + unavailable_channels: string[]; + }>; + lineage_graph?: LineageGraph; + delivery?: { + contract_version: string; + report: { + media_type: string; + body: string; + source_documents: Array<{ + post_id: string; + title: string; + api_path: string; + resource_uri: string; + evidence_facts: CitedPostEvidenceFact[]; + }>; + }; + alert: { + trigger_code: string; + delivery_status_code: string; + eligible: boolean; + watched_resource_uris: string[]; + }; + }; +} + +export interface ExternalClaimEvidence { + title: string; + url: string; + snippet: string; +} + +export interface ExternalClaim { + claim_text: string; + claim_kind: string; + status_code: string; + rationale: string; + source_post_ids: string[]; + evidence: ExternalClaimEvidence[]; +} + +export interface IssueTicket { + issue_ticket_id: string; + post_id: string; + ticket_status_code: string; + ticket_status_label?: string; + ticket_title: string; + assigned_account_id: string | null; + due_date: string | null; + commitment_summary: string | null; + created_at: string; + updated_at: string; +} + +export interface CalendarEntry extends IssueTicket { + post_title: string; +} + +export interface NaruonCalendarEvent { + occurrence_reference: string; + event_reference: string; + source_reference: string; + display_text: string; + starts_at: string; + ends_at: string; + all_day: boolean; + time_zone: string; + status_code: string; + disclosure_code: string; + truth_status_code: string; + observed_at: string; + provider_revision: string; +} + +export interface CalendarResponse { + events: NaruonCalendarEvent[]; + commitments: CalendarEntry[]; + calendar_sources: { + naruon_available: boolean; + naruon_next_action: string | null; + }; +} + +export interface DerivedCommitment { + post_id: string; + has_commitment: boolean; + ticket: IssueTicket | null; +} + +export interface ActivityEvent { + event_id: string; + event_type: string; + actor_account_id: string; + summary: string; +} + +export class BackendError extends Error { + readonly status: number; + + constructor(path: string, status: number, detail?: string) { + const message = + status === 0 + ? "The service is unreachable. Try again later." + : status >= 500 + ? "The service could not complete this request. Try again later." + : detail && detail.trim() + ? detail + : `${path} -> HTTP ${status}`; + super(message); + this.name = "BackendError"; + this.status = status; + } +} + +export function fetchProjectHistory( + accessToken: string, + projectKey: string, + focusPostId: string, + knowledgeCutoff?: string | null, +): Promise { + const query = new URLSearchParams({ focus_post_id: focusPostId }); + if (knowledgeCutoff) query.set("knowledge_cutoff", knowledgeCutoff); + return backendFetch( + `/api/projects/${encodeURIComponent(projectKey)}/history?${query.toString()}`, + accessToken, + ); +} + +async function backendFetch( + path: string, + accessToken: string, + init?: RequestInit, +): Promise { + let response: Response; + try { + response = await fetch(`${config.backendBaseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${accessToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + }); + } catch { + throw new BackendError(path, 0); + } + if (!response.ok) { + let detail: string | undefined; + try { + const body: unknown = await response.json(); + if (body && typeof body === "object" && "detail" in body && typeof body.detail === "string") { + detail = body.detail; + } + } catch { + detail = undefined; + } + throw new BackendError(path, response.status, detail); + } + return response.json() as Promise; +} + +export interface LineageGraphNode { + id: string; + group: string; + label: string; + occurred_at: string; + is_root: boolean; + is_branch_point: boolean; +} + +export interface LineageChannelEvidence { + signal_code: string; + signal_label: string; + score: number; + weight: number; + contribution: number; + rank: number; +} + +export interface LineageRebuildProfile { + reconstruction_version: string; + generated_at: string; + min_fused_score: number; + candidate_window: number; + active_weights: { signal_code: string; signal_weight: number }[]; +} + +export interface LineageGraphEdge { + source: string; + target: string; + fused_score: number; + channel_evidence?: LineageChannelEvidence[]; + interval_relation_code?: string; + interval_relation_label?: string; +} + +export interface LineageGraph { + nodes: LineageGraphNode[]; + edges: LineageGraphEdge[]; + truncated?: boolean; + reconstruction?: LineageRebuildProfile | null; + isolation_reason?: "comparison_candidates_available" | "no_comparison_group" | null; +} + +export function fetchLineageGraph(accessToken: string, postId?: string): Promise { + const query = postId ? `?post_id=${encodeURIComponent(postId)}` : ""; + return backendFetch(`/api/lineage${query}`, accessToken); +} + +export interface CorporateEntityRef { + corporate_entity_id: string; + entity_name: string; +} + +export interface CustomerMasterEntity extends CorporateEntityRef { + corporate_entity_code: string; + entity_level_code: string; + entity_level_label: string; + parent_entity_id: string | null; +} + +export interface CustomerMasterKeymanAffiliation { + organization_name: string; + corporate_entity_id: string | null; + entity_name: string | null; + role_title: string | null; +} + +export interface CustomerMasterKeyman { + person_id: string; + person_name: string; + person_side_code: string; + person_side_label: string; + last_known_job_title: string | null; + affiliations: CustomerMasterKeymanAffiliation[]; +} + +export interface SourceCustomerHint { + customer_code: string | null; + customer_name: string | null; + post_count: number; + related_posts: LinkedPostRef[]; + resolution_status: string; + hint_trust: string; + provenance: string; +} + +export interface SourceAuthorAffiliation { + corporate_entity_id: string; + entity_name: string; + process_unit_code: string | null; + process_unit_name: string | null; +} + +export interface SourceAuthorContext { + author_account_id: string; + account_display_name: string; + source_author_code: string | null; + source_author_name: string | null; + account_affiliations: SourceAuthorAffiliation[]; + resolution_status: string; + provenance: string; +} + +export interface SourceAuthorKeymanHint { + person_id: string; + person_name: string; + person_side_code: string; + last_known_job_title: string | null; + mention_count: number; + provenance: string; +} + +export interface SourceAuthorHint { + author_code: string; + author_name: string | null; + author_account_id: string; + account_display_name: string; + account_affiliations: SourceAuthorAffiliation[]; + post_count: number; + keyman_hints: SourceAuthorKeymanHint[]; + related_posts: LinkedPostRef[]; + resolution_status: string; + provenance: string; +} + +export interface CounterpartyRelationshipRole { + relationship_type_code: string; + relationship_label: string; + post_count: number; +} + +export interface RelationshipNetworkEntry { + counterparty_entity_name: string; + corporate_entity_id: string | null; + total_post_count: number; + relationships: CounterpartyRelationshipRole[]; + multi_role: boolean; +} + +export interface CustomerMasterResponse { + corporate_entities: CustomerMasterEntity[]; + keymen: CustomerMasterKeyman[]; + source_customer_hints: SourceCustomerHint[]; + source_author_hints: SourceAuthorHint[]; + relationship_network: RelationshipNetworkEntry[]; +} + +export interface CurrentUser { + user_account_id: string; + display_name: string; + permission_codes: string[]; + corporate_entities?: CorporateEntityRef[]; + preferred_locale?: string | null; +} + +export function fetchMe(accessToken: string): Promise { + return backendFetch("/api/me", accessToken); +} + +export function setPreferredLocale( + accessToken: string, + preferredLocale: string, +): Promise<{ preferred_locale: string }> { + return backendFetch<{ preferred_locale: string }>("/api/me/preferences", accessToken, { + method: "PATCH", + body: JSON.stringify({ preferred_locale: preferredLocale }), + }); +} + +export function fetchCustomerMaster(accessToken: string): Promise { + return backendFetch("/api/customer-master", accessToken); +} + +export interface CustomerHintResolution { + corporate_entity_id: string; + entity_name: string; + linked_post_count: number; + verification_evidence_url: string | null; +} + +export function resolveCustomerHint( + accessToken: string, + hintCode: string, +): Promise { + return backendFetch("/api/customer-master/resolve-hint", accessToken, { + method: "POST", + body: JSON.stringify({ hint_code: hintCode }), + }); +} + +export function rebuildLineage(accessToken: string): Promise<{ edge_count: number }> { + return backendFetch("/api/lineage/rebuild", accessToken, { method: "POST" }); +} + +export function fetchPosts( + accessToken: string, + limit?: number, + offset?: number, + search?: string, + vocTypes?: string[], + visibility?: string, + sort?: PostSortOrder, +): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) { + params.set("limit", String(limit)); + params.set("offset", String(offset ?? 0)); + } + if (search?.trim()) { + params.set("search", search.trim()); + } + for (const vocType of vocTypes ?? []) { + params.append("voc_type", vocType); + } + if (visibility) { + params.set("visibility", visibility); + } + if (sort) { + params.set("sort", sort); + } + const query = params.toString(); + return backendFetch(`/api/posts${query ? `?${query}` : ""}`, accessToken).then( + (payload) => + Array.isArray(payload) + ? { posts: payload, total_count: payload.length, limit: limit ?? payload.length, offset: offset ?? 0 } + : payload, + ); +} + +export function fetchPost( + accessToken: string, + postId: string, + asOf?: string, +): Promise { + const query = asOf ? `?as_of=${encodeURIComponent(asOf)}` : ""; + return backendFetch(`/api/posts/${postId}${query}`, accessToken); +} + +export function createPostVoiceAssignment( + accessToken: string, + postId: string, + voiceTypeCode: string, + truthStatusCode: string, +): Promise { + return backendFetch(`/api/posts/${postId}/voice-assignments`, accessToken, { + method: "POST", + body: JSON.stringify({ + voice_type_code: voiceTypeCode, + truth_status_code: truthStatusCode, + evidence_post_id: postId, + }), + }); +} + +export function fetchPostContent(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/content`, accessToken); +} + +export interface PostBookmark { + post_id: string; + bookmarked: boolean; +} + +export function fetchPostBookmark(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/bookmark`, accessToken); +} + +export function setPostBookmark( + accessToken: string, + postId: string, + bookmarked: boolean, +): Promise { + return backendFetch(`/api/posts/${postId}/bookmark`, accessToken, { + method: "POST", + body: JSON.stringify({ bookmarked }), + }); +} + +export function fetchPostKeymen( + accessToken: string, + postId: string, +): Promise<{ keymen: Keyman[]; source_author_context?: SourceAuthorContext | null }> { + return backendFetch(`/api/posts/${postId}/keymen`, accessToken); +} + +export function fetchPostCounterparties( + accessToken: string, + postId: string, +): Promise<{ counterparties: Counterparty[] }> { + return backendFetch(`/api/posts/${postId}/counterparties`, accessToken); +} + +export function fetchPostAffiliateTree( + accessToken: string, + postId: string, +): Promise<{ trees: AffiliateNode[] }> { + return backendFetch(`/api/posts/${postId}/affiliate-tree`, accessToken); +} + +export function fetchPostVocEvidence(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/voc-evidence`, accessToken); +} + +export interface SimilarVocItem { + post_id: string; + post_title: string; + issue_summary: string; + focal_evidence_text: string; + candidate_evidence_text: string; + customer_cohort_text: string | null; + action_history: string[]; + occurred_at: string; +} + +export function fetchSimilarVoc( + accessToken: string, + postId: string, + offset = 0, +): Promise<{ items: SimilarVocItem[]; next_offset: number | null }> { + const query = offset ? `?offset=${offset}` : ""; + return backendFetch(`/api/posts/${postId}/similar-voc${query}`, accessToken); +} + +export interface PersonRoleHistoryEntry { + post_id: string; + post_title: string; + created_at: string; + responsibility: string; + affiliated_organization_name: string | null; +} + +export function fetchRelatedKeymen( + accessToken: string, + personId: string, +): Promise<{ + person_id: string; + person_name: string; + person_side_code: string; + related: RelatedNode[]; + role_history?: PersonRoleHistoryEntry[]; +}> { + return backendFetch(`/api/keymen/${personId}/related`, accessToken); +} + +export function fetchRelatedEntity( + accessToken: string, + entityId: string, +): Promise<{ corporate_entity_id: string; entity_name: string; related: RelatedNode[] }> { + return backendFetch(`/api/corporate-entities/${entityId}/related`, accessToken); +} + +export function fetchRelatedTeam( + accessToken: string, + teamId: string, +): Promise<{ team_id: string; team_name: string; related: RelatedNode[] }> { + return backendFetch(`/api/teams/${teamId}/related`, accessToken); +} + +export interface OntologyGraphNodePayload { + node_id: string; + node_type_code: string; + ontology_class_iri: string; + display_label: string; + truth_status_code: string | null; + valid_from: string | null; + valid_to: string | null; + recorded_at: string | null; + evidence_count: number; + shape_code: string; +} + +export interface OntologyGraphEdgePayload { + edge_id: string; + source_node_type_code: string; + source_node_id: string; + target_node_type_code: string; + target_node_id: string; + property_code: string; + ontology_property_iri: string; + property_label: string; + truth_status_code: string; + valid_from: string | null; + valid_to: string | null; + recorded_at: string; + provenance_reference: string | null; + evidence_references: string[]; +} + +export interface OntologyExactValueRow { + edge_id: string; + source_node_id: string; + source_label: string; + source_type_code: string; + property_code: string; + property_label: string; + ontology_property_iri: string; + target_node_id: string; + target_label: string; + target_type_code: string; + truth_status_code: string; + recorded_at: string; + valid_from: string; + valid_to: string; + evidence_count: string; + evidence_post_id?: string; +} + +export interface OntologyVoiceAssignmentPayload { + post_id: string; + voice_type_code: string; + voice_type_iri: string; + voice_type_label: string; + is_primary: boolean; + truth_status_code: string; + recorded_at: string; + provenance_reference: string; + evidence_post_id: string | null; +} + +export interface OntologyNeighborhoodPayload { + focus_node_id: string; + focus_node_type_code: string; + truncated: boolean; + next_cursor: string | null; + limitation_code: string | null; + nodes: OntologyGraphNodePayload[]; + edges: OntologyGraphEdgePayload[]; + exact_value_rows: OntologyExactValueRow[]; + voice_assignments?: OntologyVoiceAssignmentPayload[]; + jsonld: Record; +} + +export interface OntologyNeighborhoodQuery { + focusNodeType: string; + focusNodeId: string; + maximumDepth?: number; + maximumNodes?: number; + maximumEdges?: number; + allowedPropertyCodes?: string[]; + knowledgeCutoff?: string; + cursor?: string; +} + +export function fetchOntologyNeighborhood( + accessToken: string, + query: OntologyNeighborhoodQuery, +): Promise { + const params = new URLSearchParams({ + focus_node_type: query.focusNodeType, + focus_node_id: query.focusNodeId, + }); + if (query.maximumDepth != null) params.set("maximum_depth", String(query.maximumDepth)); + if (query.maximumNodes != null) params.set("maximum_nodes", String(query.maximumNodes)); + if (query.maximumEdges != null) params.set("maximum_edges", String(query.maximumEdges)); + if (query.knowledgeCutoff) params.set("knowledge_cutoff", query.knowledgeCutoff); + if (query.cursor) params.set("cursor", query.cursor); + for (const code of query.allowedPropertyCodes ?? []) { + params.append("allowed_property_codes", code); + } + return backendFetch(`/api/ontology/neighborhood?${params.toString()}`, accessToken); +} + +export interface WorkerFunctionConstructPayload { + iri: string; + category: "cognitive" | "affective" | "behavioral"; + label: string; + dimension: string; + theoretical_basis: string; + definition: string; +} + +export interface WorkerFunctionProfilePayload { + function_domain: "data" | "people" | "things"; + function_rank: number; + function_label: string; + cognitive_demands: WorkerFunctionConstructPayload[]; + mental_workload_demands: WorkerFunctionConstructPayload[]; + affective_demands: WorkerFunctionConstructPayload[]; + emotional_labor_demands: WorkerFunctionConstructPayload[]; + behavioral_manifestations: WorkerFunctionConstructPayload[]; + psychomotor_behaviors: WorkerFunctionConstructPayload[]; + interpersonal_behaviors: WorkerFunctionConstructPayload[]; +} + +export interface WorkerFunctionRelationPayload { + source_iri: string; + source_label: string; + predicate_iri: string; + predicate_label: string; + target_iri: string; + target_label: string; + target_category: string; +} + +export interface WorkerFunctionConstructCatalogPayload { + constructs: Partial< + Record<"cognitive" | "affective" | "behavioral", WorkerFunctionConstructPayload[]> + >; + relations: WorkerFunctionRelationPayload[]; +} + +export function fetchWorkerFunctionProfile( + accessToken: string, + domain: string, + rank: number, +): Promise { + return backendFetch(`/api/ontology/worker-functions/${domain}/${rank}`, accessToken); +} + +export function fetchWorkerFunctionConstructCatalog( + accessToken: string, +): Promise { + return backendFetch("/api/ontology/worker-function-constructs", accessToken); +} + +export interface OccupationalConstructSearchHit { + construct_id: string; + construct_iri: string; + construct_family_code: string; + preferred_label: string; + vocabulary_version: string; + supporting_post_id: string; + supporting_post_title: string; + evidence_text: string; + truth_status_code: string; +} + +export interface OccupationalConstructSearchPage { + query: string; + family_code: string | null; + next_cursor: string | null; + hits: OccupationalConstructSearchHit[]; +} + +export interface OccupationalConstructSearchQuery { + query: string; + family?: string; + knowledgeCutoff?: string; + cursor?: string; + limit?: number; +} + +export function fetchOccupationalConstructSearch( + accessToken: string, + query: OccupationalConstructSearchQuery, +): Promise { + const params = new URLSearchParams({ q: query.query }); + if (query.family) params.set("family", query.family); + if (query.knowledgeCutoff) params.set("knowledge_cutoff", query.knowledgeCutoff); + if (query.cursor) params.set("cursor", query.cursor); + if (query.limit != null) params.set("limit", String(query.limit)); + return backendFetch(`/api/occupational-constructs/search?${params.toString()}`, accessToken); +} + +export function extractPostKeymen( + accessToken: string, + postId: string, +): Promise<{ extracted_count: number }> { + return backendFetch(`/api/posts/${postId}/extract-keymen`, accessToken, { method: "POST" }); +} + +export interface VerifiedRelation { + counterparty_entity_name: string; + verification_status_code: string; + verification_evidence_url: string | null; + verification_evidence_post_id: string | null; +} + +export function verifyPostRelations( + accessToken: string, + postId: string, +): Promise<{ verified: VerifiedRelation[] }> { + return backendFetch(`/api/posts/${postId}/verify-relations`, accessToken, { method: "POST" }); +} + +export interface EvaluationResponse { + criterion_code: string; + criterion_label: string | null; + response_category: number; + rubric_version: string; +} + +export interface PostEvaluation { + post_id: string; + rubric_version: string; + responses: EvaluationResponse[]; +} + +export function fetchPostEvaluation(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/evaluation`, accessToken); +} + +export function evaluatePost(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/evaluate`, accessToken, { method: "POST" }); +} + +export interface ReportMember { + post_id: string; + post_title: string; + theta_eap: number; + theta_sd: number; + ticket_due_date?: string | null; + ticket_title?: string | null; + ticket_status_code?: string | null; + ticket_status_label?: string | null; +} + +export interface SelectedReportItem { + item_code: string; + rank: number; + information: number; +} + +export interface LeftoverPair { + pair_kind: "closest" | "farthest" | string; + post_id: string; + post_title: string; + criterion_code: string; + leftover_distance: number; + leftover_residual: number; + observed_response?: number | null; + expected_response?: number | null; + leftover_map_rank?: number | null; + leftover_map_unexplained?: number | null; + leftover_map_cross_share?: number | null; + leftover_map_reconstruction?: number | null; + leftover_map_unexplained_share?: number | null; + leftover_map_explained_share?: number | null; + leftover_map_person_axis_1?: number | null; + leftover_map_person_axis_2?: number | null; + leftover_map_item_axis_1?: number | null; + leftover_map_item_axis_2?: number | null; +} + +export interface LeftoverMapAxis { + axis_index: number; + leftover_singular_value: number; + leftover_share: number; +} + +export interface LeftoverMapCoverage { + map_post_count: number; + scored_post_count: number; + map_item_count: number; + scored_item_count: number; + incomplete_post_count: number; + incomplete_item_count: number; +} + +export interface PeriodGroupReport { + grouping_key: string; + grouping_label?: string; + selected_model: string; + mean_theta: number; + mean_theta_sd: number; + post_count: number; + item_count: number; + fit_converged: boolean; + link_method: string; + anchor_period_code: string | null; + delta_mean_theta: number | null; + members: ReportMember[]; + selected_items: SelectedReportItem[]; + leftover_pairs: LeftoverPair[]; + leftover_map_axes?: LeftoverMapAxis[]; + leftover_map_coverage?: LeftoverMapCoverage | null; +} + +export interface PeriodReports { + grouping_kind: string; + period_code: string; + reports: PeriodGroupReport[]; +} + +export interface PeriodReportSummary { + grouping_key: string; + period_code: string; + selected_model: string; + mean_theta: number; + post_count: number; + link_method: string; + anchor_period_code: string | null; + delta_mean_theta: number | null; + selected_item_code: string | null; + selected_item_information: number | null; +} + +export interface PeriodReportIndex { + grouping_kind: string; + periods: PeriodReportSummary[]; +} + +export interface GroupingComparisonRow { + grouping_kind: string; + grouping_key: string; + grouping_label: string; + mean_theta: number; + post_count: number; + link_method: string; + leftover_pairs?: LeftoverPair[]; +} + +export interface PeriodComparison { + period_code: string; + groupings: GroupingComparisonRow[]; +} + +export function fetchPeriodComparison( + accessToken: string, + periodCode: string, +): Promise { + return backendFetch(`/api/reports/compare/${periodCode}`, accessToken); +} + +export function fetchPeriodReportIndex( + accessToken: string, + groupingKind: string, +): Promise { + return backendFetch(`/api/reports/${groupingKind}`, accessToken); +} + +export function fetchPeriodReports( + accessToken: string, + groupingKind: string, + periodCode: string, +): Promise { + return backendFetch(`/api/reports/${groupingKind}/${periodCode}`, accessToken); +} + +export function rebuildPeriodReports( + accessToken: string, + groupingKind: string, + periodCode: string, +): Promise<{ group_count: number }> { + return backendFetch(`/api/reports/${groupingKind}/${periodCode}/rebuild`, accessToken, { + method: "POST", + }); +} + +export function fetchPostSummary(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/summary`, accessToken); +} + +export function fetchPostFiveW1H(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/five-w1h`, accessToken); +} + +export function fetchPostLineage(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/lineage`, accessToken); +} + +export function fetchPostChat(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/chat`, accessToken); +} + +export function askPostChat(accessToken: string, postId: string, question: string): Promise { + return backendFetch(`/api/posts/${postId}/chat`, accessToken, { + method: "POST", + body: JSON.stringify({ question }), + }); +} + +/** How often the queued Ask job is polled, and for how long overall. + * A live orchestrator answer can take minutes under shared-gateway load, + * so the ceiling is generous; the poll interval keeps the reader's + * "Thinking..." state honest without hammering the backend. */ +const ASK_POLL_INTERVAL_MS = 2000; +// Must exceed the backend's whole pipeline for one job — queue wait plus +// the 600 s job deadline — and the e2e suite's own answer deadline, so a +// stored answer is never abandoned by the client that asked for it. +const ASK_POLL_CEILING_MS = 15 * 60 * 1000; + +interface AskJobStatus { + ask_job_id: string; + job_status_code: "queued" | "running" | "succeeded" | "failed"; + answer?: AskAgentResponse; + failure_detail?: string | null; +} + +export function optionalKnowledgeCutoffIso(value: string): string | undefined { + const input = value.trim(); + if (!input) return undefined; + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) { + throw new RangeError("invalid knowledge cutoff"); + } + return parsed.toISOString(); +} + +/** Submit the question as an asynchronous job and poll it to completion. + * The signature and resolved value are unchanged from the old synchronous + * call, so callers (AskAgentPanel) keep their existing pending/complete + * states without modification. */ +export async function askAgent( + accessToken: string, + question: string, + verifyExternal = false, + knowledgeCutoff?: string, +): Promise { + const requestBody: { + question: string; + verify_external: boolean; + knowledge_cutoff?: string; + } = { question, verify_external: verifyExternal }; + if (knowledgeCutoff) requestBody.knowledge_cutoff = knowledgeCutoff; + const submitted = await backendFetch("/api/ask", accessToken, { + method: "POST", + body: JSON.stringify(requestBody), + }); + const deadline = Date.now() + ASK_POLL_CEILING_MS; + for (;;) { + const job = await backendFetch( + `/api/ask/jobs/${submitted.ask_job_id}`, + accessToken, + ); + if (job.job_status_code === "succeeded" && job.answer) { + return job.answer; + } + if (job.job_status_code === "failed") { + throw new Error(job.failure_detail || "Ask Agent could not answer this question."); + } + if (Date.now() > deadline) { + throw new Error("Ask Agent timed out waiting for an answer. Try again."); + } + await new Promise((resolve) => setTimeout(resolve, ASK_POLL_INTERVAL_MS)); + } +} + +export function fetchPostTickets(accessToken: string, postId: string): Promise<{ tickets: IssueTicket[] }> { + return backendFetch(`/api/posts/${postId}/tickets`, accessToken); +} + +export function createPostTicket( + accessToken: string, + postId: string, + ticketTitle: string, + ticketStatusCode: string, + dueDate?: string, +): Promise { + return backendFetch(`/api/posts/${postId}/tickets`, accessToken, { + method: "POST", + body: JSON.stringify({ + ticket_title: ticketTitle, + ticket_status_code: ticketStatusCode, + ...(dueDate ? { due_date: dueDate } : {}), + }), + }); +} + +export function updateTicketStatus( + accessToken: string, + issueTicketId: string, + ticketStatusCode: string, +): Promise { + return backendFetch(`/api/tickets/${issueTicketId}`, accessToken, { + method: "PATCH", + body: JSON.stringify({ ticket_status_code: ticketStatusCode }), + }); +} + +export interface OccupationRatingItem { + element_id: string; + element_name: string; + scale_id: string; + scale_name: string; + minimum_value: string; + maximum_value: string; + category_value: number | null; + data_value: string; + sample_size: number | null; + standard_error: string | null; + lower_ci_bound: string | null; + upper_ci_bound: string | null; + recommend_suppress: boolean | null; + not_relevant: boolean | null; + source_updated_month: string | null; + domain_source_code: string | null; +} + +export interface OccupationRatingProfile { + data_release_code: string; + source_table_code: string; + onetsoc_code: string; + source_available: boolean; + source: { + source_table_name: string; + source_artifact_url: string; + source_artifact_sha256: string; + source_row_count: number; + scale_artifact_url: string | null; + scale_artifact_sha256: string | null; + scale_source_row_count: number | null; + } | null; + items: OccupationRatingItem[]; + next_offset: number | null; +} + +export interface OccupationRatingSource { + data_release_code: string; + release_version: string; + source_publisher_name: string; + source_license_url: string; + source_table_code: string; + source_table_name: string; + source_artifact_url: string; + source_artifact_sha256: string; + source_row_count: number; +} + +export function fetchOccupationRatingSources( + accessToken: string, +): Promise<{ sources: OccupationRatingSource[] }> { + return backendFetch("/api/occupation-rating-sources", accessToken); +} + +export interface RatingSourceOccupation { + onetsoc_code: string; + occupation_title: string; +} + +export function fetchRatingSourceOccupations( + accessToken: string, + dataReleaseCode: string, + sourceTableCode: string, +): Promise<{ + data_release_code: string; + source_table_code: string; + source_available: boolean; + occupations: RatingSourceOccupation[]; +}> { + const params = new URLSearchParams({ + data_release_code: dataReleaseCode, + source_table_code: sourceTableCode, + }); + return backendFetch(`/api/occupation-rating-occupations?${params.toString()}`, accessToken); +} + +export function fetchOccupationRatings( + accessToken: string, + query: { + onetsocCode: string; + dataReleaseCode: string; + sourceTableCode: string; + limit?: number; + offset?: number; + }, +): Promise { + const params = new URLSearchParams({ + data_release_code: query.dataReleaseCode, + source_table_code: query.sourceTableCode, + limit: String(query.limit ?? 100), + offset: String(query.offset ?? 0), + }); + return backendFetch( + `/api/occupations/${encodeURIComponent(query.onetsocCode)}/ratings?${params.toString()}`, + accessToken, + ); +} + +export function fetchPostActivity( + accessToken: string, + postId: string, +): Promise<{ events: ActivityEvent[] }> { + return backendFetch(`/api/posts/${postId}/activity`, accessToken); +} + +export function deriveCommitment(accessToken: string, postId: string): Promise { + return backendFetch(`/api/posts/${postId}/derive-commitment`, accessToken, { method: "POST" }); +} + +export function fetchCalendar(accessToken: string): Promise { + return backendFetch("/api/calendar", accessToken); +} + +export interface AnalysisRunCount { + count_type_code: string; + count_type_label: string; + count_value: number; +} + +/** Registry kinds from `analysis_run.run_kind_code` (migration 0018, extended 0131). */ +export type AnalysisRunKindCode = + | "analysis_run_lineage" + | "analysis_run_report" + | "analysis_run_tepp" + | "analysis_run_topic_lineage"; + +/** Registry statuses from `analysis_run_status_event.status_code`. */ +export type AnalysisRunStatusCode = + | "analysis_status_pending" + | "analysis_status_running" + | "analysis_status_succeeded" + | "analysis_status_failed" + | "analysis_status_cancelled"; + +export interface AnalysisRunStatusEvent { + status_ordinal: number; + status_code: AnalysisRunStatusCode; + status_label: string; + occurred_at: string; + failure_code?: string; +} + +export interface AnalysisRunOutboxDelivery { + delivery_ordinal: number; + delivery_status_code: string; + delivery_status_label: string; + occurred_at: string; +} + +export interface AnalysisRunReconstructedEdge { + parent_post_id: string; + parent_post_title: string; + child_post_id: string; + child_post_title: string; + fused_score: number; +} + +export interface AnalysisRunVisiblePost { + post_id: string; + post_title: string; + updated_at?: string; + live_after_cutoff?: boolean; +} + +export interface AnalysisRunTeppAcceptedReceipt { + remote_run_id: string; + accepted_status_code: "accepted"; + received_at: string; +} + +export interface AnalysisRun { + analysis_run_id: string; + run_kind_code: AnalysisRunKindCode; + run_kind_label: string; + scope_kind_code: string; + scope_kind_label: string; + scope_entity_name?: string; + scope_key?: string; + scope_grouping_key?: string; + status_code: AnalysisRunStatusCode | null; + status_label: string | null; + failure_code?: string; + knowledge_cutoff: string; + requested_at: string; + source_counts: AnalysisRunCount[]; + status_history?: AnalysisRunStatusEvent[]; + outbox_deliveries?: AnalysisRunOutboxDelivery[]; + visible_posts?: AnalysisRunVisiblePost[]; + reconstructed_edges?: AnalysisRunReconstructedEdge[]; + reconstruction_result_sha256?: string; + topic_lineage_result?: Record; + topic_lineage_result_sha256?: string; + tepp_accepted_receipt?: AnalysisRunTeppAcceptedReceipt; + code_revision_sha?: string; + configuration_sha256?: string; +} + +export function fetchAnalysisRuns(accessToken: string): Promise<{ analysis_runs: AnalysisRun[] }> { + return backendFetch("/api/analysis-runs", accessToken); +} + +export function fetchAnalysisRun(accessToken: string, analysisRunId: string): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}`, accessToken); +} + +export interface CreateAnalysisRunRequest { + run_kind_code?: string; + scope_kind_code?: string; + corporate_entity_id?: string; + knowledge_cutoff?: string; + idempotency_key: string; +} + +export function createAnalysisRun( + accessToken: string, + request: CreateAnalysisRunRequest, +): Promise { + return backendFetch("/api/analysis-runs", accessToken, { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function startAnalysisRun( + accessToken: string, + analysisRunId: string, +): Promise { + return backendFetch(`/api/analysis-runs/${analysisRunId}/start`, accessToken, { + method: "POST", + }); +} + +export interface RankingChannelEvidence { + signal_code: string; + signal_label: string; + channel_rank: number; + weight: number; + contribution: number; + rank: number; +} + +export interface RankedPost { + post_id: string; + post_title: string; + fused_rank: number; + channel_evidence?: RankingChannelEvidence[]; +} + +export interface RankingList { + port: string; + status: "accepted" | "unavailable"; + status_reason: string | null; + rankings: RankedPost[]; +} + +export function fetchRankings(accessToken: string): Promise { + return backendFetch("/api/rankings", accessToken); +} + +export function fetchTenantConfig(accessToken: string): Promise<{ brandName: string }> { + return backendFetch("/api/settings", accessToken); +} + +export function updateTenantConfig( + accessToken: string, + brandName: string, +): Promise<{ brandName: string }> { + return backendFetch("/api/settings", accessToken, { + method: "PATCH", + body: JSON.stringify({ brandName }), + }); +} diff --git a/frontend/src/customerMasterProjection.ts b/frontend/src/customerMasterProjection.ts new file mode 100644 index 000000000..96e6cb3a4 --- /dev/null +++ b/frontend/src/customerMasterProjection.ts @@ -0,0 +1,44 @@ +import type { CustomerMasterEntity, CustomerMasterResponse } from "./apiTransport"; +import { + buildCustomerEntityTree, + type CustomerEntityTreeNode, + type CustomerHierarchyIssue, +} from "./customerMasterTree"; + +const HIERARCHY_ISSUE_DISPLAY: Record = { + cycle_parent_ignored: "Cyclic parent link omitted", + self_parent_ignored: "Self-parent link omitted", + parent_not_available: "Parent not available in this authorized view", +}; + +function flattenDisplayTree( + nodes: CustomerEntityTreeNode[], + parentEntityId: string | null, + output: CustomerMasterEntity[], +): void { + for (const node of nodes) { + const suffix = node.hierarchyIssue ? ` · ${HIERARCHY_ISSUE_DISPLAY[node.hierarchyIssue]}` : ""; + output.push({ + ...node.entity, + parent_entity_id: parentEntityId, + entity_level_label: `${node.entity.entity_level_label}${suffix}`, + }); + flattenDisplayTree(node.children, node.entity.corporate_entity_id, output); + } +} + +/** + * Produces the Customer Master display projection consumed by the existing tree UI. + * + * The API response remains immutable. Only the frontend projection rewrites malformed + * parent pointers to the deterministic visible forest and composes disclosure into the + * existing display label. `entity_level_code` and every other authoritative source fact + * are preserved exactly; no corrected parent is invented or persisted. + */ +export function projectCustomerMasterResponse( + response: CustomerMasterResponse, +): CustomerMasterResponse { + const corporateEntities: CustomerMasterEntity[] = []; + flattenDisplayTree(buildCustomerEntityTree(response.corporate_entities), null, corporateEntities); + return { ...response, corporate_entities: corporateEntities }; +} From 47f8ab37ae103d1201912d8082357153699b1016 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:06:53 +0900 Subject: [PATCH 05/26] refactor(customer): depend on raw transport DTO --- frontend/src/customerMasterTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index 844e78c23..8630a3784 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -1,4 +1,4 @@ -import type { CustomerMasterEntity } from "./api"; +import type { CustomerMasterEntity } from "./apiTransport"; export type CustomerHierarchyIssue = | "cycle_parent_ignored" From 0bc574be714bd08c99be1b83630b3a84fab224a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:50:21 +0900 Subject: [PATCH 06/26] test(customer): reproduce stale Customer Master response overwrite --- .../src/customerMasterRequestGate.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 frontend/src/customerMasterRequestGate.test.ts diff --git a/frontend/src/customerMasterRequestGate.test.ts b/frontend/src/customerMasterRequestGate.test.ts new file mode 100644 index 000000000..7b691aa24 --- /dev/null +++ b/frontend/src/customerMasterRequestGate.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { CustomerMasterRequestGate } from "./customerMasterRequestGate"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("CustomerMasterRequestGate", () => { + it("prevents an older response from overwriting the newest Customer Master request", async () => { + const gate = new CustomerMasterRequestGate(); + const older = deferred(); + const newer = deferred(); + + const olderResult = gate.run(() => older.promise); + const newerResult = gate.run(() => newer.promise); + + older.resolve("old-account-view"); + newer.resolve("new-account-view"); + + await expect(olderResult).resolves.toBe("new-account-view"); + await expect(newerResult).resolves.toBe("new-account-view"); + }); + + it("ignores a stale request failure once a newer request owns the view", async () => { + const gate = new CustomerMasterRequestGate(); + const older = deferred(); + const newer = deferred(); + + const olderResult = gate.run(() => older.promise); + const newerResult = gate.run(() => newer.promise); + + older.reject(new Error("stale authorization failed")); + newer.resolve("current-account-view"); + + await expect(olderResult).resolves.toBe("current-account-view"); + await expect(newerResult).resolves.toBe("current-account-view"); + }); + + it("still surfaces a failure from the current request", async () => { + const gate = new CustomerMasterRequestGate(); + const current = deferred(); + const result = gate.run(() => current.promise); + + current.reject(new Error("current request failed")); + + await expect(result).rejects.toThrow("current request failed"); + }); +}); From d3286e268f5d2ab8333ab1eb6bf649fdc7c32a4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:50:42 +0900 Subject: [PATCH 07/26] fix(customer): keep stale Customer Master requests from replacing current view --- frontend/src/customerMasterRequestGate.ts | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/src/customerMasterRequestGate.ts diff --git a/frontend/src/customerMasterRequestGate.ts b/frontend/src/customerMasterRequestGate.ts new file mode 100644 index 000000000..dc50ad1a8 --- /dev/null +++ b/frontend/src/customerMasterRequestGate.ts @@ -0,0 +1,29 @@ +/** + * Keeps Customer Master rendering bound to the newest request started by the view. + * + * A token/account transition can leave an older HTTP request in flight after a newer + * request has started. React callers cannot distinguish those promises once they resolve, + * so an older response could otherwise overwrite the current authorized view. Stale + * completions adopt the newest request's result instead of exposing their own payload. + */ +export class CustomerMasterRequestGate { + private generation = 0; + private latestRequest: Promise | null = null; + + run(start: () => Promise): Promise { + const generation = ++this.generation; + const pending = start(); + const guarded = pending.then( + (value) => { + if (generation !== this.generation) return this.latestRequest!; + return value; + }, + (error: unknown) => { + if (generation !== this.generation) return this.latestRequest!; + throw error; + }, + ); + this.latestRequest = guarded; + return guarded; + } +} From c583fff4adae3f4d85eba4504b7b96eb797a4885 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:50:52 +0900 Subject: [PATCH 08/26] fix(customer): bind Customer Master to newest request --- frontend/src/api.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 639c7aa62..02188d3a8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -3,12 +3,18 @@ export * from "./apiTransport"; import type { CustomerMasterResponse } from "./apiTransport"; import { fetchCustomerMaster as fetchCustomerMasterTransport } from "./apiTransport"; import { projectCustomerMasterResponse } from "./customerMasterProjection"; +import { CustomerMasterRequestGate } from "./customerMasterRequestGate"; + +const customerMasterRequestGate = new CustomerMasterRequestGate(); /** * Loads Customer Master through the raw transport, then creates the safe display projection. - * Other API functions remain direct re-exports so this boundary does not absorb unrelated - * domain behavior. + * A newer request owns the visible result so an older account/token response cannot overwrite + * the current authorized view. Other API functions remain direct re-exports so this boundary + * does not absorb unrelated domain behavior. */ export function fetchCustomerMaster(accessToken: string): Promise { - return fetchCustomerMasterTransport(accessToken).then(projectCustomerMasterResponse); + return customerMasterRequestGate.run(() => + fetchCustomerMasterTransport(accessToken).then(projectCustomerMasterResponse), + ); } From 9cae92f8918ed1713fb369fcda330ed70c2022f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:50:02 +0900 Subject: [PATCH 09/26] test(customer): keep request ownership when start throws --- frontend/src/customerMasterRequestGate.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/customerMasterRequestGate.test.ts b/frontend/src/customerMasterRequestGate.test.ts index 7b691aa24..be131fc67 100644 --- a/frontend/src/customerMasterRequestGate.test.ts +++ b/frontend/src/customerMasterRequestGate.test.ts @@ -42,6 +42,22 @@ describe("CustomerMasterRequestGate", () => { await expect(newerResult).resolves.toBe("current-account-view"); }); + it("does not supersede the current request when the next request cannot start", async () => { + const gate = new CustomerMasterRequestGate(); + const current = deferred(); + const currentResult = gate.run(() => current.promise); + + expect(() => + gate.run(() => { + throw new Error("request construction failed"); + }), + ).toThrow("request construction failed"); + + current.resolve("still-current-account-view"); + + await expect(currentResult).resolves.toBe("still-current-account-view"); + }); + it("still surfaces a failure from the current request", async () => { const gate = new CustomerMasterRequestGate(); const current = deferred(); From 99891b52f29043024f3459407282b06c31345d79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:50:40 +0900 Subject: [PATCH 10/26] fix(customer): preserve request owner on sync start failure --- frontend/src/customerMasterRequestGate.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/customerMasterRequestGate.ts b/frontend/src/customerMasterRequestGate.ts index dc50ad1a8..ee3dad82a 100644 --- a/frontend/src/customerMasterRequestGate.ts +++ b/frontend/src/customerMasterRequestGate.ts @@ -5,6 +5,7 @@ * request has started. React callers cannot distinguish those promises once they resolve, * so an older response could otherwise overwrite the current authorized view. Stale * completions adopt the newest request's result instead of exposing their own payload. + * A request that throws before returning its promise never takes ownership of the view. */ export class CustomerMasterRequestGate { private generation = 0; @@ -12,7 +13,13 @@ export class CustomerMasterRequestGate { run(start: () => Promise): Promise { const generation = ++this.generation; - const pending = start(); + let pending: Promise; + try { + pending = start(); + } catch (error) { + if (this.generation === generation) this.generation = generation - 1; + throw error; + } const guarded = pending.then( (value) => { if (generation !== this.generation) return this.latestRequest!; From f6b9c324e7a735edcaccc6f3d55c5a5aacef7385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:45:53 +0900 Subject: [PATCH 11/26] test(customer): reproduce re-entrant request ownership cycle --- frontend/src/customerMasterRequestGate.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/customerMasterRequestGate.test.ts b/frontend/src/customerMasterRequestGate.test.ts index be131fc67..98852b2b3 100644 --- a/frontend/src/customerMasterRequestGate.test.ts +++ b/frontend/src/customerMasterRequestGate.test.ts @@ -58,6 +58,24 @@ describe("CustomerMasterRequestGate", () => { await expect(currentResult).resolves.toBe("still-current-account-view"); }); + it("keeps a re-entrant newer request as owner when an older start returns afterward", async () => { + const gate = new CustomerMasterRequestGate(); + const outer = deferred(); + const newest = deferred(); + let newestResult!: Promise; + + const outerResult = gate.run(() => { + newestResult = gate.run(() => newest.promise); + return outer.promise; + }); + + outer.resolve("stale-outer-view"); + newest.resolve("newest-account-view"); + + await expect(newestResult).resolves.toBe("newest-account-view"); + await expect(outerResult).resolves.toBe("newest-account-view"); + }); + it("still surfaces a failure from the current request", async () => { const gate = new CustomerMasterRequestGate(); const current = deferred(); From 1fda2834ad9dc4306975b4b68f1f769be5d4b5b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:46:38 +0900 Subject: [PATCH 12/26] fix(customer): preserve re-entrant newest request ownership --- frontend/src/customerMasterRequestGate.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/customerMasterRequestGate.ts b/frontend/src/customerMasterRequestGate.ts index ee3dad82a..cfc2e22c0 100644 --- a/frontend/src/customerMasterRequestGate.ts +++ b/frontend/src/customerMasterRequestGate.ts @@ -5,7 +5,9 @@ * request has started. React callers cannot distinguish those promises once they resolve, * so an older response could otherwise overwrite the current authorized view. Stale * completions adopt the newest request's result instead of exposing their own payload. - * A request that throws before returning its promise never takes ownership of the view. + * A request that throws before returning its promise never takes ownership of the view, + * and an outer start callback cannot reclaim ownership after starting a newer request + * re-entrantly. */ export class CustomerMasterRequestGate { private generation = 0; @@ -30,7 +32,7 @@ export class CustomerMasterRequestGate { throw error; }, ); - this.latestRequest = guarded; + if (this.generation === generation) this.latestRequest = guarded; return guarded; } } From 2fdcf6c1ef31e9ce42531103e6b844cd23c781e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:52:21 +0900 Subject: [PATCH 13/26] test(customer): reject duplicate customer identities --- frontend/src/customerMasterTree.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/customerMasterTree.test.ts b/frontend/src/customerMasterTree.test.ts index ec5eec814..9d124b56e 100644 --- a/frontend/src/customerMasterTree.test.ts +++ b/frontend/src/customerMasterTree.test.ts @@ -45,6 +45,15 @@ describe("buildCustomerEntityTree", () => { } }); + it("rejects duplicate corporate entity identities instead of choosing one row", () => { + expect(() => + buildCustomerEntityTree([ + entity("duplicate", "Original entity", null), + entity("duplicate", "Conflicting entity", null), + ]), + ).toThrow("duplicate corporate_entity_id: duplicate"); + }); + it("keeps ordinary parent-child structure deterministic", () => { const parent = entity("parent", "Parent", null); const childB = entity("child-b", "Child B", "parent"); From 7b6d86fb01decf572b6b9de57f7ca1101486e2b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:52:45 +0900 Subject: [PATCH 14/26] fix(customer): fail closed on duplicate entity identities --- frontend/src/customerMasterTree.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index 8630a3784..b2cb31b79 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -19,19 +19,40 @@ function compareEntity(left: CustomerMasterEntity, right: CustomerMasterEntity): return 0; } +/** + * Indexes one authorized entity row per canonical corporate entity identity. + * + * A duplicated identity makes parent and child references ambiguous: silently taking + * either row would convert response ordering into business truth. The Customer Master + * therefore fails closed so the existing request error state can disclose a data-integrity + * failure instead of rendering a fabricated hierarchy. + */ +function indexEntitiesById(entities: CustomerMasterEntity[]): Map { + const byId = new Map(); + for (const entity of entities) { + if (byId.has(entity.corporate_entity_id)) { + throw new Error(`duplicate corporate_entity_id: ${entity.corporate_entity_id}`); + } + byId.set(entity.corporate_entity_id, entity); + } + return byId; +} + /** * Builds the authorized Customer Master hierarchy without hiding malformed records. * * Parent pointers are presentation evidence, not permission to discard an otherwise * authorized entity. Missing parents, self-parent edges, and one deterministic edge * per pure cycle are therefore omitted from the rendered forest and disclosed on the - * promoted root. No replacement parent or organization is invented. Ordering uses - * code-point comparison rather than runtime locale so repeated renders are stable. + * promoted root. Conflicting duplicate entity identities fail closed because there is no + * safe presentation-only rule for choosing one source row. No replacement parent or + * organization is invented. Ordering uses code-point comparison rather than runtime locale + * so repeated renders are stable. */ export function buildCustomerEntityTree( entities: CustomerMasterEntity[], ): CustomerEntityTreeNode[] { - const byId = new Map(entities.map((entity) => [entity.corporate_entity_id, entity])); + const byId = indexEntitiesById(entities); const parentById = new Map(); const issueById = new Map(); From 8d0db3e89b6e5875babebd67d42701d77e3d97a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:06:07 +0900 Subject: [PATCH 15/26] test(customer): reproduce deep hierarchy call-stack failure --- frontend/src/customerMasterDepth.test.ts | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/src/customerMasterDepth.test.ts diff --git a/frontend/src/customerMasterDepth.test.ts b/frontend/src/customerMasterDepth.test.ts new file mode 100644 index 000000000..41f76444e --- /dev/null +++ b/frontend/src/customerMasterDepth.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { CustomerMasterEntity, CustomerMasterResponse } from "./apiTransport"; +import { projectCustomerMasterResponse } from "./customerMasterProjection"; + +function deepHierarchy(depth: number): CustomerMasterEntity[] { + return Array.from({ length: depth }, (_, index) => ({ + corporate_entity_id: `entity-${index.toString().padStart(5, "0")}`, + corporate_entity_code: `ENTITY_${index}`, + entity_name: `Entity ${index.toString().padStart(5, "0")}`, + entity_level_code: "company", + entity_level_label: "Company", + parent_entity_id: + index === 0 ? null : `entity-${(index - 1).toString().padStart(5, "0")}`, + })); +} + +describe("Customer Master deep hierarchy", () => { + it("projects a deep authorized hierarchy without recursive call-stack failure", () => { + const response: CustomerMasterResponse = { + corporate_entities: deepHierarchy(12_000), + keymen: [], + source_customer_hints: [], + source_author_hints: [], + relationship_network: [], + }; + + const projected = projectCustomerMasterResponse(response); + + expect(projected.corporate_entities).toHaveLength(12_000); + expect(projected.corporate_entities[0].corporate_entity_id).toBe("entity-00000"); + expect(projected.corporate_entities.at(-1)?.corporate_entity_id).toBe("entity-11999"); + expect(projected.corporate_entities.at(-1)?.parent_entity_id).toBe("entity-11998"); + }); +}); From 9e5d2bb889960830491766907d539ab6974ac2c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:06:29 +0900 Subject: [PATCH 16/26] fix(customer): materialize deep hierarchy iteratively --- frontend/src/customerMasterTree.ts | 34 +++++++++++++++++------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index b2cb31b79..52ce6e75f 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -47,7 +47,8 @@ function indexEntitiesById(entities: CustomerMasterEntity[]): Map(); - const roots: CustomerMasterEntity[] = []; + const nodeById = new Map(); for (const entity of entities) { + nodeById.set(entity.corporate_entity_id, { + entity, + hierarchyIssue: issueById.get(entity.corporate_entity_id) ?? null, + children: [], + }); + } + + const roots: CustomerEntityTreeNode[] = []; + for (const entity of entities) { + const node = nodeById.get(entity.corporate_entity_id)!; const parentId = parentById.get(entity.corporate_entity_id) ?? null; if (!parentId) { - roots.push(entity); + roots.push(node); continue; } - const children = childrenByParent.get(parentId) ?? []; - children.push(entity); - childrenByParent.set(parentId, children); + nodeById.get(parentId)!.children.push(node); } - const toNode = (entity: CustomerMasterEntity): CustomerEntityTreeNode => ({ - entity, - hierarchyIssue: issueById.get(entity.corporate_entity_id) ?? null, - children: [...(childrenByParent.get(entity.corporate_entity_id) ?? [])] - .sort(compareEntity) - .map(toNode), - }); + for (const node of nodeById.values()) { + node.children.sort((left, right) => compareEntity(left.entity, right.entity)); + } - return roots.sort(compareEntity).map(toNode); + return roots.sort((left, right) => compareEntity(left.entity, right.entity)); } From 0244be1ec677e19270835eb8dc2ee00b034596d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:06:44 +0900 Subject: [PATCH 17/26] fix(customer): flatten deep hierarchy iteratively --- frontend/src/customerMasterProjection.ts | 29 ++++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/frontend/src/customerMasterProjection.ts b/frontend/src/customerMasterProjection.ts index 96e6cb3a4..7a756c7e2 100644 --- a/frontend/src/customerMasterProjection.ts +++ b/frontend/src/customerMasterProjection.ts @@ -16,14 +16,27 @@ function flattenDisplayTree( parentEntityId: string | null, output: CustomerMasterEntity[], ): void { - for (const node of nodes) { - const suffix = node.hierarchyIssue ? ` · ${HIERARCHY_ISSUE_DISPLAY[node.hierarchyIssue]}` : ""; + const pending = [...nodes] + .reverse() + .map((node) => ({ node, parentEntityId })); + + while (pending.length > 0) { + const current = pending.pop()!; + const suffix = current.node.hierarchyIssue + ? ` · ${HIERARCHY_ISSUE_DISPLAY[current.node.hierarchyIssue]}` + : ""; output.push({ - ...node.entity, - parent_entity_id: parentEntityId, - entity_level_label: `${node.entity.entity_level_label}${suffix}`, + ...current.node.entity, + parent_entity_id: current.parentEntityId, + entity_level_label: `${current.node.entity.entity_level_label}${suffix}`, }); - flattenDisplayTree(node.children, node.entity.corporate_entity_id, output); + + for (let index = current.node.children.length - 1; index >= 0; index -= 1) { + pending.push({ + node: current.node.children[index], + parentEntityId: current.node.entity.corporate_entity_id, + }); + } } } @@ -33,7 +46,9 @@ function flattenDisplayTree( * The API response remains immutable. Only the frontend projection rewrites malformed * parent pointers to the deterministic visible forest and composes disclosure into the * existing display label. `entity_level_code` and every other authoritative source fact - * are preserved exactly; no corrected parent is invented or persisted. + * are preserved exactly; no corrected parent is invented or persisted. Traversal is + * iterative so a valid deep hierarchy cannot fail solely because of JavaScript call-stack + * depth. */ export function projectCustomerMasterResponse( response: CustomerMasterResponse, From fe019f2b1db23668d6f98a9eb01142d1cfefe562 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:56:41 +0900 Subject: [PATCH 18/26] test(customer): disclose empty parent identity --- frontend/src/customerMasterTree.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/customerMasterTree.test.ts b/frontend/src/customerMasterTree.test.ts index 9d124b56e..67198a616 100644 --- a/frontend/src/customerMasterTree.test.ts +++ b/frontend/src/customerMasterTree.test.ts @@ -30,6 +30,16 @@ describe("buildCustomerEntityTree", () => { ]); }); + it("discloses an empty malformed parent identity instead of treating it as a root", () => { + const forest = buildCustomerEntityTree([ + entity("malformed-parent", "Malformed parent", ""), + ]); + + expect(forest).toHaveLength(1); + expect(forest[0].entity.corporate_entity_id).toBe("malformed-parent"); + expect(forest[0].hierarchyIssue).toBe("parent_not_available"); + }); + it("breaks a pure cycle deterministically without dropping either entity", () => { const alpha = entity("alpha", "Alpha", "beta"); const beta = entity("beta", "Beta", "alpha"); From 453b39a027cf8fb097a6686bcd6359dbdb6f737e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:57:04 +0900 Subject: [PATCH 19/26] fix(customer): distinguish null from malformed empty parent --- frontend/src/customerMasterTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index 52ce6e75f..2fc2edaf9 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -59,7 +59,7 @@ export function buildCustomerEntityTree( for (const entity of entities) { const parentId = entity.parent_entity_id; - if (!parentId) { + if (parentId === null) { parentById.set(entity.corporate_entity_id, null); } else if (parentId === entity.corporate_entity_id) { parentById.set(entity.corporate_entity_id, null); From 4932a02a8a37e6b4b8490ea008f180aa200d4082 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:19:36 +0900 Subject: [PATCH 20/26] test(customer): expose anonymous canonical entity roots --- frontend/src/customerMasterTree.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/customerMasterTree.test.ts b/frontend/src/customerMasterTree.test.ts index 67198a616..6cacaf363 100644 --- a/frontend/src/customerMasterTree.test.ts +++ b/frontend/src/customerMasterTree.test.ts @@ -64,6 +64,16 @@ describe("buildCustomerEntityTree", () => { ).toThrow("duplicate corporate_entity_id: duplicate"); }); + it("rejects blank canonical entity identities instead of rendering anonymous roots", () => { + for (const malformedId of ["", " "]) { + expect(() => + buildCustomerEntityTree([ + entity(malformedId, "Missing canonical identity", null), + ]), + ).toThrow("corporate_entity_id must be a non-blank string"); + } + }); + it("keeps ordinary parent-child structure deterministic", () => { const parent = entity("parent", "Parent", null); const childB = entity("child-b", "Child B", "parent"); From d7040670d0c8a30e5c984d5ca0fb357e6419a945 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:20:07 +0900 Subject: [PATCH 21/26] fix(customer): reject blank canonical entity identities --- frontend/src/customerMasterTree.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index 2fc2edaf9..b5f48cee1 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -22,14 +22,21 @@ function compareEntity(left: CustomerMasterEntity, right: CustomerMasterEntity): /** * Indexes one authorized entity row per canonical corporate entity identity. * - * A duplicated identity makes parent and child references ambiguous: silently taking - * either row would convert response ordering into business truth. The Customer Master - * therefore fails closed so the existing request error state can disclose a data-integrity - * failure instead of rendering a fabricated hierarchy. + * A missing/blank canonical identity cannot safely participate in hierarchy joins, and a + * duplicated identity makes parent and child references ambiguous. Rendering either case + * would convert malformed transport data or response ordering into business truth. The + * Customer Master therefore fails closed so the existing request error state can disclose + * a data-integrity failure instead of rendering a fabricated hierarchy. */ function indexEntitiesById(entities: CustomerMasterEntity[]): Map { const byId = new Map(); for (const entity of entities) { + if ( + typeof entity.corporate_entity_id !== "string" || + entity.corporate_entity_id.trim().length === 0 + ) { + throw new Error("corporate_entity_id must be a non-blank string"); + } if (byId.has(entity.corporate_entity_id)) { throw new Error(`duplicate corporate_entity_id: ${entity.corporate_entity_id}`); } @@ -44,11 +51,11 @@ function indexEntitiesById(entities: CustomerMasterEntity[]): Map Date: Wed, 2 Sep 2026 22:42:57 +0900 Subject: [PATCH 22/26] test(customer): reject canonical entity id whitespace aliases --- frontend/src/customerMasterTree.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/customerMasterTree.test.ts b/frontend/src/customerMasterTree.test.ts index 6cacaf363..601e236dc 100644 --- a/frontend/src/customerMasterTree.test.ts +++ b/frontend/src/customerMasterTree.test.ts @@ -74,6 +74,16 @@ describe("buildCustomerEntityTree", () => { } }); + it("rejects surrounding whitespace in canonical entity identities instead of creating aliases", () => { + for (const malformedId of [" entity-id", "entity-id ", "\tentity-id", "entity-id\n"]) { + expect(() => + buildCustomerEntityTree([ + entity(malformedId, "Aliased canonical identity", null), + ]), + ).toThrow("corporate_entity_id must not contain surrounding whitespace"); + } + }); + it("keeps ordinary parent-child structure deterministic", () => { const parent = entity("parent", "Parent", null); const childB = entity("child-b", "Child B", "parent"); From 5b9aabd8d694dcd3a475bd352199c863e4085e37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:43:28 +0900 Subject: [PATCH 23/26] fix(customer): reject canonical entity id whitespace aliases --- frontend/src/customerMasterTree.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index b5f48cee1..9d21b21cf 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -22,11 +22,12 @@ function compareEntity(left: CustomerMasterEntity, right: CustomerMasterEntity): /** * Indexes one authorized entity row per canonical corporate entity identity. * - * A missing/blank canonical identity cannot safely participate in hierarchy joins, and a - * duplicated identity makes parent and child references ambiguous. Rendering either case - * would convert malformed transport data or response ordering into business truth. The - * Customer Master therefore fails closed so the existing request error state can disclose - * a data-integrity failure instead of rendering a fabricated hierarchy. + * A missing/blank or whitespace-aliased canonical identity cannot safely participate in + * hierarchy joins, and a duplicated identity makes parent and child references ambiguous. + * Rendering any of those cases would convert malformed transport data or response ordering + * into business truth. The Customer Master therefore fails closed so the existing request + * error state can disclose a data-integrity failure instead of rendering fabricated authority. + * Opaque identities are rejected rather than silently trimmed or otherwise normalized. */ function indexEntitiesById(entities: CustomerMasterEntity[]): Map { const byId = new Map(); @@ -37,6 +38,9 @@ function indexEntitiesById(entities: CustomerMasterEntity[]): Map Date: Fri, 4 Sep 2026 23:23:22 +0900 Subject: [PATCH 24/26] fix(web): bound authenticated API requests Reject remote cleartext API destinations before attaching authorization and apply the established Ask deadline to submission and poll requests. Signed-off-by: Codex --- ...-authenticated-browser-request-boundary.md | 43 +++++++++++++++ docs/adr/README.md | 1 + frontend/src/api.test.ts | 54 +++++++++++++++++++ frontend/src/apiTransport.ts | 52 +++++++++++++++--- 4 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 docs/adr/0364-authenticated-browser-request-boundary.md diff --git a/docs/adr/0364-authenticated-browser-request-boundary.md b/docs/adr/0364-authenticated-browser-request-boundary.md new file mode 100644 index 000000000..0bac4e630 --- /dev/null +++ b/docs/adr/0364-authenticated-browser-request-boundary.md @@ -0,0 +1,43 @@ +# ADR 0364: Authenticated browser request boundary + +- Status: Proposed +- Date: 2026-09-04 + +## Context + +The browser sends an access token to the configured LineageWeave API. A remote +cleartext URL would expose that credential in transit. Local Compose development +still needs loopback HTTP. + +Global Ask is an asynchronous job. Its existing fifteen-minute product ceiling +was checked only between requests, so a stalled submission or poll could keep +the visible waiting state alive indefinitely. + +## Decision + +Authenticated browser requests admit HTTPS destinations. HTTP is admitted only +for `localhost`, `127.0.0.1`, and `[::1]`; embedded URL credentials and every +other scheme or cleartext host fail before the authorization header is built. + +Global Ask establishes its existing whole-operation deadline before submission. +The submission and every poll receive an abort signal for the remaining time. +A deadline abort becomes the existing actionable Ask timeout outcome, while +other connectivity failures keep the shared unavailable outcome. + +## Consequences + +- A deployment cannot send an access token to a remote cleartext API by + configuration mistake. +- Loopback Compose development keeps its current HTTP URL. +- A stalled request cannot outlive the same ceiling that governs polling. +- This decision changes no server job deadline and does not claim that a timed + out job was cancelled server-side. + +## Alternatives considered + +- Enforce the rule only in deployment documentation: rejected because the + browser would still attach the token when configuration drifts. +- Start the Ask deadline after submission: rejected because submission latency + is part of the user's wait. +- Add a second shorter per-request timeout: rejected because no separate + evidence supports another threshold. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8979111c1..4038210d9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,6 +27,7 @@ decision from them. | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) | | [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | +| Authenticated browser requests and Global Ask polling | [0364](0364-authenticated-browser-request-boundary.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) | diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 9495dc241..85937a737 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BackendError, + askAgent, fetchMe, fetchOccupationRatingSources, fetchOccupationRatings, @@ -8,12 +9,65 @@ import { fetchRatingSourceOccupations, updateTenantConfig, } from "./api"; +import { config } from "./config"; + +const defaultBackendBaseUrl = config.backendBaseUrl; afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); + config.backendBaseUrl = defaultBackendBaseUrl; }); describe("backendFetch provider-error boundary", () => { + it("refuses a remote cleartext backend before attaching authorization", async () => { + config.backendBaseUrl = "http://service.example"; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchMe("access-token")).rejects.toMatchObject({ status: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("bounds a stalled Ask submission by the whole-operation deadline", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }), + ), + ); + + const pending = askAgent("access-token", "What changed?"); + const rejection = expect(pending).rejects.toThrow("timed out waiting for an answer"); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + + await rejection; + }); + + it("bounds a stalled Ask poll by the same whole-operation deadline", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ask_job_id: "job-1", job_status_code: "queued" })), + ) + .mockImplementation((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const pending = askAgent("access-token", "What changed?"); + const rejection = expect(pending).rejects.toThrow("timed out waiting for an answer"); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + + await rejection; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); it("binds the selected Dashboard period as inclusive API dates", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ cases: [] }), { headers: { "Content-Type": "application/json" } }), diff --git a/frontend/src/apiTransport.ts b/frontend/src/apiTransport.ts index 5db021a05..1be0815b0 100644 --- a/frontend/src/apiTransport.ts +++ b/frontend/src/apiTransport.ts @@ -552,6 +552,23 @@ async function backendFetch( accessToken: string, init?: RequestInit, ): Promise { + let backendUrl: URL; + try { + backendUrl = new URL(config.backendBaseUrl); + } catch { + throw new BackendError(path, 0); + } + const loopbackHost = + backendUrl.hostname === "localhost" || + backendUrl.hostname === "127.0.0.1" || + backendUrl.hostname === "[::1]"; + if ( + backendUrl.username || + backendUrl.password || + (backendUrl.protocol !== "https:" && !(backendUrl.protocol === "http:" && loopbackHost)) + ) { + throw new BackendError(path, 0); + } let response: Response; try { response = await fetch(`${config.backendBaseUrl}${path}`, { @@ -1351,6 +1368,7 @@ const ASK_POLL_INTERVAL_MS = 2000; // the 600 s job deadline — and the e2e suite's own answer deadline, so a // stored answer is never abandoned by the client that asked for it. const ASK_POLL_CEILING_MS = 15 * 60 * 1000; +const ASK_TIMEOUT_MESSAGE = "Ask Agent timed out waiting for an answer. Try again."; interface AskJobStatus { ask_job_id: string; @@ -1385,16 +1403,32 @@ export async function askAgent( knowledge_cutoff?: string; } = { question, verify_external: verifyExternal }; if (knowledgeCutoff) requestBody.knowledge_cutoff = knowledgeCutoff; - const submitted = await backendFetch("/api/ask", accessToken, { + const deadline = Date.now() + ASK_POLL_CEILING_MS; + const fetchBeforeDeadline = async (path: string, init?: RequestInit): Promise => { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new Error(ASK_TIMEOUT_MESSAGE); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), remainingMs); + try { + return await backendFetch(path, accessToken, { + ...init, + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted && error instanceof BackendError && error.status === 0) { + throw new Error(ASK_TIMEOUT_MESSAGE); + } + throw error; + } finally { + window.clearTimeout(timeout); + } + }; + const submitted = await fetchBeforeDeadline("/api/ask", { method: "POST", body: JSON.stringify(requestBody), }); - const deadline = Date.now() + ASK_POLL_CEILING_MS; for (;;) { - const job = await backendFetch( - `/api/ask/jobs/${submitted.ask_job_id}`, - accessToken, - ); + const job = await fetchBeforeDeadline(`/api/ask/jobs/${submitted.ask_job_id}`); if (job.job_status_code === "succeeded" && job.answer) { return job.answer; } @@ -1402,9 +1436,11 @@ export async function askAgent( throw new Error(job.failure_detail || "Ask Agent could not answer this question."); } if (Date.now() > deadline) { - throw new Error("Ask Agent timed out waiting for an answer. Try again."); + throw new Error(ASK_TIMEOUT_MESSAGE); } - await new Promise((resolve) => setTimeout(resolve, ASK_POLL_INTERVAL_MS)); + await new Promise((resolve) => + setTimeout(resolve, Math.min(ASK_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()))), + ); } } From 3fa92d2b3082770efa7642e38d475833404497a2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 00:48:29 +0900 Subject: [PATCH 25/26] fix(customer): complete malformed hierarchy rendering Signed-off-by: Codex --- ...-authenticated-browser-request-boundary.md | 2 +- ...365-customer-master-malformed-hierarchy.md | 40 +++++ docs/adr/README.md | 1 + frontend/src/App.tsx | 138 +++++++++--------- frontend/src/api.test.ts | 24 +++ frontend/src/apiTransport.ts | 13 +- frontend/src/customerMasterDepth.test.ts | 4 + frontend/src/customerMasterProjection.test.ts | 9 +- frontend/src/customerMasterProjection.ts | 16 +- frontend/src/customerMasterTree.ts | 29 ++++ 10 files changed, 192 insertions(+), 84 deletions(-) create mode 100644 docs/adr/0365-customer-master-malformed-hierarchy.md diff --git a/docs/adr/0364-authenticated-browser-request-boundary.md b/docs/adr/0364-authenticated-browser-request-boundary.md index 0bac4e630..35049754d 100644 --- a/docs/adr/0364-authenticated-browser-request-boundary.md +++ b/docs/adr/0364-authenticated-browser-request-boundary.md @@ -1,6 +1,6 @@ # ADR 0364: Authenticated browser request boundary -- Status: Proposed +- Status: Accepted - Date: 2026-09-04 ## Context diff --git a/docs/adr/0365-customer-master-malformed-hierarchy.md b/docs/adr/0365-customer-master-malformed-hierarchy.md new file mode 100644 index 000000000..778e89284 --- /dev/null +++ b/docs/adr/0365-customer-master-malformed-hierarchy.md @@ -0,0 +1,40 @@ +# ADR 0365: Customer Master malformed hierarchy presentation + +- Status: Accepted +- Date: 2026-09-05 + +## Context + +An authorized Customer Master response can contain an entity whose parent is not +visible, points to itself, or participates in a cycle. Dropping that entity hides +authorized customer evidence. Treating the malformed edge as valid can recurse +forever or exhaust the browser call stack. Changing the stored parent would invent +organizational authority. + +## Decision + +The presentation projection keeps every uniquely identified authorized entity. It +omits a missing, self-referential, or one deterministic cycle-closing parent edge and +promotes that entity to a visible root. A presentation-only issue code travels to the +render boundary, where localized customer copy discloses the omitted edge. It does not +change the API's authoritative name, level, identifier, or stored parent. + +Duplicate, blank, and whitespace-aliased canonical entity identifiers fail closed. +Tree construction, flattening, and rendering are iterative so valid depth cannot +exhaust the JavaScript call stack. Sibling and cycle-break ordering uses code-point +comparison and carries no ranking or organizational inference. + +## Consequences + +- Authorized entities stay visible even when their visible hierarchy is incomplete. +- The screen distinguishes source facts from a presentation-only omitted-edge notice. +- A malformed identity yields the existing load failure instead of an ambiguous tree. +- Deep hierarchies render as one flat DOM list with visual indentation. + +## Alternatives considered + +- Drop malformed descendants: rejected because it hides authorized evidence. +- Reassign a replacement parent: rejected because the product has no authority to + invent organizational structure. +- Render the source graph recursively: rejected because cycles and valid deep inputs + can prevent the screen from rendering. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4038210d9..4c8a86317 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ decision from them. | [`operability/mcp-concurrency-evidence.md`](../operability/mcp-concurrency-evidence.md) | [0218](0218-current-contract-mcp-global-ask.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | Authenticated browser requests and Global Ask polling | [0364](0364-authenticated-browser-request-boundary.md) | +| Customer Master malformed hierarchy presentation | [0365](0365-customer-master-malformed-hierarchy.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | | [`python-mathematical-compute-boundary-audit.md`](../doctoring/python-mathematical-compute-boundary-audit.md) | [0208](0208-externalize-local-mathematical-compute.md) | | [`WORKER_FUNCTION_TAXONOMY_REFERENCES.md`](../doctoring/WORKER_FUNCTION_TAXONOMY_REFERENCES.md) | [0232](0232-worker-function-taxonomy-in-the-published-ontology.md) | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3fb6c796..a798c23a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -88,6 +88,10 @@ import { type SimilarVocItem, fetchTenantConfig, } from "./api"; +import { + customerEntityDisplayRows, + type CustomerHierarchyIssue, +} from "./customerMasterTree"; import { CitationChip } from "./components/CitationChip"; import { PublicClaimVerification } from "./components/PublicClaimVerification"; import { OrganizationAliasChip } from "./components/OrganizationAliasChip"; @@ -4610,59 +4614,33 @@ function PostList({ ); } -interface CustomerEntityTreeNode { - entity: CustomerMasterEntity; - children: CustomerEntityTreeNode[]; -} - -// Live bug (2026-08-19): Customer Master's own entity list rendered every -// corporate_entity as an independent top-level row, even though the API -// already carries parent_entity_id and the codebase already knows how to -// build a real forest from it (lineageweave/affiliate_tree.py, used for -// the post-detail popup's Affiliate tree) -- a group holding company and -// its subsidiaries showed up as an unrelated flat list with no visual -// hierarchy at all. A parent not present in this account's own visible -// entity list (a real possibility -- ABAC can authorize a child entity -// without its parent) is not dropped; that entity becomes a root here -// instead of disappearing. -function buildCustomerEntityTree(entities: CustomerMasterEntity[]): CustomerEntityTreeNode[] { - const byId = new Map(entities.map((entity) => [entity.corporate_entity_id, entity])); - const childrenByParent = new Map(); - const roots: CustomerMasterEntity[] = []; - for (const entity of entities) { - if (entity.parent_entity_id && byId.has(entity.parent_entity_id)) { - const siblings = childrenByParent.get(entity.parent_entity_id) ?? []; - siblings.push(entity); - childrenByParent.set(entity.parent_entity_id, siblings); - } else { - roots.push(entity); - } - } - const toNode = (entity: CustomerMasterEntity): CustomerEntityTreeNode => ({ - entity, - children: (childrenByParent.get(entity.corporate_entity_id) ?? []).map(toNode), - }); - return roots.map(toNode); -} +const CUSTOMER_HIERARCHY_ISSUE_LABEL: Record = { + cycle_parent_ignored: "Cyclic parent link omitted", + self_parent_ignored: "Self-parent link omitted", + parent_not_available: "Parent not available in this authorized view", +}; function CustomerEntityTreeRow({ - node, + entity, + hierarchyIssue, depth, expandedEntityId, relatedByEntity, relatedLoading, onToggle, onOpenPost, + children, }: { - node: CustomerEntityTreeNode; + entity: CustomerMasterEntity; + hierarchyIssue: CustomerHierarchyIssue | null; depth: number; expandedEntityId: string | null; relatedByEntity: Record; relatedLoading: string | null; onToggle: (entityId: string) => void; onOpenPost: (postId: string) => void; + children?: ReactNode; }) { - const { entity, children } = node; const relatedPosts = (relatedByEntity[entity.corporate_entity_id] ?? []).filter( (related) => related.node_type_code === NODE_POST, ); @@ -4675,7 +4653,10 @@ function CustomerEntityTreeRow({ onClick={() => onToggle(entity.corporate_entity_id)} > {entity.entity_name} - {entity.corporate_entity_code} · {entity.entity_level_label} + + {entity.corporate_entity_code} · {entity.entity_level_label} + {hierarchyIssue ? ` · ${t(CUSTOMER_HIERARCHY_ISSUE_LABEL[hierarchyIssue])}` : ""} + {expandedEntityId === entity.corporate_entity_id ? (
@@ -4700,26 +4681,55 @@ function CustomerEntityTreeRow({ ) : null}
) : null} - {children.length > 0 ? ( -
    - {children.map((child) => ( - - ))} -
- ) : null} + {children} ); } +function renderCustomerEntityRows( + entities: CustomerMasterEntity[], + expandedEntityId: string | null, + relatedByEntity: Record, + relatedLoading: string | null, + onToggle: (entityId: string) => void, + onOpenPost: (postId: string) => void, +): ReactNode[] { + const rows = customerEntityDisplayRows(entities); + const pendingByDepth: ReactNode[][] = []; + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + const descendants = pendingByDepth[row.depth + 1] ?? []; + pendingByDepth[row.depth + 1] = []; + const nested = descendants.length > 0 ? ( +
    + {descendants} +
+ ) : null; + const rendered = ( + + {nested} + + ); + const siblings = pendingByDepth[row.depth] ?? []; + siblings.unshift(rendered); + pendingByDepth[row.depth] = siblings; + } + return pendingByDepth[0] ?? []; +} + function CustomerRelatedPostCard({ postId, postTitle, @@ -4866,18 +4876,14 @@ function CustomerMasterPanel({ ) : null} {master && master.corporate_entities.length > 0 ? (
    - {buildCustomerEntityTree(master.corporate_entities).map((node) => ( - - ))} + {renderCustomerEntityRows( + master.corporate_entities, + expandedEntityId, + relatedByEntity, + relatedLoading, + toggleEntity, + openPost, + )}
) : null} {master && (master.relationship_network ?? []).length > 0 ? ( diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 85937a737..5913844a9 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -68,6 +68,30 @@ describe("backendFetch provider-error boundary", () => { await rejection; expect(fetchMock).toHaveBeenCalledTimes(2); }); + + it("maps a stalled Ask response body to the whole-operation timeout", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => + Promise.resolve({ + ok: true, + json: () => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + }), + ), + ); + + const pending = askAgent("access-token", "What changed?"); + const rejection = expect(pending).rejects.toThrow("timed out waiting for an answer"); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + + await rejection; + }); it("binds the selected Dashboard period as inclusive API dates", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ cases: [] }), { headers: { "Content-Type": "application/json" } }), diff --git a/frontend/src/apiTransport.ts b/frontend/src/apiTransport.ts index 1be0815b0..30deb2dd9 100644 --- a/frontend/src/apiTransport.ts +++ b/frontend/src/apiTransport.ts @@ -590,11 +590,17 @@ async function backendFetch( detail = body.detail; } } catch { + if (init?.signal?.aborted) throw new BackendError(path, 0); detail = undefined; } throw new BackendError(path, response.status, detail); } - return response.json() as Promise; + try { + return (await response.json()) as T; + } catch (error) { + if (init?.signal?.aborted) throw new BackendError(path, 0); + throw error; + } } export interface LineageGraphNode { @@ -651,6 +657,11 @@ export interface CorporateEntityRef { } export interface CustomerMasterEntity extends CorporateEntityRef { + hierarchy_issue_code?: + | "cycle_parent_ignored" + | "self_parent_ignored" + | "parent_not_available" + | null; corporate_entity_code: string; entity_level_code: string; entity_level_label: string; diff --git a/frontend/src/customerMasterDepth.test.ts b/frontend/src/customerMasterDepth.test.ts index 41f76444e..0b9227cf7 100644 --- a/frontend/src/customerMasterDepth.test.ts +++ b/frontend/src/customerMasterDepth.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { CustomerMasterEntity, CustomerMasterResponse } from "./apiTransport"; import { projectCustomerMasterResponse } from "./customerMasterProjection"; +import { customerEntityDisplayRows } from "./customerMasterTree"; function deepHierarchy(depth: number): CustomerMasterEntity[] { return Array.from({ length: depth }, (_, index) => ({ @@ -30,5 +31,8 @@ describe("Customer Master deep hierarchy", () => { expect(projected.corporate_entities[0].corporate_entity_id).toBe("entity-00000"); expect(projected.corporate_entities.at(-1)?.corporate_entity_id).toBe("entity-11999"); expect(projected.corporate_entities.at(-1)?.parent_entity_id).toBe("entity-11998"); + const rows = customerEntityDisplayRows(projected.corporate_entities); + expect(rows).toHaveLength(12_000); + expect(rows.at(-1)?.depth).toBe(11_999); }); }); diff --git a/frontend/src/customerMasterProjection.test.ts b/frontend/src/customerMasterProjection.test.ts index f24d85aa2..fd892b13f 100644 --- a/frontend/src/customerMasterProjection.test.ts +++ b/frontend/src/customerMasterProjection.test.ts @@ -42,13 +42,15 @@ describe("projectCustomerMasterResponse", () => { corporate_entity_id: "orphan", parent_entity_id: null, entity_level_code: "company", - entity_level_label: "Company · Parent not available in this authorized view", + entity_level_label: "Company", + hierarchy_issue_code: "parent_not_available", }), expect.objectContaining({ corporate_entity_id: "self", parent_entity_id: null, entity_level_code: "company", - entity_level_label: "Company · Self-parent link omitted", + entity_level_label: "Company", + hierarchy_issue_code: "self_parent_ignored", }), ]); }); @@ -65,7 +67,8 @@ describe("projectCustomerMasterResponse", () => { expect.objectContaining({ corporate_entity_id: "alpha", parent_entity_id: null, - entity_level_label: "Company · Cyclic parent link omitted", + entity_level_label: "Company", + hierarchy_issue_code: "cycle_parent_ignored", }), expect.objectContaining({ corporate_entity_id: "beta", diff --git a/frontend/src/customerMasterProjection.ts b/frontend/src/customerMasterProjection.ts index 7a756c7e2..1cd58099a 100644 --- a/frontend/src/customerMasterProjection.ts +++ b/frontend/src/customerMasterProjection.ts @@ -2,15 +2,8 @@ import type { CustomerMasterEntity, CustomerMasterResponse } from "./apiTranspor import { buildCustomerEntityTree, type CustomerEntityTreeNode, - type CustomerHierarchyIssue, } from "./customerMasterTree"; -const HIERARCHY_ISSUE_DISPLAY: Record = { - cycle_parent_ignored: "Cyclic parent link omitted", - self_parent_ignored: "Self-parent link omitted", - parent_not_available: "Parent not available in this authorized view", -}; - function flattenDisplayTree( nodes: CustomerEntityTreeNode[], parentEntityId: string | null, @@ -22,13 +15,10 @@ function flattenDisplayTree( while (pending.length > 0) { const current = pending.pop()!; - const suffix = current.node.hierarchyIssue - ? ` · ${HIERARCHY_ISSUE_DISPLAY[current.node.hierarchyIssue]}` - : ""; output.push({ ...current.node.entity, parent_entity_id: current.parentEntityId, - entity_level_label: `${current.node.entity.entity_level_label}${suffix}`, + hierarchy_issue_code: current.node.hierarchyIssue, }); for (let index = current.node.children.length - 1; index >= 0; index -= 1) { @@ -44,8 +34,8 @@ function flattenDisplayTree( * Produces the Customer Master display projection consumed by the existing tree UI. * * The API response remains immutable. Only the frontend projection rewrites malformed - * parent pointers to the deterministic visible forest and composes disclosure into the - * existing display label. `entity_level_code` and every other authoritative source fact + * parent pointers to the deterministic visible forest and carries a presentation-only issue + * code for localized rendering. `entity_level_code` and every authoritative source fact * are preserved exactly; no corrected parent is invented or persisted. Traversal is * iterative so a valid deep hierarchy cannot fail solely because of JavaScript call-stack * depth. diff --git a/frontend/src/customerMasterTree.ts b/frontend/src/customerMasterTree.ts index 9d21b21cf..1697a24a1 100644 --- a/frontend/src/customerMasterTree.ts +++ b/frontend/src/customerMasterTree.ts @@ -11,6 +11,12 @@ export interface CustomerEntityTreeNode { children: CustomerEntityTreeNode[]; } +export interface CustomerEntityDisplayRow { + entity: CustomerMasterEntity; + hierarchyIssue: CustomerHierarchyIssue | null; + depth: number; +} + function compareEntity(left: CustomerMasterEntity, right: CustomerMasterEntity): number { if (left.entity_name < right.entity_name) return -1; if (left.entity_name > right.entity_name) return 1; @@ -141,3 +147,26 @@ export function buildCustomerEntityTree( return roots.sort((left, right) => compareEntity(left.entity, right.entity)); } + +/** Flattens the safe forest iteratively so rendering never recurses with source depth. */ +export function customerEntityDisplayRows( + entities: CustomerMasterEntity[], +): CustomerEntityDisplayRow[] { + const rows: CustomerEntityDisplayRow[] = []; + const pending = buildCustomerEntityTree(entities) + .reverse() + .map((node) => ({ node, depth: 0 })); + + while (pending.length > 0) { + const { node, depth } = pending.pop()!; + rows.push({ + entity: node.entity, + hierarchyIssue: node.entity.hierarchy_issue_code ?? node.hierarchyIssue, + depth, + }); + for (let index = node.children.length - 1; index >= 0; index -= 1) { + pending.push({ node: node.children[index], depth: depth + 1 }); + } + } + return rows; +} From e82aed38c0997588529e21fe0e1bf4159f3c198c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 5 Sep 2026 03:41:13 +0900 Subject: [PATCH 26/26] feat(customer): make malformed hierarchy visible Signed-off-by: Codex --- docs/storybook-inventory.md | 1 + frontend/src/App.css | 88 ++++++++++++++++++++ frontend/src/App.tsx | 6 +- frontend/src/CustomerMasterPanel.stories.tsx | 75 +++++++++++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 frontend/src/CustomerMasterPanel.stories.tsx diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index f426285a6..b7b781e6a 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -8,6 +8,7 @@ operator-facing control you can click before changing product CSS. | `Reports/LeftoverMapPlot` | Read the leftover-map graphic display of persisted `ξ` (posts) and `ζ` (criteria), match axis ticks to those coordinates and pair-segment `d` to leftover-map distance, then click a post marker to open that post. Leftover-map axes name persisted leftover-map axis share when finite. `ClosestAndFarthest`, `RankZeroOrigin`, `MissingCoordinates`, and `MissingAxisShare` cover two-pair maps, rank-0 origin with 0% share, a `0` tick, and `d 0.00`, omitted plots, and missing share that keeps existing leftover-map axis text. The plot does not invent a leftover score. | `LeftoverMapPlot`, `leftoverMapPlotLayout`, `leftoverMapPlotAxisShare`, `--color-primary`, `--color-palette-blue-mid` | | `Reports/LeftoverPairList` | Read closest/farthest leftover pairs with named `R`, `Y`/`E`, rank, `U`, `s`, `e`, `x`, `R̂`, `ξ`/`ζ`, and `d`, then open that post. The leftover-map graphic display sits above the pair buttons when coordinates are finite, leftover-map axes name persisted leftover-map axis share, leftover-map axis ticks name persisted coordinates, and pair segments name persisted leftover-map distance. | `LeftoverPairList`, `LeftoverMapPlot`, `ticket-list`, `post-badge` | | `Workspace/OperationsDashboard` | Compare Event and post counts, inspect external-information coverage, then open the cited source behind a claim, handover, or repeat-issue fact. `EvidenceReady`, `NarrowViewport`, `AnalysisPendingAndMissingEvidence`, `AnalysisFailed`, and `LoadError` cover populated, mobile, unavailable-evidence, analysis-pending, retryable failure, and transport-error states. | `--color-dashboard-*`, `OperationsDashboard` | +| `Workspace/CustomerMasterPanel` | Inspect every authorized customer entity even when imported parent links contain a cycle. `CyclePreserved` and `CyclePreservedMobile` cover desktop and narrow layouts while keeping the malformed link visibly omitted instead of hiding a record. | `CustomerMasterPanel`, `customer-master`, `--surface`, `--border`, `--size-control-min` | | `Post/SimilarVocPanel` | Compare ontology/semantic similar VOC and prior action evidence, then open the source; unavailable states show no fabricated TEPP theta or weight. | `SimilarVocPanel.css`, `SimilarVocPanel` | | `Post/Recorded perspectives` | Read the imported primary and every evidence-connected additional Voice with its recorded truth state instead of flattening them into one compound category. `CombinedEvidence`, `RejectedEvidence`, and `NarrowViewport` cover desktop, rejected-evidence, and narrow layouts. | `VoicePerspectiveList`, `ticket-list`, `post-meta` | | `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` | diff --git a/frontend/src/App.css b/frontend/src/App.css index 3e4c13599..37b50748c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -291,6 +291,94 @@ text-transform: uppercase; } +/* Workspace destinations */ +.workspace-destination { + box-sizing: border-box; + width: min(100%, 64rem); + margin-inline: auto; + padding: var(--space-panel-block); +} + +.workspace-destination > h2, +.workspace-destination > p { + margin-block-start: 0; +} + +.section-eyebrow { + color: var(--color-text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workspace-destination-intro { + color: var(--color-text); + max-width: 46rem; +} + +.customer-master-list { + list-style: none; + margin: 0; + padding: 0; +} + +.customer-master-tree, +.customer-master-tree-children { + display: grid; + gap: var(--space-control-gap); +} + +.customer-master-tree-children { + margin-block-start: var(--space-control-gap); +} + +.customer-master-tree li { + margin-inline-start: calc(var(--customer-hierarchy-depth, 0) * 1.25rem); +} + +.customer-entity-button { + align-items: flex-start; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-control); + color: var(--text); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-height: var(--size-control-min); + padding: 0.75rem 1rem; + text-align: start; + width: 100%; +} + +.customer-entity-button:hover, +.customer-entity-button:focus-visible { + border-color: var(--color-focus-border); +} + +.customer-entity-button span { + color: var(--color-text); + font-size: 0.875rem; +} + +.customer-related-posts, +.customer-keymen { + margin-block-start: var(--space-panel-block); +} + +@media (max-width: 768px) { + .workspace-destination { + padding: var(--space-control-gap); + } + + .customer-master-tree > li, + .customer-master-tree-children > li { + margin-inline-start: 0; + } +} + /* Popup / Modals (§3.6.1 모달 레이어 투명도 50%) */ .popup-backdrop { position: fixed; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a798c23a7..c8d6a15b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { focusedGraphMustReset } from "./focusedGraphSelection"; import { canAuthorVoice, postPrimaryVoiceLabel } from "./voicePerspective"; -import { Component, lazy, Suspense, useCallback, useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; +import { Component, lazy, Suspense, useCallback, useEffect, useEffectEvent, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { askPostChat, @@ -4645,7 +4645,7 @@ function CustomerEntityTreeRow({ (related) => related.node_type_code === NODE_POST, ); return ( -
  • +