diff --git a/hub/blueprint/bp-ai-assistant-platform.radical b/hub/blueprint/bp-ai-assistant-platform.radical index a8ac504..98f9ab2 100644 --- a/hub/blueprint/bp-ai-assistant-platform.radical +++ b/hub/blueprint/bp-ai-assistant-platform.radical @@ -951,9 +951,212 @@ "The case surfaces in the handoff queue for the next available agent.", "The agent resolves it from the console; had confidence been high enough, the orchestrator would instead have answered the customer directly." ] + }, + { + "id": "seq-flow-customer", + "name": "Customer journey", + "relationIds": [ + "mockup-chat-start--mockup-chat-answer", + "mockup-chat-answer--mockup-support-chat", + "mockup-support-chat--mockup-chat-human" + ], + "stepDescriptions": [ + "Ask → Chat — Grounded Answer. Answer grounded in retrieved sources; actions need the customer's confirmation.", + "Not resolved → Chat — Handoff. Customer-facing chat: the assistant answers with cited sources when it can ground the answer and hands off to a person when it cannot.", + "Agent joins → Chat — Agent Joined. A person joins with the full case history, within {{ESCALATION_SLA_MIN}} minutes of the handoff." + ] + }, + { + "id": "seq-flow-human-agent", + "name": "Human agent journey", + "relationIds": [ + "mockup-escalation-queue--mockup-case-review", + "mockup-case-review--mockup-case-resolved", + "mockup-case-resolved--mockup-case-audit", + "mockup-case-resolved--mockup-escalation-queue" + ], + "stepDescriptions": [ + "Pick up → Case Review. The assistant's proposed action with its confidence, per-field extraction confidence (below {{EXTRACTION_REVIEW_THRESHOLD_PCT}}% needs review) and the sources it used.", + "Approve & send → Case Resolved. Resolution with every autonomous and human decision recorded for {{AUDIT_RETENTION_DAYS}} days.", + "Audit trail → Case Audit Trail. Search decisions, sources and confidence across cases for audits.", + "Next case → Escalation Queue. Human agents pick up escalated cases oldest-first; each case must be picked up within {{ESCALATION_SLA_MIN}} minutes." + ] + }, + { + "id": "seq-flow-assistant-ops", + "name": "Assistant ops journey", + "relationIds": [ + "mockup-kb-sources--mockup-quality-drift", + "mockup-quality-drift--mockup-kb-sources" + ], + "stepDescriptions": [ + "Quality → Quality & Drift. Autonomous resolution rate, eval results and cost per case.", + "Fix stale source → Knowledge Sources. Source sync status against the {{KB_STALENESS_HOURS}}-hour freshness limit." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "customer", + "human-agent", + "ops", + "platform", + "llm-provider", + "source-channel" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "human-agent": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ops": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "llm-provider": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "source-channel": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "customer", + "human-agent", + "ops", + "platform", + "ingestion-api", + "extraction-worker", + "retriever", + "orchestrator", + "handoff-queue", + "agent-console", + "knowledge-base", + "case-store", + "llm-provider", + "source-channel" + ], + "positions": { + "agent-console": { + "x": 710, + "y": 350, + "width": 210, + "height": 140 + }, + "case-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "extraction-worker": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "handoff-queue": { + "x": 370, + "y": 350, + "width": 220, + "height": 95 + }, + "human-agent": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ingestion-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "knowledge-base": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "llm-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "ops": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "retriever": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "source-channel": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + } + } + }, { "id": "bp-ai-assistant-platform-flow", "name": "Autonomous resolution with escalation fallback", @@ -975,7 +1178,333 @@ "llm-provider", "source-channel" ], - "positions": {} + "positions": { + "agent-console": { + "x": 710, + "y": 350, + "width": 210, + "height": 140 + }, + "case-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "extraction-worker": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "handoff-queue": { + "x": 370, + "y": 350, + "width": 220, + "height": 95 + }, + "human-agent": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ingestion-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "knowledge-base": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "llm-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "ops": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "retriever": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "source-channel": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-escalation-pickup", + "req-extraction-review", + "req-kb-freshness", + "req-case-audit-trail", + "ff-escalation-backlog-guard", + "ff-extraction-drift-monitor", + "ff-cost-per-case-guard", + "adr-confidence-gated-autonomy", + "adr-retrieval-over-finetuning", + "handoff-queue", + "extraction-worker", + "knowledge-base", + "case-store", + "orchestrator", + "retriever" + ], + "positions": { + "adr-confidence-gated-autonomy": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-retrieval-over-finetuning": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "case-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "extraction-worker": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "ff-cost-per-case-guard": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-escalation-backlog-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-extraction-drift-monitor": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "handoff-queue": { + "x": 370, + "y": 350, + "width": 220, + "height": 95 + }, + "knowledge-base": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "req-case-audit-trail": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-escalation-pickup": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-extraction-review": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-kb-freshness": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "retriever": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-customer", + "name": "Customer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-customer", + "nodeIds": [ + "flow-customer", + "mockup-chat-start", + "mockup-chat-answer", + "mockup-support-chat", + "mockup-chat-human" + ], + "positions": { + "flow-customer": { + "x": 0, + "y": 2084, + "width": 1240, + "height": 330 + }, + "mockup-chat-answer": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-chat-human": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-chat-start": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-support-chat": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-human-agent", + "name": "Human agent journey", + "kind": "dynamic", + "sequenceId": "seq-flow-human-agent", + "nodeIds": [ + "flow-human-agent", + "mockup-escalation-queue", + "mockup-case-review", + "mockup-case-resolved", + "mockup-case-audit" + ], + "positions": { + "flow-human-agent": { + "x": 0, + "y": 2494, + "width": 1240, + "height": 330 + }, + "mockup-case-audit": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-case-resolved": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-case-review": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-escalation-queue": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-assistant-ops", + "name": "Assistant ops journey", + "kind": "dynamic", + "sequenceId": "seq-flow-assistant-ops", + "nodeIds": [ + "flow-assistant-ops", + "mockup-kb-sources", + "mockup-quality-drift" + ], + "positions": { + "flow-assistant-ops": { + "x": 0, + "y": 2904, + "width": 600, + "height": 330 + }, + "mockup-kb-sources": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-quality-drift": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-analytical-data-platform.radical b/hub/blueprint/bp-analytical-data-platform.radical index baed821..b573db0 100644 --- a/hub/blueprint/bp-analytical-data-platform.radical +++ b/hub/blueprint/bp-analytical-data-platform.radical @@ -833,5 +833,479 @@ "targetId": "orchestrator", "relationType": "presented-by" } + ], + "sequences": [ + { + "id": "seq-technical", + "name": "From event to dashboard", + "relationIds": [ + "stream--ingestion", + "ingestion--lake", + "orchestrator--transform", + "transform--lake", + "transform--catalog", + "analyst--bi", + "bi--query", + "query--catalog", + "query--lake" + ], + "stepDescriptions": [ + "Operational events stream into ingestion.", + "Raw events land in the lake within the freshness target.", + "Orchestrator schedules the transformation run.", + "Models turn raw into curated tables; failing rows are quarantined.", + "Lineage and test results are published to the catalog.", + "Analyst opens a dashboard.", + "BI runs the governed query.", + "Query engine resolves row and column policies for the user.", + "Curated tables are read with the policies applied." + ] + }, + { + "id": "seq-flow-analyst", + "name": "Analyst journey", + "relationIds": [ + "mockup-bi-home--mockup-sales-dashboard", + "mockup-sales-dashboard--mockup-explore", + "mockup-explore--mockup-access-request", + "mockup-access-request--mockup-dataset-lineage", + "mockup-dataset-lineage--mockup-access-request", + "mockup-sales-dashboard--mockup-dataset-lineage" + ], + "stepDescriptions": [ + "Open → Sales Dashboard. Self-service dashboard showing how fresh the data is; restricted columns are masked for users whose role does not allow them.", + "Explore → Explore. Self-service exploration; row and column policies from the catalogue still apply.", + "Request access → Request Access. Access to restricted columns is requested from the dataset owner, time-boxed and audited.", + "Send request → Dataset & Lineage. Catalogue page for a curated dataset with owner, classification and column-level lineage from source to dashboard.", + "Request access → Request Access. Access to restricted columns is requested from the dataset owner, time-boxed and audited.", + "View dataset → Dataset & Lineage. Catalogue page for a curated dataset with owner, classification and column-level lineage from source to dashboard." + ] + }, + { + "id": "seq-flow-data-engineer", + "name": "Data engineer journey", + "relationIds": [ + "mockup-pipelines--mockup-quality-runs", + "mockup-quality-runs--mockup-quarantine", + "mockup-quarantine--mockup-quality-runs", + "mockup-pipelines--mockup-freshness" + ], + "stepDescriptions": [ + "Open runs → Data Quality Runs. Run results with the quality gate applied: failing records are quarantined, the affected models fail and the dataset owner is notified before anything reaches consumers.", + "Open quarantine → Quarantine. Rows that failed the quality gate, held back until fixed, discarded or released.", + "Release after fix → Data Quality Runs. Run results with the quality gate applied: failing records are quarantined, the affected models fail and the dataset owner is notified before anything reaches consumers.", + "Freshness → Freshness Monitor. Streaming latency against the {{INGESTION_LATENCY_SECONDS}} s target from event to raw zone." + ] + } + ], + "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "analyst", + "data-engineer", + "platform" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "analyst": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "data-engineer": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "analyst", + "data-engineer", + "platform", + "ingestion", + "stream", + "lake", + "transform", + "orchestrator", + "catalog", + "query", + "bi" + ], + "positions": { + "analyst": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "bi": { + "x": 1050, + "y": 350, + "width": 210, + "height": 140 + }, + "catalog": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "data-engineer": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "ingestion": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "lake": { + "x": 710, + "y": 110, + "width": 190, + "height": 130 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1380, + "height": 580 + }, + "query": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "stream": { + "x": 370, + "y": 110, + "width": 220, + "height": 95 + }, + "transform": { + "x": 1050, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-technical-flow", + "name": "From event to dashboard", + "kind": "dynamic", + "sequenceId": "seq-technical", + "nodeIds": [ + "stream", + "ingestion", + "lake", + "orchestrator", + "transform", + "catalog", + "analyst", + "bi", + "query" + ], + "positions": { + "analyst": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "bi": { + "x": 1050, + "y": 350, + "width": 210, + "height": 140 + }, + "catalog": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "ingestion": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "lake": { + "x": 710, + "y": 110, + "width": 190, + "height": 130 + }, + "orchestrator": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1380, + "height": 580 + }, + "query": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "stream": { + "x": 370, + "y": 110, + "width": 220, + "height": 95 + }, + "transform": { + "x": 1050, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-freshness", + "req-quality-gate", + "req-rls", + "req-lineage", + "ff-freshness", + "ff-quality", + "ff-cost", + "adr-lakehouse", + "adr-dbt", + "ingestion", + "transform", + "query", + "catalog", + "lake" + ], + "positions": { + "adr-dbt": { + "x": 320, + "y": 1362, + "width": 260, + "height": 52 + }, + "adr-lakehouse": { + "x": 0, + "y": 1362, + "width": 260, + "height": 52 + }, + "catalog": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "ff-cost": { + "x": 640, + "y": 1190, + "width": 260, + "height": 52 + }, + "ff-freshness": { + "x": 0, + "y": 1190, + "width": 260, + "height": 52 + }, + "ff-quality": { + "x": 320, + "y": 1190, + "width": 260, + "height": 52 + }, + "ingestion": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "lake": { + "x": 710, + "y": 110, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1380, + "height": 580 + }, + "query": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "req-freshness": { + "x": 0, + "y": 990, + "width": 260, + "height": 80 + }, + "req-lineage": { + "x": 960, + "y": 990, + "width": 260, + "height": 80 + }, + "req-quality-gate": { + "x": 320, + "y": 990, + "width": 260, + "height": 80 + }, + "req-rls": { + "x": 640, + "y": 990, + "width": 260, + "height": 80 + }, + "transform": { + "x": 1050, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-analyst", + "name": "Analyst journey", + "kind": "dynamic", + "sequenceId": "seq-flow-analyst", + "nodeIds": [ + "flow-analyst", + "mockup-bi-home", + "mockup-sales-dashboard", + "mockup-explore", + "mockup-dataset-lineage", + "mockup-access-request" + ], + "positions": { + "flow-analyst": { + "x": 0, + "y": 1534, + "width": 1560, + "height": 330 + }, + "mockup-access-request": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bi-home": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-dataset-lineage": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-explore": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-sales-dashboard": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-data-engineer", + "name": "Data engineer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-data-engineer", + "nodeIds": [ + "flow-data-engineer", + "mockup-pipelines", + "mockup-quality-runs", + "mockup-quarantine", + "mockup-freshness" + ], + "positions": { + "flow-data-engineer": { + "x": 0, + "y": 1944, + "width": 1240, + "height": 330 + }, + "mockup-freshness": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-pipelines": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-quality-runs": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-quarantine": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } + } ] } diff --git a/hub/blueprint/bp-ecommerce-platform.radical b/hub/blueprint/bp-ecommerce-platform.radical index 272e825..a367375 100644 --- a/hub/blueprint/bp-ecommerce-platform.radical +++ b/hub/blueprint/bp-ecommerce-platform.radical @@ -1196,5 +1196,642 @@ "relationType": "navigates-to", "label": "Back to orders" } + ], + "sequences": [ + { + "id": "seq-technical", + "name": "Place an order and pay", + "relationIds": [ + "shopper--storefront", + "storefront--gateway", + "gateway--cart", + "cart--catalog", + "cart--orders", + "orders--orders-db", + "orders--bus", + "bus--payments", + "payments--psp", + "payments--bus", + "bus--orders", + "orders--email", + "bus--fulfilment", + "fulfilment--carrier" + ], + "stepDescriptions": [ + "Shopper adds the item to the cart and checks out.", + "Storefront submits the checkout step through the API gateway.", + "Gateway routes /checkout/** to Cart & Checkout.", + "Cart re-checks prices and availability.", + "Cart creates the order.", + "Order and outbox event are written in one transaction; stock is reserved.", + "OrderPlaced is published via the outbox.", + "Payment Service consumes OrderPlaced.", + "Payment is captured once, keyed by the order id.", + "PaymentCaptured is published.", + "Order Service marks the order paid.", + "Order confirmation e-mail goes to the shopper.", + "Fulfilment consumes PaymentCaptured.", + "Fulfilment books the shipment with the carrier." + ] + }, + { + "id": "seq-flow-shopper", + "name": "Shopper journey", + "relationIds": [ + "mockup-home--mockup-search-results", + "mockup-search-results--mockup-product-page", + "mockup-product-page--mockup-cart", + "mockup-cart--mockup-checkout-address", + "mockup-checkout-address--mockup-checkout-shipping", + "mockup-checkout-shipping--mockup-checkout", + "mockup-checkout--mockup-order-confirmation", + "mockup-order-confirmation--mockup-order-tracking", + "mockup-checkout--mockup-payment-declined", + "mockup-payment-declined--mockup-checkout", + "mockup-order-confirmation--mockup-home" + ], + "stepDescriptions": [ + "Search → Search Results. Filtered results from the search index (at most {{INDEX_LAG_SECONDS}} s behind the catalogue).", + "Open product → Product Page. Product detail with live availability from the catalogue; \"Add to cart\" leads into checkout.", + "Add to cart → Cart. Cart with quantities and promo code; shipping is quoted at the next step.", + "Checkout → Checkout — Address. First checkout step; like every step it answers within {{CHECKOUT_P95_MS}} ms at p95.", + "Continue → Checkout — Shipping. Live carrier quotes; still inside the checkout latency budget.", + "Continue → Checkout — Payment. Final checkout step.", + "Pay → Order Confirmation. Shown once payment is captured.", + "Track order → Order Tracking. Order status and carrier events after fulfilment books the shipment.", + "Card declined → Payment Declined. Declined payment: nothing was captured, items stay reserved for the rest of the {{RESERVATION_MINUTES}}-minute hold, and retrying reuses the same idempotency key.", + "Try another card → Checkout — Payment. Final checkout step.", + "Continue shopping → Home. Landing page: search, campaigns and categories." + ] + }, + { + "id": "seq-flow-operations", + "name": "Operations journey", + "relationIds": [ + "mockup-bo-catalogue--mockup-bo-product-editor", + "mockup-bo-product-editor--mockup-bo-catalogue", + "mockup-bo-catalogue--mockup-bo-orders", + "mockup-bo-orders--mockup-bo-order-detail", + "mockup-bo-order-detail--mockup-bo-orders" + ], + "stepDescriptions": [ + "Edit product → Product Editor. Edit price, stock and copy; changes reach the search index within {{INDEX_LAG_SECONDS}} s.", + "Save changes → Catalogue. Products with price, stock and publication state.", + "Orders → Orders. Order list filtered to the exceptions staff act on.", + "Open order → Order Detail. Payment attempts share one idempotency key, so staff can see nothing was captured twice; stock is released automatically when the hold expires.", + "Back to orders → Orders. Order list filtered to the exceptions staff act on." + ] + } + ], + "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "shopper", + "ops", + "platform", + "psp", + "carrier", + "email" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "shopper": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "psp": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "carrier": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + }, + "email": { + "x": 1240, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "shopper", + "ops", + "platform", + "storefront", + "backoffice", + "gateway", + "bus", + "catalog", + "cart", + "orders", + "payments", + "fulfilment", + "catalog-db", + "cart-db", + "orders-db", + "payments-db", + "search", + "psp", + "carrier", + "email" + ], + "positions": { + "backoffice": { + "x": 370, + "y": 110, + "width": 210, + "height": 140 + }, + "bus": { + "x": 1050, + "y": 110, + "width": 220, + "height": 95 + }, + "carrier": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "cart": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "cart-db": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "catalog": { + "x": 1390, + "y": 110, + "width": 300, + "height": 200 + }, + "catalog-db": { + "x": 1390, + "y": 350, + "width": 190, + "height": 130 + }, + "email": { + "x": 840, + "y": 1160, + "width": 360, + "height": 260 + }, + "fulfilment": { + "x": 1050, + "y": 350, + "width": 300, + "height": 200 + }, + "gateway": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "orders": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "payments-db": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1720, + "height": 750 + }, + "psp": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "search": { + "x": 1050, + "y": 590, + "width": 190, + "height": 130 + }, + "shopper": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "storefront": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-technical-flow", + "name": "Place an order and pay", + "kind": "dynamic", + "sequenceId": "seq-technical", + "nodeIds": [ + "shopper", + "storefront", + "gateway", + "cart", + "catalog", + "orders", + "orders-db", + "bus", + "payments", + "psp", + "email", + "fulfilment", + "carrier" + ], + "positions": { + "bus": { + "x": 1050, + "y": 110, + "width": 220, + "height": 95 + }, + "carrier": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "cart": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "catalog": { + "x": 1390, + "y": 110, + "width": 300, + "height": 200 + }, + "email": { + "x": 840, + "y": 1160, + "width": 360, + "height": 260 + }, + "fulfilment": { + "x": 1050, + "y": 350, + "width": 300, + "height": 200 + }, + "gateway": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "orders": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1720, + "height": 750 + }, + "psp": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "shopper": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "storefront": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-checkout-latency", + "req-payment-once", + "req-stock-reservation", + "req-pci-scope", + "ff-checkout-conversion", + "ff-payment-reconciliation", + "ff-search-freshness", + "adr-hosted-payments", + "adr-database-per-service", + "cart", + "payments", + "orders", + "search", + "platform" + ], + "positions": { + "adr-database-per-service": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-hosted-payments": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "cart": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "ff-checkout-conversion": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-payment-reconciliation": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-search-freshness": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "orders": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "payments": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1720, + "height": 750 + }, + "req-checkout-latency": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-payment-once": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-pci-scope": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-stock-reservation": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "search": { + "x": 1050, + "y": 590, + "width": 190, + "height": 130 + } + } + }, + { + "id": "view-flow-shopper", + "name": "Shopper journey", + "kind": "dynamic", + "sequenceId": "seq-flow-shopper", + "nodeIds": [ + "flow-shopper", + "mockup-home", + "mockup-search-results", + "mockup-product-page", + "mockup-cart", + "mockup-checkout-address", + "mockup-checkout-shipping", + "mockup-checkout", + "mockup-payment-declined", + "mockup-order-confirmation", + "mockup-order-tracking" + ], + "positions": { + "flow-shopper": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 630 + }, + "mockup-cart": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-checkout": { + "x": 350, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-checkout-address": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-checkout-shipping": { + "x": 30, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-home": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-order-confirmation": { + "x": 990, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-order-tracking": { + "x": 1310, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-payment-declined": { + "x": 670, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-product-page": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-search-results": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-operations", + "name": "Operations journey", + "kind": "dynamic", + "sequenceId": "seq-flow-operations", + "nodeIds": [ + "flow-operations", + "mockup-bo-catalogue", + "mockup-bo-product-editor", + "mockup-bo-orders", + "mockup-bo-order-detail" + ], + "positions": { + "flow-operations": { + "x": 0, + "y": 2794, + "width": 1240, + "height": 330 + }, + "mockup-bo-catalogue": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bo-order-detail": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bo-orders": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-bo-product-editor": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } + } ] } diff --git a/hub/blueprint/bp-fintech-ledger-platform.radical b/hub/blueprint/bp-fintech-ledger-platform.radical index ea55cc0..cd8f430 100644 --- a/hub/blueprint/bp-fintech-ledger-platform.radical +++ b/hub/blueprint/bp-fintech-ledger-platform.radical @@ -986,9 +986,216 @@ "Reconciliation worker independently reads posted entries from the journal.", "Reconciliation worker also fetches the bank's own end-of-day statement in a nightly batch and matches it against what was posted, catching anything the event stream alone would have missed." ] + }, + { + "id": "seq-flow-customer", + "name": "Customer journey", + "relationIds": [ + "mockup-accounts-overview--mockup-statement", + "mockup-statement--mockup-transaction-detail", + "mockup-transaction-detail--mockup-dispute", + "mockup-dispute--mockup-dispute-status" + ], + "stepDescriptions": [ + "View statements → Account Statement. Customer statement generated strictly from balances replayed through a fixed cut-off; each line links to a dispute.", + "Open transaction → Transaction Detail. One statement line with its settlement status and the journal entry behind it.", + "Dispute → Dispute a Transaction. Opens an investigation the moment it is submitted; the customer gets a resolution or explanation within {{DISPUTE_INVESTIGATION_DAYS}} days.", + "Submit dispute → Dispute Status. Open dispute with the time left until the {{DISPUTE_INVESTIGATION_DAYS}}-day resolution deadline." + ] + }, + { + "id": "seq-flow-finance-ops", + "name": "Finance Ops journey", + "relationIds": [ + "mockup-reconciliation-exceptions--mockup-exception-detail", + "mockup-exception-detail--mockup-compensating-entry", + "mockup-compensating-entry--mockup-entry-approval", + "mockup-entry-approval--mockup-journal-entry", + "mockup-journal-entry--mockup-reconciliation-exceptions", + "mockup-exception-detail--mockup-reconciliation-exceptions" + ], + "stepDescriptions": [ + "Open exception → Exception Detail. Settlement event next to candidate journal entries; a manual match or a compensating entry clears it.", + "Compensating entry → Compensating Entry. Double-entry form that cannot be submitted unless debits equal credits.", + "Submit for approval → Four-Eyes Approval. A second person approves before the entry is posted; nobody approves their own entries.", + "Approve & post → Journal Entry. Posted entries are immutable; the only correction is another entry.", + "Exception cleared → Reconciliation Exceptions. Finance Ops queue of settlement events not matched within {{RECONCILIATION_LAG_MIN}} minutes.", + "Confirm match → Reconciliation Exceptions. Finance Ops queue of settlement events not matched within {{RECONCILIATION_LAG_MIN}} minutes." + ] + }, + { + "id": "seq-flow-compliance", + "name": "Compliance journey", + "relationIds": [ + "mockup-audit-trail--mockup-regulatory-reports" + ], + "stepDescriptions": [ + "Reports → Regulatory Reports. Scheduled regulatory extracts built from the journal at a fixed cut-off, signed off by Compliance." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "customer", + "finance-ops", + "compliance-officer", + "platform", + "bank-rail", + "regulator" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "compliance-officer": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "bank-rail": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "regulator": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "customer", + "finance-ops", + "compliance-officer", + "platform", + "posting-api", + "balance-projector", + "reconciliation-worker", + "statement-service", + "finance-ops-console", + "settlement-events", + "journal-store", + "balance-store", + "bank-rail", + "regulator" + ], + "positions": { + "balance-projector": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "balance-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "bank-rail": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "compliance-officer": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + }, + "journal-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "posting-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "reconciliation-worker": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "regulator": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "settlement-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "statement-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + } + } + }, { "id": "bp-fintech-ledger-platform-flow", "name": "Settlement arrives, posts, and reconciles", @@ -1010,7 +1217,340 @@ "bank-rail", "regulator" ], - "positions": {} + "positions": { + "balance-projector": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "balance-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "bank-rail": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "compliance-officer": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "customer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "finance-ops-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + }, + "journal-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "posting-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "reconciliation-worker": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "regulator": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "settlement-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "statement-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-balanced-entry-enforcement", + "req-reconciliation-freshness", + "req-statement-accuracy-cutoff", + "req-dispute-investigation-sla", + "ff-settlement-backlog-guard", + "ff-manual-adjustment-rate-guard", + "ff-regulatory-report-timeliness", + "adr-suspense-account-for-unmatched", + "adr-ledger-as-system-of-record", + "posting-api", + "reconciliation-worker", + "statement-service", + "finance-ops-console", + "balance-projector" + ], + "positions": { + "adr-ledger-as-system-of-record": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-suspense-account-for-unmatched": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "balance-projector": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "ff-manual-adjustment-rate-guard": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-regulatory-report-timeliness": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-settlement-backlog-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "finance-ops-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "posting-api": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "reconciliation-worker": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "req-balanced-entry-enforcement": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-dispute-investigation-sla": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-reconciliation-freshness": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-statement-accuracy-cutoff": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "statement-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-customer", + "name": "Customer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-customer", + "nodeIds": [ + "flow-customer", + "mockup-accounts-overview", + "mockup-statement", + "mockup-transaction-detail", + "mockup-dispute", + "mockup-dispute-status" + ], + "positions": { + "flow-customer": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 330 + }, + "mockup-accounts-overview": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-dispute": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-dispute-status": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-statement": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-transaction-detail": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-finance-ops", + "name": "Finance Ops journey", + "kind": "dynamic", + "sequenceId": "seq-flow-finance-ops", + "nodeIds": [ + "flow-finance-ops", + "mockup-reconciliation-exceptions", + "mockup-exception-detail", + "mockup-compensating-entry", + "mockup-entry-approval", + "mockup-journal-entry" + ], + "positions": { + "flow-finance-ops": { + "x": 0, + "y": 2494, + "width": 1560, + "height": 330 + }, + "mockup-compensating-entry": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-entry-approval": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-exception-detail": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-journal-entry": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-reconciliation-exceptions": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-compliance", + "name": "Compliance journey", + "kind": "dynamic", + "sequenceId": "seq-flow-compliance", + "nodeIds": [ + "flow-compliance", + "mockup-audit-trail", + "mockup-regulatory-reports" + ], + "positions": { + "flow-compliance": { + "x": 0, + "y": 2904, + "width": 600, + "height": 330 + }, + "mockup-audit-trail": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-regulatory-reports": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-marketplace-platform.radical b/hub/blueprint/bp-marketplace-platform.radical index 70963be..c8c592c 100644 --- a/hub/blueprint/bp-marketplace-platform.radical +++ b/hub/blueprint/bp-marketplace-platform.radical @@ -1047,9 +1047,225 @@ "Once the order is delivered and the dispute window has passed with no dispute open, the payout worker marks it payout-eligible.", "Payout worker instructs the facilitator to release the seller's share, net of commission." ] + }, + { + "id": "seq-flow-buyer", + "name": "Buyer journey", + "relationIds": [ + "mockup-mp-search--mockup-mp-listing", + "mockup-mp-listing--mockup-mp-checkout", + "mockup-mp-checkout--mockup-order-dispute", + "mockup-order-dispute--mockup-mp-open-dispute", + "mockup-mp-open-dispute--mockup-mp-dispute-status" + ], + "stepDescriptions": [ + "Open listing → Listing. Listing page with seller reputation and buyer protection terms.", + "Buy now → Checkout. Payment goes to the payments facilitator, which holds the funds; the platform never custodies them.", + "Pay → Order & Buyer Protection. Buyer view of an order: funds stay held for {{DISPUTE_WINDOW_DAYS}} days after delivery, and opening a dispute freezes the payout.", + "Open a dispute → Open a Dispute. Opening a dispute inside the {{DISPUTE_WINDOW_DAYS}}-day window freezes the seller payout.", + "Open dispute → Dispute Status. Conversation and status while Trust & Safety reviews." + ] + }, + { + "id": "seq-flow-seller", + "name": "Seller journey", + "relationIds": [ + "mockup-seller-dashboard--mockup-new-listing", + "mockup-new-listing--mockup-my-listings", + "mockup-my-listings--mockup-new-listing", + "mockup-seller-dashboard--mockup-seller-payouts" + ], + "stepDescriptions": [ + "New listing → New Listing. Seller creates a listing; it goes into review and only appears in search once approved (within {{LISTING_REVIEW_SLA_HOURS}} h).", + "Submit for review → My Listings. Listing states from the moderation workflow — in review, live, rejected with a reason.", + "Edit & resubmit → New Listing. Seller creates a listing; it goes into review and only appears in search once approved (within {{LISTING_REVIEW_SLA_HOURS}} h).", + "Payouts → Payouts. Per-order split: {{COMMISSION_PCT}}% commission deducted, payouts held for the protection window and frozen while a dispute is open." + ] + }, + { + "id": "seq-flow-trust-safety", + "name": "Trust & Safety journey", + "relationIds": [ + "mockup-listing-review-queue--mockup-seller-investigation", + "mockup-seller-investigation--mockup-ts-dispute-case", + "mockup-seller-investigation--mockup-listing-review-queue" + ], + "stepDescriptions": [ + "Suspend → Seller Investigation. Suspending a seller removes every listing from search within {{SUPPRESSION_MINUTES}} minutes.", + "Disputes → Dispute Case. Evidence from both sides; the payout stays frozen until a resolution is chosen.", + "Suspend seller → Listing Review Queue. Trust & Safety queue sorted by time left on the review SLA." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "buyer", + "seller", + "trust-safety-agent", + "platform", + "payments-provider", + "notification-provider" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "buyer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "seller": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-agent": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "payments-provider": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "notification-provider": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "buyer", + "seller", + "trust-safety-agent", + "platform", + "listings-service", + "order-checkout-service", + "payments-facilitator-adapter", + "payout-worker", + "trust-safety-console", + "order-events", + "listings-db", + "orders-db", + "search-index", + "payments-provider", + "notification-provider" + ], + "positions": { + "buyer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "listings-db": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "listings-service": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "notification-provider": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "order-checkout-service": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "order-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments-facilitator-adapter": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "payments-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "payout-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "search-index": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "seller": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-agent": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + } + } + }, { "id": "bp-marketplace-platform-flow", "name": "Seller lists, buyer orders, payout releases after the dispute window", @@ -1072,7 +1288,353 @@ "payments-provider", "notification-provider" ], - "positions": {} + "positions": { + "buyer": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "listings-db": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "listings-service": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "notification-provider": { + "x": 420, + "y": 1160, + "width": 360, + "height": 260 + }, + "order-checkout-service": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "order-events": { + "x": 710, + "y": 350, + "width": 220, + "height": 95 + }, + "orders-db": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "payments-facilitator-adapter": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "payments-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "payout-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "search-index": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "seller": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-agent": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "trust-safety-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-listing-moderation", + "req-commission-accuracy", + "req-dispute-window-hold", + "req-delisting-suppression", + "ff-payout-reconciliation-guard", + "ff-chargeback-rate-guard", + "ff-search-relevance-guard", + "adr-centralised-search-index", + "adr-suspend-first-moderation", + "listings-service", + "payments-facilitator-adapter", + "payout-worker", + "search-index", + "trust-safety-console" + ], + "positions": { + "adr-centralised-search-index": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-suspend-first-moderation": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "ff-chargeback-rate-guard": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-payout-reconciliation-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-search-relevance-guard": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "listings-service": { + "x": 30, + "y": 110, + "width": 300, + "height": 200 + }, + "payments-facilitator-adapter": { + "x": 710, + "y": 110, + "width": 300, + "height": 200 + }, + "payout-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 750 + }, + "req-commission-accuracy": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-delisting-suppression": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-dispute-window-hold": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-listing-moderation": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "search-index": { + "x": 710, + "y": 590, + "width": 190, + "height": 130 + }, + "trust-safety-console": { + "x": 370, + "y": 350, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-flow-buyer", + "name": "Buyer journey", + "kind": "dynamic", + "sequenceId": "seq-flow-buyer", + "nodeIds": [ + "flow-buyer", + "mockup-mp-search", + "mockup-mp-listing", + "mockup-mp-checkout", + "mockup-order-dispute", + "mockup-mp-open-dispute", + "mockup-mp-dispute-status" + ], + "positions": { + "flow-buyer": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 630 + }, + "mockup-mp-checkout": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-mp-dispute-status": { + "x": 30, + "y": 410, + "width": 220, + "height": 190 + }, + "mockup-mp-listing": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-mp-open-dispute": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-mp-search": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-order-dispute": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-seller", + "name": "Seller journey", + "kind": "dynamic", + "sequenceId": "seq-flow-seller", + "nodeIds": [ + "flow-seller", + "mockup-seller-dashboard", + "mockup-new-listing", + "mockup-my-listings", + "mockup-seller-payouts" + ], + "positions": { + "flow-seller": { + "x": 0, + "y": 2794, + "width": 1240, + "height": 330 + }, + "mockup-my-listings": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-new-listing": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-seller-dashboard": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-seller-payouts": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-trust-safety", + "name": "Trust & Safety journey", + "kind": "dynamic", + "sequenceId": "seq-flow-trust-safety", + "nodeIds": [ + "flow-trust-safety", + "mockup-listing-review-queue", + "mockup-seller-investigation", + "mockup-ts-dispute-case" + ], + "positions": { + "flow-trust-safety": { + "x": 0, + "y": 3204, + "width": 920, + "height": 330 + }, + "mockup-listing-review-queue": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-seller-investigation": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-ts-dispute-case": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-realtime-collaboration.radical b/hub/blueprint/bp-realtime-collaboration.radical index 8acd379..70d210e 100644 --- a/hub/blueprint/bp-realtime-collaboration.radical +++ b/hub/blueprint/bp-realtime-collaboration.radical @@ -842,9 +842,167 @@ "Every other gateway instance receives it and relays it to its own connected replicas — merge order doesn't matter, so this can happen in any order relative to other concurrent edits.", "The originating gateway durably appends the operation to the op log." ] + }, + { + "id": "seq-flow-collaborator", + "name": "Collaborator journey", + "relationIds": [ + "mockup-doc-list--mockup-document-editor", + "mockup-document-editor--mockup-reconnect-merge", + "mockup-document-editor--mockup-version-history", + "mockup-version-history--mockup-restore-version", + "mockup-restore-version--mockup-document-editor" + ], + "stepDescriptions": [ + "Open → Document Editor. Everyone edits a local replica: changes apply instantly, keep working offline (up to {{OFFLINE_GRACE_HOURS}} h) and merge on reconnect; presence shows up to {{MAX_DOC_COLLABORATORS}} collaborators.", + "Reconnect → Back Online — Merged. On reconnect the offline operation log is replayed and merged automatically — no conflict dialogs, no lost edits.", + "History → Version History. Browse and restore any earlier state of the document, reconstructed from snapshots plus the operation log.", + "Restore this version → Restore Version. Restoring an older state is itself a new version, reconstructed from snapshot plus operation log.", + "Restore → Document Editor. Everyone edits a local replica: changes apply instantly, keep working offline (up to {{OFFLINE_GRACE_HOURS}} h) and merge on reconnect; presence shows up to {{MAX_DOC_COLLABORATORS}} collaborators." + ] + }, + { + "id": "seq-flow-workspace-admin", + "name": "Workspace admin journey", + "relationIds": [ + "mockup-share-dialog--mockup-workspace-members", + "mockup-workspace-members--mockup-remove-member", + "mockup-remove-member--mockup-access-revoked" + ], + "stepDescriptions": [ + "Manage members → Workspace Members. Everyone with access to the workspace and their role.", + "Remove → Remove Member. Removal disconnects live sessions immediately instead of waiting for tokens to expire.", + "Remove → Access Removed. What the removed collaborator sees: the session is closed and further operations are rejected." + ] } ], "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "collaborator", + "workspace-admin", + "platform", + "identity-provider" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "collaborator": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "workspace-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "identity-provider": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "collaborator", + "workspace-admin", + "platform", + "editor-client", + "sync-gateway", + "op-broadcast-bus", + "snapshot-worker", + "permission-service", + "op-log-store", + "snapshot-store", + "identity-provider" + ], + "positions": { + "collaborator": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "editor-client": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + }, + "identity-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "op-broadcast-bus": { + "x": 710, + "y": 110, + "width": 220, + "height": 95 + }, + "op-log-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "permission-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 960, + "height": 750 + }, + "snapshot-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "snapshot-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "sync-gateway": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "workspace-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + } + } + }, { "id": "bp-realtime-collaboration-flow", "name": "An edit is accepted, broadcast, and persisted", @@ -863,7 +1021,284 @@ "snapshot-store", "identity-provider" ], - "positions": {} + "positions": { + "collaborator": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "editor-client": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + }, + "identity-provider": { + "x": 0, + "y": 1160, + "width": 360, + "height": 260 + }, + "op-broadcast-bus": { + "x": 710, + "y": 110, + "width": 220, + "height": 95 + }, + "op-log-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "permission-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 960, + "height": 750 + }, + "snapshot-store": { + "x": 370, + "y": 590, + "width": 190, + "height": 130 + }, + "snapshot-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "sync-gateway": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + }, + "workspace-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-offline-merge", + "req-room-capacity", + "req-access-revocation", + "req-snapshot-recovery", + "ff-presence-fanout-guard", + "ff-snapshot-compaction-lag", + "ff-reconnect-success-rate", + "adr-crdt-over-ot", + "adr-stateless-gateway-fleet", + "sync-gateway", + "permission-service", + "op-log-store", + "snapshot-worker", + "editor-client" + ], + "positions": { + "adr-crdt-over-ot": { + "x": 0, + "y": 1912, + "width": 260, + "height": 52 + }, + "adr-stateless-gateway-fleet": { + "x": 320, + "y": 1912, + "width": 260, + "height": 52 + }, + "editor-client": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + }, + "ff-presence-fanout-guard": { + "x": 0, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-reconnect-success-rate": { + "x": 640, + "y": 1740, + "width": 260, + "height": 52 + }, + "ff-snapshot-compaction-lag": { + "x": 320, + "y": 1740, + "width": 260, + "height": 52 + }, + "op-log-store": { + "x": 30, + "y": 590, + "width": 190, + "height": 130 + }, + "permission-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 960, + "height": 750 + }, + "req-access-revocation": { + "x": 640, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-offline-merge": { + "x": 0, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-room-capacity": { + "x": 320, + "y": 1540, + "width": 260, + "height": 80 + }, + "req-snapshot-recovery": { + "x": 960, + "y": 1540, + "width": 260, + "height": 80 + }, + "snapshot-worker": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "sync-gateway": { + "x": 370, + "y": 110, + "width": 300, + "height": 200 + } + } + }, + { + "id": "view-flow-collaborator", + "name": "Collaborator journey", + "kind": "dynamic", + "sequenceId": "seq-flow-collaborator", + "nodeIds": [ + "flow-collaborator", + "mockup-doc-list", + "mockup-document-editor", + "mockup-reconnect-merge", + "mockup-version-history", + "mockup-restore-version" + ], + "positions": { + "flow-collaborator": { + "x": 0, + "y": 2084, + "width": 1560, + "height": 330 + }, + "mockup-doc-list": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-document-editor": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-reconnect-merge": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-restore-version": { + "x": 1310, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-version-history": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-workspace-admin", + "name": "Workspace admin journey", + "kind": "dynamic", + "sequenceId": "seq-flow-workspace-admin", + "nodeIds": [ + "flow-workspace-admin", + "mockup-share-dialog", + "mockup-workspace-members", + "mockup-remove-member", + "mockup-access-revoked" + ], + "positions": { + "flow-workspace-admin": { + "x": 0, + "y": 2494, + "width": 1240, + "height": 330 + }, + "mockup-access-revoked": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-remove-member": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-share-dialog": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-workspace-members": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + } + } } ] } diff --git a/hub/blueprint/bp-saas-b2b-platform.radical b/hub/blueprint/bp-saas-b2b-platform.radical index 6396024..1581387 100644 --- a/hub/blueprint/bp-saas-b2b-platform.radical +++ b/hub/blueprint/bp-saas-b2b-platform.radical @@ -971,5 +971,591 @@ "targetId": "admin-console", "relationType": "presented-by" } + ], + "sequences": [ + { + "id": "seq-technical", + "name": "Sign-up to a provisioned workspace", + "relationIds": [ + "tenant-admin--webapp", + "webapp--gateway", + "gateway--identity-service", + "identity-service--identity-db", + "identity-service--event-bus", + "notification-service--event-bus", + "notification-service--email-provider", + "tenant-user--webapp", + "gateway--core-service", + "core-service--tenant-db" + ], + "stepDescriptions": [ + "The new customer's admin signs up in the web app.", + "Web app calls the sign-up API.", + "Gateway routes to the Identity & Tenant Service.", + "Tenant, admin user and default roles are created.", + "TenantProvisioned is published.", + "Notification Service picks up the lifecycle event.", + "Welcome e-mail with the workspace link is sent.", + "Invited users sign in to the new workspace.", + "Requests reach the core service with the resolved tenant context.", + "Every query is scoped by tenant_id." + ] + }, + { + "id": "seq-flow-onboarding", + "name": "Onboarding journey", + "relationIds": [ + "mockup-signup--mockup-provisioning", + "mockup-provisioning--mockup-invite-team" + ], + "stepDescriptions": [ + "Create workspace → Provisioning. Provisioning progress; an isolated workspace is ready within {{PROVISION_MINUTES}} minutes.", + "Ready → Invite Your Team. Last onboarding step; invites land in the new tenant only." + ] + }, + { + "id": "seq-flow-tenant-user", + "name": "Tenant user journey", + "relationIds": [ + "mockup-workspace-dashboard--mockup-project", + "mockup-workspace-dashboard--mockup-read-only" + ], + "stepDescriptions": [ + "Open project → Project. Project board; all data is scoped by tenant id — nothing from other tenants is reachable.", + "Payment fails → Read-Only Workspace. After a failed payment the workspace stays readable but writes are blocked until billing is fixed." + ] + }, + { + "id": "seq-flow-tenant-admin", + "name": "Tenant admin journey", + "relationIds": [ + "mockup-billing-usage--mockup-update-payment", + "mockup-billing-usage--mockup-sso-settings", + "mockup-sso-settings--mockup-users-roles" + ], + "stepDescriptions": [ + "Update payment method → Update Payment Method. New card; the failed invoice is retried immediately and write access comes back on success.", + "Single sign-on → Single Sign-On. SAML/OIDC federation with the tenant's own identity provider.", + "Users & roles → Users & Roles. Seats, roles and how each user signs in." + ] + }, + { + "id": "seq-flow-platform-ops", + "name": "Platform ops journey", + "relationIds": [ + "mockup-tenant-list--mockup-tenant-detail" + ], + "stepDescriptions": [ + "Open tenant → Tenant Detail. Per-tenant health against the {{MAX_TENANT_SHARE_PCT}}% noisy-neighbour limit; impersonation is audited." + ] + } + ], + "views": [ + { + "id": "view-context", + "name": "System context", + "kind": "static", + "nodeIds": [ + "tenant-user", + "tenant-admin", + "ops-staff", + "platform", + "idp", + "payment-provider", + "email-provider" + ], + "collapsedNodeIds": [ + "platform" + ], + "positions": { + "ops-staff": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "platform": { + "x": 0, + "y": 290, + "width": 280, + "height": 180 + }, + "tenant-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "tenant-user": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "idp": { + "x": 400, + "y": 290, + "width": 360, + "height": 260 + }, + "payment-provider": { + "x": 820, + "y": 290, + "width": 360, + "height": 260 + }, + "email-provider": { + "x": 1240, + "y": 290, + "width": 360, + "height": 260 + } + } + }, + { + "id": "view-containers", + "name": "Containers", + "kind": "static", + "nodeIds": [ + "tenant-user", + "tenant-admin", + "ops-staff", + "platform", + "webapp", + "admin-console", + "gateway", + "identity-service", + "billing-service", + "core-service", + "notification-service", + "event-bus", + "tenant-db", + "identity-db", + "idp", + "payment-provider", + "email-provider" + ], + "positions": { + "admin-console": { + "x": 280, + "y": 110, + "width": 210, + "height": 140 + }, + "billing-service": { + "x": 370, + "y": 350, + "width": 300, + "height": 200 + }, + "core-service": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "email-provider": { + "x": 840, + "y": 1400, + "width": 360, + "height": 260 + }, + "event-bus": { + "x": 370, + "y": 590, + "width": 220, + "height": 95 + }, + "gateway": { + "x": 530, + "y": 110, + "width": 300, + "height": 200 + }, + "identity-db": { + "x": 260, + "y": 830, + "width": 190, + "height": 130 + }, + "identity-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "idp": { + "x": 0, + "y": 1400, + "width": 360, + "height": 260 + }, + "notification-service": { + "x": 30, + "y": 590, + "width": 300, + "height": 200 + }, + "ops-staff": { + "x": 420, + "y": 0, + "width": 150, + "height": 170 + }, + "payment-provider": { + "x": 420, + "y": 1400, + "width": 360, + "height": 260 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 990 + }, + "tenant-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "tenant-db": { + "x": 30, + "y": 830, + "width": 190, + "height": 130 + }, + "tenant-user": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "webapp": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-technical-flow", + "name": "Sign-up to a provisioned workspace", + "kind": "dynamic", + "sequenceId": "seq-technical", + "nodeIds": [ + "tenant-admin", + "webapp", + "gateway", + "identity-service", + "identity-db", + "event-bus", + "notification-service", + "email-provider", + "tenant-user", + "core-service", + "tenant-db" + ], + "positions": { + "core-service": { + "x": 710, + "y": 350, + "width": 300, + "height": 200 + }, + "email-provider": { + "x": 840, + "y": 1400, + "width": 360, + "height": 260 + }, + "event-bus": { + "x": 370, + "y": 590, + "width": 220, + "height": 95 + }, + "gateway": { + "x": 530, + "y": 110, + "width": 300, + "height": 200 + }, + "identity-db": { + "x": 260, + "y": 830, + "width": 190, + "height": 130 + }, + "identity-service": { + "x": 30, + "y": 350, + "width": 300, + "height": 200 + }, + "notification-service": { + "x": 30, + "y": 590, + "width": 300, + "height": 200 + }, + "platform": { + "x": 0, + "y": 290, + "width": 1040, + "height": 990 + }, + "tenant-admin": { + "x": 210, + "y": 0, + "width": 150, + "height": 170 + }, + "tenant-db": { + "x": 30, + "y": 830, + "width": 190, + "height": 130 + }, + "tenant-user": { + "x": 0, + "y": 0, + "width": 150, + "height": 170 + }, + "webapp": { + "x": 30, + "y": 110, + "width": 210, + "height": 140 + } + } + }, + { + "id": "view-governance", + "name": "Governance", + "kind": "static", + "nodeIds": [ + "req-tenant-isolation", + "req-tenant-provisioning-time", + "req-subscription-enforcement", + "req-usage-metering-accuracy", + "ff-tenant-isolation-fuzz", + "ff-noisy-neighbor-guard", + "ff-billing-reconciliation", + "adr-tenancy-model", + "adr-sso-federation" + ], + "positions": { + "adr-sso-federation": { + "x": 320, + "y": 2152, + "width": 260, + "height": 52 + }, + "adr-tenancy-model": { + "x": 0, + "y": 2152, + "width": 260, + "height": 52 + }, + "ff-billing-reconciliation": { + "x": 640, + "y": 1980, + "width": 260, + "height": 52 + }, + "ff-noisy-neighbor-guard": { + "x": 320, + "y": 1980, + "width": 260, + "height": 52 + }, + "ff-tenant-isolation-fuzz": { + "x": 0, + "y": 1980, + "width": 260, + "height": 52 + }, + "req-subscription-enforcement": { + "x": 640, + "y": 1780, + "width": 260, + "height": 80 + }, + "req-tenant-isolation": { + "x": 0, + "y": 1780, + "width": 260, + "height": 80 + }, + "req-tenant-provisioning-time": { + "x": 320, + "y": 1780, + "width": 260, + "height": 80 + }, + "req-usage-metering-accuracy": { + "x": 960, + "y": 1780, + "width": 260, + "height": 80 + } + } + }, + { + "id": "view-flow-onboarding", + "name": "Onboarding journey", + "kind": "dynamic", + "sequenceId": "seq-flow-onboarding", + "nodeIds": [ + "flow-onboarding", + "mockup-signup", + "mockup-provisioning", + "mockup-invite-team" + ], + "positions": { + "flow-onboarding": { + "x": 0, + "y": 2324, + "width": 920, + "height": 330 + }, + "mockup-invite-team": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-provisioning": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-signup": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-tenant-user", + "name": "Tenant user journey", + "kind": "dynamic", + "sequenceId": "seq-flow-tenant-user", + "nodeIds": [ + "flow-tenant-user", + "mockup-workspace-dashboard", + "mockup-project", + "mockup-read-only" + ], + "positions": { + "flow-tenant-user": { + "x": 0, + "y": 2734, + "width": 920, + "height": 330 + }, + "mockup-project": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-read-only": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-workspace-dashboard": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-tenant-admin", + "name": "Tenant admin journey", + "kind": "dynamic", + "sequenceId": "seq-flow-tenant-admin", + "nodeIds": [ + "flow-tenant-admin", + "mockup-billing-usage", + "mockup-update-payment", + "mockup-sso-settings", + "mockup-users-roles" + ], + "positions": { + "flow-tenant-admin": { + "x": 0, + "y": 3144, + "width": 1240, + "height": 330 + }, + "mockup-billing-usage": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-sso-settings": { + "x": 670, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-update-payment": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-users-roles": { + "x": 990, + "y": 110, + "width": 220, + "height": 190 + } + } + }, + { + "id": "view-flow-platform-ops", + "name": "Platform ops journey", + "kind": "dynamic", + "sequenceId": "seq-flow-platform-ops", + "nodeIds": [ + "flow-platform-ops", + "mockup-tenant-list", + "mockup-tenant-detail" + ], + "positions": { + "flow-platform-ops": { + "x": 0, + "y": 3554, + "width": 600, + "height": 330 + }, + "mockup-tenant-detail": { + "x": 350, + "y": 110, + "width": 220, + "height": 190 + }, + "mockup-tenant-list": { + "x": 30, + "y": 110, + "width": 220, + "height": 190 + } + } + } ] } diff --git a/src/renderer/src/components/HubImportModal.tsx b/src/renderer/src/components/HubImportModal.tsx index 70d6c0a..ba5b434 100644 --- a/src/renderer/src/components/HubImportModal.tsx +++ b/src/renderer/src/components/HubImportModal.tsx @@ -6,6 +6,7 @@ import type { C4Node, C4Relation, C4ElementType, DiagramSequence } from '../type import { NODE_SIZES } from '../types/c4' import { isParentAllowed } from '../types/metamodel' import { nodeTypeTheme } from '../types/hubTheme' +import { conceptViewsToDiagram } from '../hub/conceptViews' // ─── Props ────────────────────────────────────────────────────────────────── @@ -570,11 +571,13 @@ export function HubImportModal({ open, onClose, preselectedIds }: Props): React. // Sequences (dynamic flows) survive only if every step's relation was imported. const newSequences: Record = {} + const seqIdMap = new Map() for (const raw of concept.sequences ?? []) { const ids = (raw.relationIds as string[] | undefined) ?? [] const mapped = ids.map((id) => relIdMap.get(id)) if (ids.length === 0 || mapped.some((id) => !id)) continue const seqId = crypto.randomUUID() + if (typeof raw.id === 'string') seqIdMap.set(raw.id, seqId) newSequences[seqId] = { id: seqId, name: (raw.name as string) || concept.name, @@ -585,12 +588,20 @@ export function HubImportModal({ open, onClose, preselectedIds }: Props): React. // Single undo + bulk insert — bypasses per-node metamodel validation // so curated hub concepts always import cleanly. + // Named views travel with the concept, restricted to what was imported. + const newViews = conceptViewsToDiagram( + concept.views, + { node: (id) => idMap.get(id), relation: (id) => relIdMap.get(id), sequence: (id) => seqIdMap.get(id) }, + { newId: () => crypto.randomUUID(), namePrefix: concept.name }, + ) + store._pushUndo() store._markMilestoneEdit() useDiagramStore.setState((state) => { Object.assign(state.c4Nodes, newNodes) Object.assign(state.c4Relations, newRelations) Object.assign(state.sequences, newSequences) + for (const v of newViews) state.views[v.id] = v if (state.activeViewId && state.views[state.activeViewId]) { state.views[state.activeViewId].nodeIds.push(...Object.keys(newNodes)) } diff --git a/src/renderer/src/hub/HubApp.tsx b/src/renderer/src/hub/HubApp.tsx index 5be5a5c..c8d414b 100644 --- a/src/renderer/src/hub/HubApp.tsx +++ b/src/renderer/src/hub/HubApp.tsx @@ -263,8 +263,10 @@ function HubAppInner(): React.ReactElement { const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorage.getItem(LS_THEME) as 'dark' | 'light') || 'dark') const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) const [tagsExpanded, setTagsExpanded] = useState(false) - // Pending view kind from a deep link, applied once the concept is loaded. + // Pending view kind (and named canvas view) from a deep link, applied once the concept is loaded. const pendingView = useRef(parseHubHash(window.location.hash).view) + const pendingCanvasView = useRef(parseHubHash(window.location.hash).canvasView) + const views = useDiagramStore((s) => s.views) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -291,6 +293,13 @@ function HubAppInner(): React.ReactElement { ) const [conceptError, setConceptError] = useState(null) const viewKind = kindForViewId(activeViewId) + // The concept's own named canvas views ("Containers", a screen flow, …); + // `null` = the full model ("All elements"). + const canvasViews = useMemo( + () => Object.values(views).filter((v) => kindForViewId(v.id) === 'canvas'), + [views], + ) + const canvasViewId = viewKind === 'canvas' ? activeViewId : null // Fetch the selected concept's .radical file once the index knows about it. useEffect(() => { @@ -308,8 +317,12 @@ function HubAppInner(): React.ReactElement { if (!concept) return loadDiagram(conceptToDiagramData(concept)) const kind = pendingView.current ?? defaultViewKind(concept) + const named = pendingCanvasView.current pendingView.current = undefined - setActiveView(viewIdForKind(kind)) + pendingCanvasView.current = undefined + const hasNamed = kind === 'canvas' && !!named && (concept.views ?? []).some((v) => v.id === named) + if (hasNamed) loadDiagram(conceptToDiagramData(concept, { layoutFrom: named })) + setActiveView(hasNamed ? named! : viewIdForKind(kind)) if (kind === 'canvas') return scheduleFit(fitAll) }, [concept, loadDiagram, setActiveView, fitAll]) @@ -328,6 +341,7 @@ function HubAppInner(): React.ReactElement { browse: browsing, concept: concept?.id, view: concept ? viewKind : undefined, + canvasView: concept && canvasViewId ? canvasViewId : undefined, category: activeCategory ?? undefined, tag: setToCsv(activeTags), status: setToCsv(activeStatuses), @@ -339,14 +353,15 @@ function HubAppInner(): React.ReactElement { const url = hash || window.location.pathname if (changed) history.pushState(null, '', url) else history.replaceState(null, '', url) - }, [browsing, concept, viewKind, activeCategory, activeTags, activeStatuses, sortBy]) + }, [browsing, concept, viewKind, canvasViewId, activeCategory, activeTags, activeStatuses, sortBy]) // URL → state (back/forward, pasted links). useEffect(() => { const onHash = () => { const r = parseHubHash(window.location.hash) setBrowsing(!!r.browse) - if (r.concept !== conceptId) { pendingView.current = r.view; setConceptId(r.concept) } + if (r.concept !== conceptId) { pendingView.current = r.view; pendingCanvasView.current = r.canvasView; setConceptId(r.concept) } + else if (r.view === 'canvas' && (r.canvasView ?? null) !== canvasViewId) switchCanvasViewRef.current(r.canvasView ?? null) else if (r.view && r.view !== viewKind) setActiveView(viewIdForKind(r.view)) setCategory(r.category ?? null) setTags(csvToSet(r.tag)) @@ -359,12 +374,23 @@ function HubAppInner(): React.ReactElement { window.removeEventListener('hashchange', onHash) window.removeEventListener('popstate', onHash) } - }, [conceptId, viewKind, setActiveView, setCategory, setTags, setStatuses, setSortBy]) + }, [conceptId, viewKind, canvasViewId, setActiveView, setCategory, setTags, setStatuses, setSortBy]) + + // Reload the concept laid out for the chosen view (null = the full model + // in its default layout) — see conceptToDiagramData's `layoutFrom`. + const switchCanvasView = useCallback((viewId: string | null) => { + if (!concept) return + loadDiagram(conceptToDiagramData(concept, { layoutFrom: viewId })) + setActiveView(viewId) + scheduleFit(fitAll) + }, [concept, loadDiagram, setActiveView, fitAll]) + const switchCanvasViewRef = useRef(switchCanvasView) + switchCanvasViewRef.current = switchCanvasView const switchView = useCallback((kind: HubViewKind) => { + if (kind === 'canvas') return switchCanvasView(null) setActiveView(viewIdForKind(kind)) - if (kind === 'canvas') scheduleFit(fitAll) - }, [setActiveView, fitAll]) + }, [setActiveView, switchCanvasView]) const toggleSelect = useCallback((id: string) => { setSelectedIds((prev) => toggleInSet(prev, id)) @@ -663,6 +689,19 @@ function HubAppInner(): React.ReactElement { {viewKind === 'canvas' && ( <> + {canvasViews.length > 0 && ( + + )} diff --git a/src/renderer/src/hub/conceptToDiagram.ts b/src/renderer/src/hub/conceptToDiagram.ts index 2d2f3e2..74b38c3 100644 --- a/src/renderer/src/hub/conceptToDiagram.ts +++ b/src/renderer/src/hub/conceptToDiagram.ts @@ -12,6 +12,7 @@ import type { C4Node, C4Relation, C4ElementType, DiagramData, DiagramSequence, D import { NODE_SIZES } from '../types/c4' import { builtInGovernanceMetamodel } from '../types/metamodel' import type { HubConcept, TemplateParam } from '../store/hubStore' +import { conceptViewsToDiagram } from './conceptViews' export const HUB_WIKI_VIEW_ID = 'hub-wiki' export const HUB_TABLE_VIEW_ID = 'hub-table' @@ -52,7 +53,11 @@ export function substituteTemplateDefaults(str: string, params: TemplateParam[] }) } -export function conceptToDiagramData(concept: HubConcept): DiagramData { +/** `layoutFrom`: id of one of the concept's views whose stored positions + * replace the node coordinates — the Hub viewer runs in explore mode, where + * switching the active view deliberately keeps the current layout, so it + * reloads the concept laid out for the chosen view instead. */ +export function conceptToDiagramData(concept: HubConcept, opts: { layoutFrom?: string | null } = {}): DiagramData { const ids = new Set(concept.nodes.map((n) => String(n.id))) const params = concept.templateParams @@ -102,11 +107,26 @@ export function conceptToDiagramData(concept: HubConcept): DiagramData { const allIds = nodes.map((n) => n.id) const firstRoot = nodes.find((n) => !n.parentId)?.id ?? allIds[0] ?? null + const seqIds = new Set(sequences.map((s) => s.id)) + const keep = (set: Set) => (id: string) => (set.has(id) ? id : undefined) const views: DiagramView[] = [ + ...conceptViewsToDiagram( + concept.views, + { node: keep(ids), relation: keep(relIds), sequence: keep(seqIds) }, + { keepPositions: true }, + ), { id: HUB_WIKI_VIEW_ID, name: 'Wiki', kind: 'wiki', nodeIds: allIds, positions: {}, wikiFocusId: firstRoot }, { id: HUB_TABLE_VIEW_ID, name: 'Table', kind: 'table', nodeIds: allIds, positions: {} }, ] + const layout = opts.layoutFrom ? views.find((v) => v.id === opts.layoutFrom)?.positions : undefined + if (layout) { + for (const n of nodes) { + const p = layout[n.id] + if (p) Object.assign(n, { x: p.x, y: p.y, width: p.width, height: p.height }) + } + } + return { nodes, relations, diff --git a/src/renderer/src/hub/conceptViews.ts b/src/renderer/src/hub/conceptViews.ts new file mode 100644 index 0000000..ffab088 --- /dev/null +++ b/src/renderer/src/hub/conceptViews.ts @@ -0,0 +1,97 @@ +// ─── Hub concept views → studio views ─────────────────────────────────────── +// A concept file may carry named canvas views (`views`: static diagrams such +// as "Containers" or a screen flow, and dynamic views that play a sequence). +// The same translation is needed in three places — the Hub viewer (ids kept +// as-is) and both studio import paths (ids remapped to the freshly created +// nodes / relations / sequences) — so it lives here once. +// +// Only canvas kinds are taken over: the Hub builds its own wiki / table views +// and the studio has its defaults. A view keeps only the elements that exist +// on the receiving side, so a partial blueprint import still gets sensible +// views, and a view left with no nodes is dropped. A dynamic view whose +// sequence did not survive falls back to a static view of the same nodes. +// +// Per-view positions are only meaningful where node ids and coordinates are +// the concept's own (the Hub viewer); an import re-ids nodes and re-centres +// them on the viewport, so it drops them and the view keeps the imported +// layout. + +import type { DiagramView, NodePosition } from '../types/c4' + +type Raw = Record +type IdMap = (id: string) => string | undefined + +export interface ConceptViewMaps { + node: IdMap + relation: IdMap + sequence: IdMap +} + +export interface ConceptViewOptions { + /** New id for each view (import); omitted → keep the concept's view id. */ + newId?: () => string + /** Prepended to each view name, e.g. the concept name on import. */ + namePrefix?: string + /** Keep the view's stored node positions (only when ids are unchanged). */ + keepPositions?: boolean +} + +function mapPositions(raw: unknown, map: IdMap): Record { + const out: Record = {} + if (!raw || typeof raw !== 'object') return out + for (const [id, p] of Object.entries(raw as Record)) { + const mapped = map(id) + const pos = p as Record | null + if (!mapped || !pos) continue + const { x, y, width, height } = pos + // applyPositions overwrites width/height too, so an entry without them + // would blank the node's size — skip anything incomplete. + if ([x, y, width, height].every((v) => typeof v === 'number')) { + out[mapped] = { x, y, width, height } as NodePosition + } + } + return out +} + +function mapIds(raw: unknown, map: IdMap): string[] { + if (!Array.isArray(raw)) return [] + const out: string[] = [] + for (const id of raw) { + if (typeof id !== 'string') continue + const mapped = map(id) + if (mapped) out.push(mapped) + } + return out +} + +export function conceptViewsToDiagram( + rawViews: Raw[] | undefined, + maps: ConceptViewMaps, + opts: ConceptViewOptions = {}, +): DiagramView[] { + const out: DiagramView[] = [] + for (const [i, raw] of (rawViews ?? []).entries()) { + const kind = raw.kind === 'dynamic' ? 'dynamic' : raw.kind === undefined || raw.kind === 'static' ? 'static' : null + if (!kind) continue + const nodeIds = mapIds(raw.nodeIds, maps.node) + if (nodeIds.length === 0) continue + const sequenceId = kind === 'dynamic' && typeof raw.sequenceId === 'string' ? maps.sequence(raw.sequenceId) : undefined + const name = typeof raw.name === 'string' && raw.name ? raw.name : `View ${i + 1}` + const view: DiagramView = { + id: opts.newId ? opts.newId() : typeof raw.id === 'string' ? raw.id : `view-${i}`, + name: opts.namePrefix ? `${opts.namePrefix} — ${name}` : name, + kind: sequenceId ? 'dynamic' : 'static', + nodeIds, + positions: opts.keepPositions ? mapPositions(raw.positions, maps.node) : {}, + } + if (sequenceId) view.sequenceId = sequenceId + const collapsed = mapIds(raw.collapsedNodeIds, maps.node) + if (collapsed.length) view.collapsedNodeIds = collapsed + const expanded = mapIds(raw.expandedNodeIds, maps.node) + if (expanded.length) view.expandedNodeIds = expanded + const hidden = mapIds(raw.hiddenRelationIds, maps.relation) + if (hidden.length) view.hiddenRelationIds = hidden + out.push(view) + } + return out +} diff --git a/src/renderer/src/hub/hub.css b/src/renderer/src/hub/hub.css index 9d71caf..acd6a3f 100644 --- a/src/renderer/src/hub/hub.css +++ b/src/renderer/src/hub/hub.css @@ -207,6 +207,17 @@ } .hub-list-count { display: flex; align-items: center; gap: 8px; } .hub-sort { display: flex; align-items: center; gap: 6px; } +.hub-view-select { + max-width: 260px; + height: 28px; + padding: 0 8px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg); + color: var(--text-primary); + font: inherit; + font-size: 12px; +} .hub-sort select { height: 24px; padding: 0 5px; border-radius: 6px; diff --git a/src/renderer/src/hub/hubFormat.ts b/src/renderer/src/hub/hubFormat.ts index 78fd22e..9b71625 100644 --- a/src/renderer/src/hub/hubFormat.ts +++ b/src/renderer/src/hub/hubFormat.ts @@ -47,6 +47,8 @@ export interface HubRadicalDoc { nodes: Array> relations?: Array> sequences?: Array> + /** Named canvas views (static, or dynamic over a sequence). */ + views?: Array> [extra: string]: unknown } @@ -56,6 +58,8 @@ export interface HubConcept extends HubConceptMeta { relations?: Array> /** Named flows over `relations` (Studio dynamic views). */ sequences?: Array> + /** Named canvas views — see hub/conceptViews.ts. */ + views?: Array> } /** One entry of `hub/index.json`. Enough to render cards and decide drop @@ -82,14 +86,16 @@ export function conceptFile(meta: Pick): stri export function docToConcept(doc: HubRadicalDoc): HubConcept { const c: HubConcept = { ...doc.hub, nodes: doc.nodes, relations: doc.relations } if (doc.sequences?.length) c.sequences = doc.sequences + if (doc.views?.length) c.views = doc.views return c } export function conceptToDoc(concept: HubConcept): HubRadicalDoc { - const { nodes, relations, sequences, ...hub } = concept + const { nodes, relations, sequences, views, ...hub } = concept const d: HubRadicalDoc = { hub, nodes } if (relations) d.relations = relations if (sequences?.length) d.sequences = sequences + if (views?.length) d.views = views return d } diff --git a/src/renderer/src/hub/hubRoute.ts b/src/renderer/src/hub/hubRoute.ts index eadf191..7a28030 100644 --- a/src/renderer/src/hub/hubRoute.ts +++ b/src/renderer/src/hub/hubRoute.ts @@ -4,7 +4,7 @@ // the catalogue: // // #/browse -// #/c/[/v/][/cat/][/tag/][/status/][/sort/] +// #/c/[/v/][/cv/][/cat/][/tag/][/status/][/sort/] // // `c` selects the concept shown in the viewer, `v` its presentation, `cat` / // `tag` / `status` / `sort` the catalogue filters. `tag` and `status` hold a @@ -21,6 +21,8 @@ export interface HubRoute { browse?: boolean concept?: string view?: HubViewKind + /** Named canvas view of the concept (only with view = canvas). */ + canvasView?: string category?: string /** Comma-separated tag list. */ tag?: string @@ -49,6 +51,7 @@ export function parseHubHash(hash: string): HubRoute { const view = (VIEW_KINDS as readonly string[]).includes(map.v) ? (map.v as HubViewKind) : undefined const sort = (SORT_KEYS as readonly string[]).includes(map.sort) ? (map.sort as HubSortKey) : undefined const concept = map.c || undefined + const canvasView = view === 'canvas' || !view ? map.cv || undefined : undefined const category = map.cat || undefined const tag = map.tag || undefined const status = map.status || undefined @@ -57,6 +60,7 @@ export function parseHubHash(hash: string): HubRoute { ...(browse ? { browse: true } : {}), concept, view, + ...(canvasView ? { canvasView } : {}), category, tag, status, @@ -68,6 +72,7 @@ export function formatHubHash(route: HubRoute): string { const segs: string[] = [] if (route.concept) segs.push('c', encodeURIComponent(route.concept)) if (route.concept && route.view) segs.push('v', route.view) + if (route.concept && route.view === 'canvas' && route.canvasView) segs.push('cv', encodeURIComponent(route.canvasView)) if (route.category) segs.push('cat', encodeURIComponent(route.category)) if (route.tag) segs.push('tag', encodeURIComponent(route.tag)) if (route.status) segs.push('status', encodeURIComponent(route.status)) diff --git a/src/renderer/src/hub/importConcept.ts b/src/renderer/src/hub/importConcept.ts index 4850568..93c6036 100644 --- a/src/renderer/src/hub/importConcept.ts +++ b/src/renderer/src/hub/importConcept.ts @@ -10,6 +10,7 @@ import type { C4ElementType, C4Node, C4Relation, DiagramSequence } from '../type import { NODE_SIZES } from '../types/c4' import { useDiagramStore } from '../store/diagramStore' import type { HubConcept, HubImportRecord, TemplateParam } from '../store/hubStore' +import { conceptViewsToDiagram } from './conceptViews' export interface ImportHubConceptResult { nodeIds: string[] @@ -125,11 +126,13 @@ export function importHubConceptIntoDiagram( } const newSequences: Record = {} + const seqIdMap = new Map() for (const raw of templated.sequences ?? []) { const ids = (raw.relationIds as string[] | undefined) ?? [] const mapped = ids.map((id) => relIdMap.get(id)) if (ids.length === 0 || mapped.some((id) => !id)) continue const seqId = crypto.randomUUID() + if (typeof raw.id === 'string') seqIdMap.set(raw.id, seqId) newSequences[seqId] = { id: seqId, name: (raw.name as string) || concept.name, @@ -138,12 +141,19 @@ export function importHubConceptIntoDiagram( } } + const newViews = conceptViewsToDiagram( + templated.views, + { node: (id) => idMap.get(id), relation: (id) => relIdMap.get(id), sequence: (id) => seqIdMap.get(id) }, + { newId: () => crypto.randomUUID(), namePrefix: concept.name }, + ) + store._pushUndo() store._markMilestoneEdit() useDiagramStore.setState((state) => { Object.assign(state.c4Nodes, newNodes) Object.assign(state.c4Relations, newRelations) Object.assign(state.sequences, newSequences) + for (const v of newViews) state.views[v.id] = v if (state.activeViewId && state.views[state.activeViewId]) { state.views[state.activeViewId].nodeIds.push(...Object.keys(newNodes)) } diff --git a/tests/hubCatalogue.test.ts b/tests/hubCatalogue.test.ts index 994d61b..59c4d08 100644 --- a/tests/hubCatalogue.test.ts +++ b/tests/hubCatalogue.test.ts @@ -7,6 +7,8 @@ * - blueprint mockups form per-actor screen flows (flow groups linked by * navigates-to), use metamodel-valid relations and carry wireframes that * are already sanitised + * - blueprints ship named views (context, containers, governance, one per + * screen flow) over sequences whose steps all exist */ import { describe, it, expect } from 'vitest' import { resolve } from 'node:path' @@ -119,3 +121,37 @@ describe('blueprint mockups', () => { } }) }) + +describe('blueprint views and sequences', () => { + const blueprints = readCatalogue(HUB_DIR).filter((e) => e.doc.hub.category === 'blueprint') + + it.each(blueprints.map((e) => [e.file, e.doc] as const))('%s has consistent views and sequences', (_file, doc) => { + const nodeIds = new Set(doc.nodes.map((n) => String(n.id))) + const relIds = new Set((doc.relations ?? []).map((r) => String(r.id))) + const sequences = (doc.sequences ?? []) as Array<{ id: string; relationIds: string[]; stepDescriptions?: string[] }> + const seqIds = new Set(sequences.map((s) => s.id)) + const views = (doc.views ?? []) as Array<{ id: string; kind: string; sequenceId?: string; nodeIds: string[]; positions: Record> }> + + for (const s of sequences) { + expect(s.relationIds.length).toBeGreaterThan(0) + for (const id of s.relationIds) expect(relIds.has(id), `${s.id}: unknown relation ${id}`).toBe(true) + expect(s.stepDescriptions ?? []).toHaveLength(s.relationIds.length) + } + for (const v of views) { + expect(['static', 'dynamic']).toContain(v.kind) + for (const id of v.nodeIds) expect(nodeIds.has(id), `${v.id}: unknown node ${id}`).toBe(true) + if (v.kind === 'dynamic') expect(seqIds.has(v.sequenceId!), `${v.id}: unknown sequence`).toBe(true) + for (const [id, p] of Object.entries(v.positions)) { + expect(nodeIds.has(id)).toBe(true) + for (const k of ['x', 'y', 'width', 'height']) expect(typeof p[k], `${v.id}.${id}.${k}`).toBe('number') + } + } + + const ids = views.map((v) => v.id) + expect(ids).toEqual(expect.arrayContaining(['view-context', 'view-containers', 'view-governance'])) + expect(views.some((v) => v.kind === 'dynamic' && !v.id.startsWith('view-flow-')), 'technical flow').toBe(true) + for (const g of doc.nodes.filter((n) => n.type === 'group' && String(n.id).startsWith('flow-'))) { + expect(ids, `view for ${g.id}`).toContain(`view-${g.id}`) + } + }) +}) diff --git a/tests/hubViewer.test.ts b/tests/hubViewer.test.ts index fcbc5e5..8ddd555 100644 --- a/tests/hubViewer.test.ts +++ b/tests/hubViewer.test.ts @@ -5,6 +5,7 @@ * (ids preserved, governance metamodel, synthetic wiki/table views, * template defaults substituted, dangling parents/relations dropped) * - defaultViewKind: single governance element → wiki, bundles → canvas + * - conceptViewsToDiagram: concept views kept in the viewer / remapped on import * - parseHubHash / formatHubHash round-trip */ import { describe, it, expect } from 'vitest' @@ -16,6 +17,7 @@ import { HUB_TABLE_VIEW_ID, } from '../src/renderer/src/hub/conceptToDiagram' import { parseHubHash, formatHubHash, studioImportUrl } from '../src/renderer/src/hub/hubRoute' +import { conceptViewsToDiagram } from '../src/renderer/src/hub/conceptViews' import type { HubConcept } from '../src/renderer/src/store/hubStore' const requirement: HubConcept = { @@ -135,6 +137,15 @@ describe('hub route', () => { expect(parseHubHash(hash)).toEqual(r) }) + it('round-trips a named canvas view, and only on the canvas', () => { + const r = { browse: true, concept: 'bp-x', view: 'canvas' as const, canvasView: 'view-flow-shopper' } + const hash = formatHubHash(r) + expect(hash).toBe('#/c/bp-x/v/canvas/cv/view-flow-shopper') + expect(parseHubHash(hash)).toEqual(r) + expect(formatHubHash({ ...r, view: 'wiki' })).toBe('#/c/bp-x/v/wiki') + expect(parseHubHash('#/c/bp-x/v/wiki/cv/view-flow-shopper').canvasView).toBeUndefined() + }) + it('omits view without a concept and ignores unknown view kinds', () => { expect(formatHubHash({ view: 'table', category: 'adr' })).toBe('#/cat/adr') expect(parseHubHash('#/c/x/v/bogus')).toEqual({ browse: true, concept: 'x' }) @@ -163,3 +174,35 @@ describe('hub route', () => { expect(parseHubHash('#/sort/bogus')).toEqual({}) }) }) + +describe('concept views', () => { + const withViews: HubConcept = { + ...pattern, + sequences: [{ id: 's1', name: 'Write', relationIds: ['r1'] }], + views: [ + { id: 'v-flow', name: 'Write flow', kind: 'dynamic', sequenceId: 's1', nodeIds: ['sys', 'cmd'], + positions: { sys: { x: 5, y: 6, width: 360, height: 260 }, cmd: { x: 1, y: 2 } } }, + { id: 'v-wiki', name: 'Not a canvas view', kind: 'wiki', nodeIds: ['sys'] }, + { id: 'v-empty', name: 'Only ghosts', kind: 'static', nodeIds: ['ghost'] }, + ], + } + + it('shows the concept\'s canvas views in the viewer, before wiki / table, with complete positions only', () => { + const views = conceptToDiagramData(withViews).views! + expect(views.map((v) => v.id)).toEqual(['v-flow', HUB_WIKI_VIEW_ID, HUB_TABLE_VIEW_ID]) + expect(views[0]).toMatchObject({ kind: 'dynamic', sequenceId: 's1', nodeIds: ['sys', 'cmd'] }) + expect(views[0].positions).toEqual({ sys: { x: 5, y: 6, width: 360, height: 260 } }) + }) + + it('remaps ids on import, drops unmapped elements and positions, and falls back to static without the sequence', () => { + let n = 0 + const views = conceptViewsToDiagram( + withViews.views, + { node: (id) => (id === 'sys' ? 'new-sys' : undefined), relation: () => undefined, sequence: () => undefined }, + { newId: () => `id-${++n}`, namePrefix: 'CQRS' }, + ) + expect(views).toEqual([ + { id: 'id-1', name: 'CQRS — Write flow', kind: 'static', nodeIds: ['new-sys'], positions: {} }, + ]) + }) +})