1import{q as ie,r as f,c as _,w as oe,p as se,y as d,A as a,H as C,I as N,N as E,G as y,O as x,V as le,C as L,K as D,E as U,B as de,z as c}from"./vendor-8BbPq1DG.js";import{J as B}from"./JsonTreeNode-Dh4oRRMz.js";import{o as ce,a3 as pe,p as ue,a as me}from"./index-D-Eba6G3.js";import{useAppTemplatesStore as ge}from"./appTemplatesStore-BdXu2xhC.js";import{u as he}from"./campaignSendsStore-DFwKxiOW.js";import{u as fe}from"./workflowStore-CEcGDiF6.js";import"./aws-np-Bryvr.js";import"./appTemplatesService-CiP9GGBm.js";import"./campaignSendsService-BwcDiZnq.js";const ye=`/** 2 * Agent Alarm types - configurable alarms for monitoring agent effort metrics 3 * 4 * Alarm rules are stored as \`agentAlarmRules\` on the tenant record. 5 * Evaluated hourly by the agent-alarm-evaluator Lambda. 6 * Alarm history stored in the \`alarm-history\` DynamoDB table. 7 */ 8 9/** Available metrics that can be monitored */ 10export type AgentAlarmMetric = 11 | 'disposition_time' // Total time across multiple disposition codes (ms), metricParams = code array 12 | 'disposition_count' // Count of a specific disposition, metricParam = disposition code 13 | 'disposition_single_time'; // Time for a single disposition code (ms), metricParam = disposition code 14 15/** Supported comparison operators */ 16export type AgentAlarmOperator = '>' | '<' | '>=' | '<='; 17 18/** A single alarm rule configuration */ 19export interface AgentAlarmRule { 20 /** Unique rule identifier (UUID) */ 21 ruleId: string; 22 /** Human-readable alarm name (e.g., "Break time > 1hr") */ 23 name: string; 24 /** Whether this alarm is active */ 25 enabled: boolean; 26 /** What metric to measure */ 27 metric: AgentAlarmMetric; 28 /** Parameter for single-value parameterized metrics (break name, disposition code, status name) */ 29 metricParam?: string; 30 /** Parameters for multi-value metrics like disposition_time (array of status/disposition codes) */ 31 metricParams?: string[]; 32 /** Comparison operator */ 33 operator: AgentAlarmOperator; 34 /** Threshold for time-based metrics (milliseconds) */ 35 thresholdMs?: number; 36 /** Threshold for count-based metrics */ 37 thresholdCount?: number; 38 /** Email addresses to notify when alarm triggers */ 39 recipients: string[]; 40 /** ISO 8601 timestamp */ 41 createdAt: string; 42 /** ISO 8601 timestamp */ 43 updatedAt: string; 44} 45 46/** Input for creating a new alarm rule (ruleId, createdAt, updatedAt generated server-side) */ 47export interface CreateAgentAlarmRuleInput { 48 name: string; 49 enabled: boolean; 50 metric: AgentAlarmMetric; 51 metricParam?: string; 52 metricParams?: string[]; 53 operator: AgentAlarmOperator; 54 thresholdMs?: number; 55 thresholdCount?: number; 56 recipients: string[]; 57} 58 59/** Input for updating an existing alarm rule */ 60export interface UpdateAgentAlarmRuleInput { 61 name?: string; 62 enabled?: boolean; 63 metric?: AgentAlarmMetric; 64 metricParam?: string; 65 metricParams?: string[]; 66 operator?: AgentAlarmOperator; 67 thresholdMs?: number; 68 thresholdCount?: number; 69 recipients?: string[]; 70} 71 72/** Result of evaluating a single alarm rule against agent data */ 73export interface AgentAlarmEvaluationResult { 74 ruleId: string; 75 ruleName: string; 76 triggered: boolean; 77 triggeredAgents: { 78 agentId: number; 79 agentName: string; 80 actualValue: number; 81 threshold: number; 82 unit: string; 83 }[]; 84} 85 86/** A record in the alarm-history DynamoDB table */ 87export interface AlarmHistoryRecord { 88 /** Composite key: {tenantId}#{ruleId} */ 89 pk: string; 90 /** Sort key: {dayKey}#{triggeredAt} */ 91 sk: string; 92 tenantId: string; 93 ruleId: string; 94 /** Alarm category for extensibility (e.g., 'agent', 'billing', 'shopify') */ 95 alarmCategory: string; 96 /** Melbourne date YYYY-MM-DD */ 97 dayKey: string; 98 /** ISO 8601 timestamp */ 99 triggeredAt: string; 100 /** Snapshot of rule name at trigger time */ 101 ruleName: string; 102 /** Metric type */ 103 metric: AgentAlarmMetric; 104 /** Operator used */ 105 operator: AgentAlarmOperator; 106 /** Threshold value (ms or count) */ 107 threshold: number; 108 /** 'ms' or 'count' */ 109 thresholdUnit: string; 110 /** Agents that triggered this alarm */ 111 triggeredAgents: { 112 agentId: number; 113 agentName: string; 114 actualValue: number; 115 }[]; 116 /** Email addresses that were notified */ 117 recipients: string[]; 118 /** Whether the email was successfully sent */ 119 emailSent: boolean; 120} 121 122/** Human-readable labels for alarm metrics */
123export const ALARM_METRIC_LABELS: Record<AgentAlarmMetric, string> = { 124 disposition_time: 'Agent Status Total Time', 125 disposition_count: 'Agent Status Count', 126 disposition_single_time: 'Agent Status Single Time', 127}; 128 129/** Short descriptions for alarm metrics */ 130export const ALARM_METRIC_DESCRIPTIONS: Record<AgentAlarmMetric, string> = { 131 disposition_time: 'Sum of time across selected agent statuses', 132 disposition_count: 'Count of times agent entered a specific status', 133 disposition_single_time: 'Time in a single agent status exceeds threshold', 134}; 135 136/** Whether a metric is time-based (uses thresholdMs) or count-based (uses thresholdCount) */ 137export function isTimeBasedMetric(metric: AgentAlarmMetric): boolean { 138 return metric === 'disposition_time' || metric === 'disposition_single_time'; 139} 140 141/** Whether a metric requires a metricParam (single value) */ 142export function isParameterizedMetric(metric: AgentAlarmMetric): boolean { 143 return metric === 'disposition_count' || metric === 'disposition_single_time'; 144} 145 146/** Whether a metric uses metricParams (multiple values) instead of metricParam */ 147export function isMultiParamMetric(metric: AgentAlarmMetric): boolean { 148 return metric === 'disposition_time'; 149} 150`,be=`/** 151 * Agent Distribution Types 152 * 153 * Types for configuring how leads are distributed to agents. 154 * Stored as \`distribution\` field on TenantConfig. 155 */ 156 157// ============================================================================ 158// Agent Role 159// ============================================================================ 160 161/** Role assigned to an agent â used for filtering in distribution pools */ 162export type AgentRole = 'sales' | 'operations' | 'manager'; 163 164// ============================================================================ 165// Distribution Strategy 166// ============================================================================ 167 168/** How leads are assigned to agents within a pool */ 169export type DistributionStrategy = 'round_robin' | 'manual' | 'caller'; 170 171// ============================================================================ 172// Agent Pool 173// ============================================================================ 174 175/** A named group of agents that receive leads based on matching rules */ 176export interface AgentPool { 177 /** Unique pool identifier (UUID) */ 178 poolId: string; 179 /** Display name (e.g., "Inbound Sales", "Recovery Team") */ 180 name: string; 181 /** Assignment strategy for this pool */ 182 strategy: DistributionStrategy; 183 /** MaxContact IDs of agents in this pool */ 184 agentMaxIds: string[]; 185 /** Event types that route to this pool (e.g., form.submitted, metaform.leads) */ 186 triggerTypes?: string[]; 187 /** LeadSourceChannel values that route to this pool */ 188 channels?: string[]; 189 /** Max concurrent owned leads per agent in this pool (null = use global default) */ 190 maxLeadsPerAgent?: number; 191 /** Pool priority for fallback ordering (lower = higher priority) */ 192 priority?: number; 193 /** Only distribute to agents with one of these roles (empty/undefined = all agents) */ 194 requiredRoles?: AgentRole[]; 195} 196 197// ============================================================================ 198// Distribution Config (stored on TenantConfig) 199// ============================================================================ 200 201/** Tenant-level distribution configuration */ 202export interface DistributionConfig { 203 /** Master switch â when false, no distribution occurs */ 204 enabled: boolean; 205 /** Default strategy for pools that don't override */ 206 defaultStrategy: DistributionStrategy; 207 /** Agent pools with routing rules */ 208 pools: AgentPool[]; 209 /** Global default max owned leads per agent */ 210 maxLeadsPerAgent: number; 211 /** Max pending agent actions per agent (0 = unlimited, default 10) */ 212 maxActionsPerAgent?: number; 213 /** Whether to reassign leads when an agent goes offline (future use) */ 214 reassignOnOffline: boolean; 215 /** Minutes to wait before reassigning offline agent's leads (future use) */ 216 reassignDelayMinutes: number; 217} 218 219// ============================================================================ 220// Round-Robin State (stored on tenants table) 221// ============================================================================ 222 223/** Tracks round-robin rotation index per pool */ 224export interface DistributionPoolState { 225 /** Last assigned index in the available agents array */ 226 lastIndex: number; 227} 228 229/** Distribution state stored on the tenant record */ 230export interface DistributionState { 231 pools: Record<string, DistributionPoolState>; 232} 233`,ve=`/** 234 * Agent readiness â is this agent set up to sell? 235 * 236 * Two kinds of requirement, and the difference decides how each is stored: 237 * 238 * - DERIVED the platform can see the truth for itself (a video with a usable 239 * _mms.mp4 variant, an avatar the card renderer can actually draw, 240 * a recent audio check). Never stored as a human's claim â an 241 * evaluator recomputes it and overwrites. 242 * - ATTESTED someone signs it off (a policy acknowledgement, a video approved 243 * as on-brand). Stored with who, when, which version, and when it 244 * lapses. 245 * 246 * Course completions are a third thing: earned by quiz in @bigm/training, written 247 * here so one board can render them beside everything else. 248 * 249 * â The load-bearing rule of the whole model: a requirement is NOT "the human 250 * uploaded something", it is "a resolvable artefact exists in the shape the 251 * consumer needs". Measured 10 Sep 2026, \`agents.avatarUrl\` was set on 13 of 103 252 * rows and exactly ONE agent had the .png sidecar the MMS renderer needs â so a 253 * presence check would have called 12 agents ready who render as a letter circle. 254 */ 255 256/** Whether a requirement is held once per agent, or once per tenant they dial. */ 257export type AgentRequirementScope = 'agent' | 'agent_tenant'; 258 259export type AgentRequirementKind = 'derived' | 'attested' | 'course'; 260 261/** 262 * \`in_progress\` exists for courses only â started, not yet passed. It is amber 263 * on the board, never green, and never counts as met. 264 */ 265export type AgentRequirementStatus = 266 | 'met' 267 | 'unmet' 268 | 'in_progress' 269 | 'expiring' 270 | 'expired' 271 | 'not_applicable'; 272 273/** 274 * A persona is what someone DOES, not what role string their login carries. 275 * 276 * â \`tenant_operations\` holds BOTH personas (Chris, 10 Sep 2026): "team 277 * operations also have to complete the same compliance as they are sales agents 278 * and team operations personas". Requirements are therefore a UNION, never a 279 * substitution, and an operations account is never a reduced set. The live proof 280 * this matters: the Team Operations account (maxId 78) made 712 MMC calls in 30
281 * days with no video. 282 */ 283export type AgentPersona = 'sales_agent' | 'team_operations'; 284 285/** One requirement, resolved for one agent (and one tenant, when tenant-scoped). */ 286export interface AgentRequirementResult { 287 requirementId: string; 288 scope: AgentRequirementScope; 289 kind: AgentRequirementKind; 290 /** Set for course rows, so the board can group modules by category. */ 291 category?: string; 292 /** 293 * Which tenants caused this row to be assigned. A course is held ONCE per 294 * agent but belongs to specific stores, so a board scoped to HealthCo must not 295 * show a Masseuse-only course as permanent red for people never asked to take 296 * it. Absent means it applies everywhere. 297 */ 298 assignedVia?: string[]; 299 /** Set only when \`scope\` is \`agent_tenant\`. */ 300 tenantName?: string; 301 status: AgentRequirementStatus; 302 /** 303 * Why, in words a human can act on â "master present, no _mms.mp4 variant" 304 * beats a bare \`unmet\` when an agent swears they uploaded it. 305 */ 306 detail?: string; 307 /** What satisfied it: an S3 key, a URL, an attestation id. */ 308 evidence?: string; 309 completedAt?: string; 310 expiresAt?: string; 311 /** 312 * Outbound calls in the relevance window. A gap only costs us where the agent 313 * actually dials, and this is what sorts the team board. 314 */ 315 callsInWindow?: number; 316} 317 318/** Latest per-course result, written by the training engine on a pass. */ 319export interface AgentCourseCompletion { 320 status: Extract<AgentRequirementStatus, 'met' | 'in_progress' | 'expiring' | 'expired'>; 321 courseVersion: number; 322 completedAt?: string; 323 expiresAt?: string; 324 lastScore?: number; 325 lastAttemptAt?: string; 326} 327 328/** 329 * \`agents.readiness\` â the whole nested object on the agent's own row. 330 *
331 * No new table, following \`POST /data/agents/me/audio-check\`, whose own comment 332 * is the doctrine: "NO NEW TABLE, AND NO WRITE TO ANYONE ELSE'S ROW." 333 */ 334export interface AgentReadiness { 335 evaluatedAt: string; 336 personas: AgentPersona[]; 337 results: AgentRequirementResult[]; 338 /** Keyed by courseId. */ 339 training?: Record<string, AgentCourseCompletion>; 340} 341 342/** 343 * Per-agent allocation, on the agent's own row. 344 * 345 * â The DEFAULT is derived, not enrolled: a course belongs to tenants, and 346 * every agent with access to one of those tenants holds it. That is what makes 347 * publishing a course for a second store a no-op operationally â nobody has to 348 * remember the new starter. 349 * 350 * This exists for the exceptions on top of that: one person who needs a module 351 * their store does not carry, or who is genuinely excused from one. An empty or 352 * absent allocation means "the default", which is what almost every row should 353 * say forever. 354 */ 355export interface AgentTrainingAllocation { 356 /** Extra courseIds this agent holds regardless of tenant. */ 357 include?: string[]; 358 /** courseIds this agent does NOT hold, even though their tenant carries them. */ 359 exclude?: string[]; 360 /** Whole categories excused for this agent. Use sparingly. */ 361 excludeCategories?: string[]; 362} 363`,we=`/** 364 * Per-tenant configuration vocabulary for the conversational AI agent (the SMS 365 * appointment maker as it grows into the platform's standard multi-tenant sales 366 * workload: plan \`unified-sales-agent-runtime.md\`, 20 Sep 2026). 367 * 368 * These string-literal unions are the registry keys wired at runtime by 369 * \`@bigm/shared/ai-agent\` â Context Providers (retrieval: what the agent knows) 370 * and Capabilities (native tools: what the agent can do). Defined here in 371 * @bigm/types (not @bigm/shared) so \`TenantConfig.aiAgent\` can be typed without 372 * a typesâshared circular dependency. 373 */ 374 375import type { AiPriceHold, AiBusinessFacts } from './ai-business-rules.js'; 376export type { AiPriceHold }; 377import type { AgentActionType, SalesCycleStage } from './lead.js'; 378 379/** Retrieval sources the agent may consult for a lead (grows over time). 380 * \`catalog\` = the tenant's products with RRP + website price (piece B, 20 Sep 381 * 2026); the caller preloads \`AiContextInput.catalog\` via \`loadCatalogSnapshot\`. 382 * \`knowledge\` = the tenant's product knowledge markdown 383 * (\`tenants/<tenant>/products/shopify-products.md\`, the key site chat read). 384 * \`external\` = an adapter passthrough block (e.g. the Delta X member context the 385 * platform cannot read itself), capped by the runner. */ 386export type AiContextProviderId = 'orders' | 'deliveries' | 'catalog' | 'knowledge' | 'external' | 'thread' | 'activity' | 'browsing'; 387/* \`activity\` (plan WP3) = recent contact with our team: calls (answered or not), agent actions, 388 * notes, form submissions, appointment changes, so the AI never restarts a conversation a person 389 * just had. */ 390/* \`browsing\` (23 Sep 2026, Chris) = what the customer looked at on the STOREFRONT 391 * (\`product.viewed\`, \`collection.viewed\`, \`cart.added\`). \`activity\` is contact with our TEAM and 392 * \`thread\` is what the customer TYPED; neither covered what they clicked, so the AI could not say 393 * "the one you were looking at online" while the agents do it in 7.1% of real conversations. */ 394/* \`thread\` (plan WP2, 21 Sep 2026) = what the customer has told us so far, extracted 395 * deterministically from EVERY text in the conversation window (products named, postcode, 396 * weight, height), so a fact 14 texts back survives the model's history cap. */ 397 398/** Capabilities (native tools + prompt scope) the agent may handle. Anything a 399 * tenant does NOT enable here still routes to a human via escalate. 400 * Plan A §3 is the catalogue; each id = tool schema(s) + deterministic guard + 401 * executor case + review stamp. */ 402export type AiCapabilityId = 403 | 'appointments' // book / reschedule / cancel (the base capability) 404 | 'delivery_answers' // answer "where's my delivery" from delivery.* facts 405 | 'product_service' // ask a purchaser whether the booking is product service/support 406 | 'warranty_answers' // (roadmap) answer warranty-coverage questions 407 | 'complete_sale' // (C2) raise ai_close_ready for a person to raise the draft order 408 | 'dynamic_opener' // missed-call opener copy adapts to the lead's purchase/delivery history 409 | 'followup_sms' // send a follow-up text to the lead's OWN number mid-conversation 410 | 'pricing_answers' // quote RRP / website price from the catalog provider (never below floor) 411 | 'catalog_answers' // answer model / spec / colour questions from the catalog provider 412 | 'send_link' // send one of the tenant's configured links (aiAgent.links) as a short URL 413 | 'send_media' // send an approved sales-set picture or video of a product (MMS / inline) 414 | 'send_info_email' // send one of the tenant's configured info emails (aiAgent.emailTemplates) 415 | 'draft_order' // raise the Shopify draft order for a chosen product and text the card + link 416 | 'delivery_booking'; // book the delivery after a PAID order, under the Book button's own gate 417 418/** Where a conversation is happening. Selects a per-surface skill profile. */ 419export type AiSurface = 'sms' | 'site' | 'app' | 'voice'; 420 421/** One persona per tenant. A profile never changes the name or the disclosure 422 * rule, only what the agent may do and know. */ 423export interface AiPersonaConfig { 424 /** The name the agent texts / speaks as (falls back to \`TenantConfig.aiPersonaName\`, then "Steve"). */ 425 name: string; 426 /** MMC's Steve denies being an AI;
426 Jani discloses. Tenant config, never code. */ 427 disclosesAi: boolean; 428 /** \`@bigm/prompts\` id of the persona identity block (S3-published), when not the inline mirror. */ 429 promptId?: string; 430 version?: string; 431} 432 433/** Per-surface overrides of the base capability / provider lists. */ 434export interface AiSurfaceProfile { 435 capabilities?: AiCapabilityId[]; 436 contextProviders?: AiContextProviderId[]; 437 /** What an ANONYMOUS visitor (no lead) may use on this surface. Default: 438 * conversation control only (no lead-bound tools). */ 439 anonymous?: { capabilities: AiCapabilityId[] }; 440} 441 442/** Core re-run and review policies (plan A §1a steps 5 and 6). All default ON 443 * for the SMS adapter (they are today's behaviour); voice keeps review OFF 444 * until it is proven there. */ 445export interface AiRuntimePolicies { 446 /** Re-run the model once framed in the customer's zone when a free-typed time 447 * arrives with an interstate state reveal. */ 448 reframeOnRevealedZone?: boolean; 449 /** Corrective re-run when the model wrote a done-tense confirmation with no action. */ 450 phantomConfirmationGuard?: boolean; 451 /** Run \`reviewAiThread\` + \`applyReviewOutcome\` after the model turn. */ 452 reviewLayer?: boolean; 453 /** Re-run once when a weekday/date pair in the reply disagrees with the calendar, then 454 * clarify deterministically; refuse a booking whose date contradicts the customer's 455 * stated weekday (default true; live 20 Sep 2026 "Sat, 28 Sept"). */ 456 calendarGuard?: boolean; 457} 458 459/** 460 * A qualifying move: a question the team asks before recommending a product. 461 * The ids are the ones \`mine-sales-pattern.mjs\` measures, so config and evidence 462 * use the same vocabulary. 463 */ 464export type AiQualifyingMove = 465 | 'pain' | 'who_for' | 'height' | 'weight' | 'job_lifestyle' 466 | 'space' | 'who_else_uses' | 'budget' | 'tried_before'; 467 468/** 469 * How THIS tenant sells, measured from its own calls rather than written down. 470 * 471 * â Per tenant, always. MMC and MHC demonstrably sell differently: over 21 days and 472 * 1,022 real conversations, MMC opens on pain (56% of its conversations) and MHC's 473 * top move is the showroom invite (23%) with pain and height at 17%. A shared default 474 * would flatten that and put one brand's habits in the other's mouth. Unset = the 475 * neutral default order in \`@bigm/shared/ai-sale/playbook\`, never another tenant's. 476 * 477 * Produced by \`BigM/aws-scripts-process/seed-offer-config-2026-09/mine-sales-pattern.mjs\` 478 * and reviewed by an operator before it is written; \`evidence\` carries the provenance 479 * so the AI page can show why each move is there. 480 */ 481export interface AiSalesPlaybook { 482 /** Qualifying moves, in the order this tenant's own agents use them. */ 483 qualifyingOrder?: AiQualifyingMove[]; 484 /** 485 * Where the showroom invite sits for this tenant. \`'first'\` means before any 486 * qualifying question, which is MHC's measured behaviour: the showroom invite is its 487 * TOP move at 23% of conversations while pain and height run at 17%. MMC puts it 488 * after pain. Unset = not offered as part of the qualifying run. 489 */ 490 showroomAfter?: AiQualifyingMove | 'first'; 491 /** 492 * Credibility claims, used LATE and never as an opener. In real calls these land 493 * around turn 139 to 181, as the close approaches. Verbatim lines, GSM-7, no dashes. 494 */ 495 credibility?: string[]; 496 /** 497 * Urgency, in this tenant's own reps' words (mined from their outbound texts). Chris, 24 Sep 498 * 2026: "don't say no rush ... make them rush. Say that there's a special on." One line goes 499 * with a price. Verbatim, GSM-7, no dashes, and never a stock count the AI cannot verify. 500 */ 501 urgency?: string[]; 502 /** Where these came from, e.g. "21d, 901 conversations, 23 Sep 2026". */ 503 evidence?: string; 504} 505 506export interface AiServiceConfig { 507 enabled: boolean; 508 /** The first name the service agent signs as. Absent â the tenant's default agent name. */ 509 agentName?: string; 510 /** Context providers rendered into the service prompt. Absent â orders, deliveries, thread, activity. */ 511 contextProviders?: string[]; 512 /** MAX id of the person who gets every service action the AI raise
512s. Absent â unassigned (Service Portal queue). */ 513 managerMaxId?: string; 514 /** Days after delivery during which an order still counts as open for routing (default 30). */ 515 openOrderDays?: number; 516} 517 518export interface AiAgentConfig { 519 /** Which conversation runtime serves this tenant. Absent = \`'v1'\` (the legacy 520 * engine path, byte-identical). \`'v2'\` = \`runConversationTurn\` in 521 * \`@bigm/shared\`. â Set on DEV tenant rows by C1; prod rows carry it only 522 * when Chris sets it (\`sales-ai-workload-master.md\` standing rules). */ 523 runtime?: 'v1' | 'v2'; 524 /** One persona per tenant (name + disclosure rule). */ 525 persona?: AiPersonaConfig; 526 /** Retrieval providers to run each turn, e.g. ['orders','deliveries']. */ 527 contextProviders?: AiContextProviderId[]; 528 /** Capabilities the agent may handle; everything else escalates. */ 529 capabilities?: AiCapabilityId[]; 530 /** Per-surface overrides of the two lists above (plan A §2). */ 531 profiles?: Partial<Record<AiSurface, AiSurfaceProfile>>; 532 /** Core re-run / review policy flags (plan A §1a). */ 533 policies?: AiRuntimePolicies; 534 /** How far back (days) to consider a lead's orders. Unset â no limit. */ 535 orderLookbackDays?: number; 536 /** Approved pricing answers (presence = the agent may answer price questions). 537 * Mined from the tenant's own top sales-call transcripts (2026-08-02: 538 * 2,158 price QâA windows across 1,472 MMC calls) â the agent may state 539 * these VERBATIM-ish but never a final price for a specific product. 540 * Unset â price questions escalate to a human (legacy behaviour). */ 541 pricing?: { 542 /** Non-committal pricing statements the agent may use, dash-free + GSM-7. */ 543 approvedLines: string[]; 544 }; 545 /** How THIS tenant sells, measured from its own calls (see \`AiSalesPlaybook\`). */ 546 salesPlaybook?: AiSalesPlaybook; 547 /** What this tenant calls the people who arrange deliveries, e.g. "our Happiness 548 * Team". Unset = the neutral default in \`@bigm/types/delivery-booking\`, so no 549 * tenant ever inherits another tenant's wording. */ 550 deliveryTeamName?: string; 551 /** The number the agent may give a customer who asks for one to ring 552 * (Chris, 18 Sep 2026: "I can't give out a number by text" lost a York St 553 * visit). Falls back to the showroom phone when unset. */ 554 businessPhone?: string; 555 /** Links the \`send_link\` skill may send, keyed by a short id the tool schema 556 * enumerates (poka-yoke: the model can only name a configured key). */ 557 links?: Record<string, { url: string; label: string }>; 558 /** Info emails the \`send_info_email\` skill may send, keyed by a short id the 559 * tool schema enumerates. \`templateId\` = the S3 email template folder. */ 560 emailTemplates?: Record<string, { templateId: string; label: string; subject?: string }>; 561 /** \`send_media\` options. \`allowVideo\` false â images only. */ 562 media?: { allowVideo?: boolean }; 563 /** Next best action engine (piece D, plan \`next-best-action-engine.md\`, 20 Sep 564 * 2026). â Absent on prod rows: the computer, the takeover sweep and the due 565 * emitter all skip a tenant without \`nba.enabled === true\`. */ 566 nba?: NbaConfig; 567 /** The note the AI leaves on the lead after a conversation ends, in the shape of 568 * the agents' own Zoho notes. Absent â no note is written (every tenant today). */ 569 notes?: { 570 enabled: boolean; 571 /** Mirror the note into Zoho Notes once the write client exists. */ 572 mirrorToZoho?: boolean; 573 }; 574 /** AI sale mode (plan \`sales-ai-mhc-ai-sale-mode.md\`). â Absent or \`off\` â the 575 * ladder is not stamped, no stage is written and the close tools are never 576 * exposed. \`shadow\` computes and logs everything and changes no reply. */ 577 aiSale?: AiSaleConfig; 578 /** Take AI-card and booked leads out of the MAX dialler by moving the Zoho Deal Stage 579 * (\`set_zoho_stage\`). â Absent or \`enabled: false\` â the step records the intent on the 580 * timeline and writes nothing to Zoho. */ 581 dialStop?: { enabled: boolean }; 582 /** AI Service agent for existing customers (plan \`ai-service-workload.md\`, Chris 1 Oct 2026). â Absent or
583 * \`enabled: false\` â the \`run_service_turn\` step replies to nobody. Separate from \`aiSale\`: own prompt, own 584 * tools, never books an appointment, raises service actions for a person instead. */ 585 service?: AiServiceConfig; 586 /** Which model tier serves this tenant's turns. Absent â \`fast\` (Haiku 4.5), the 587 * behaviour every live tenant has today. A tier is only pinned here after the 588 * level-2 eval passed on it (the gate runs every sale case on both tiers). */ 589 modelTier?: 'fast' | 'balanced' | 'deep'; 590 /** Per-stage override of \`modelTier\`: the ladder and booking turns can stay on 591 * \`fast\` while the sale-stage turns run \`balanced\`. */ 592 modelTierByStage?: Partial<Record<'ladder' | 'sale', 'fast' | 'balanced' | 'deep'>>; 593} 594 595// âââ AI sale mode (plan \`sales-ai-mhc-ai-sale-mode.md\`) ââââââââââââââââââââââ 596 597/** 598 * \`off\` â nothing: no ladder stamps, no stage, no close tools (every tenant today). 599 * \`shadow\` â the ladder, the assessment and the stage are computed, stamped and logged, 600 * and the reply the customer gets is UNCHANGED. The 3-day proving run. 601 * \`live\` â the close tools are exposed once the ladder is satisfied, under the caps. 602 */ 603export type AiSaleMode = 'off' | 'shadow' | 'live'; 604 605export type AiSaleGiftKey = 'eye' | 'neck'; 606export interface AiSaleGiftOption { variantId: string; title: string; price: number } 607export interface AiSaleGift { 608 default: AiSaleGiftKey; 609 options: Record<AiSaleGiftKey, AiSaleGiftOption>; 610 /** One approved sentence for the prompt, e.g. "Every new chair comes with a free Eye Massager or THERA+ Neck and Shoulder Massager, valued at $299, posted separately." */ 611 line: string; 612} 613 614export interface AiSaleConfig { 615 mode: AiSaleMode; 616 /** Most leads that may enter \`assessing\` in a rolling week (MHC: 5). */ 617 weeklyCap?: number; 618 /** Share of eligible leads held back as a control, 0..1 (default 0.3). */ 619 holdoutPct?: number; 620 /** The manager who gets \`ai_sale_handoff\` and \`draft_order_review\`. */ 621 managerMaxId?: string; 622 /** Hours since the last answered call before the AI may take a lead (measured, 623 * not guessed: see the plan's A11). Absent â 48. */ 624 recentCallHours?: number; 625 /** 626 * How long a quoted price is held, the business's own rule (Inventory â Business rules, 29 Sep 2026). 627 * Before \`cutoffHour\` (business time zone) the hold is \`beforeCutoff\`, from it \`afterCutoff\`. Absent â 628 * 17, "11:59pm tonight", "5pm tomorrow" (Chris, 24 Sep 2026). Seeded from what staff say in texts and calls. 629 */ 630 priceHold?: AiPriceHold; 631 /** 632 * The most units of one product the AI may sell in a single order. More goes to a person before the 633 * model is called. Absent â 1 (Chris, 28 Sep 2026: "any purchase of more than one goes to a person"). 634 */ 635 maxUnitsByAi?: number; 636 /** 637 * The price range the team gives when a customer asks for the price a SECOND time (Chris, 4 Oct 2026: "Give the $2k 638 * to $20k like response"), e.g. "Our chairs range from about $2,000 up to about $20,000, depending on the chair." 639 * The first ask still gets the staff-style answer (no floor opener). Its figures pass the price guard. Absent â off. 640 */ 641 priceRangeLine?: string; 642 /** 643 * The standard facts the team says on every call (years in business, family owned, Google reviews, 644 * the return guarantee, the current sale), edited on AI â Sales rules (Chris, 29 Sep 2026). Rendered 645 * into the credibility and returns lines; unset = never said. 646 */ 647 facts?: AiBusinessFacts; 648 /** 649 * The deal card at the quote (29 Sep 2026): once the needs analysis has picked a product the order and its MMS card 650 * go out WITH the price. Off â the close only opens through the old ladder (a customer who asks to buy by text). 651 * Per tenant so it can be proven on the dev twins first and switched off without a deploy.
652 */ 653 dealCard?: boolean; 654 /** 655 * The free gift that rides on every AI order card (MMC "Today Show" offer, Chris 2 Oct 2026: proven from 673 656 * calls, 19 texts and 30% of chair orders). The card carries \`options[default]\` as a line at its price discounted 657 * to $0, the customer can swap to the other, and \`line\` is the approved sentence the prompt may say (its figures 658 * pass the price guard). Absent â no gift, no mention. 659 */ 660 gift?: AiSaleGift; 661 /** Who last confirmed these business rules in Inventory. */ 662 reviewedBy?: string; 663 reviewedAt?: string; 664 /** How the seeded values were decided (real texts, calls and orders). */ 665 evidence?: string; 666} 667 668 669// âââ Next best action (piece D) âââââââââââââââââââââââââââââââââââââââââââââââ 670 671/** Channel an NBA may execute on. \`phone\` with owner \`ai\` is off by default 672 * (\`NbaConfig.aiVoice\`); \`messenger\` needs a PSID from an inbound Messenger event. */ 673export type NbaChannel = 'sms' | 'email' | 'phone' | 'messenger' | 'chat'; 674/** Who does the action. An AI-owned action is a \`scheduled\` slot on the lead's 675 * action state, never a badge; an agent-owned one is an ordinary pending action. */ 676export type NbaOwner = 'agent' | 'ai'; 677/** When an AI-owned action runs: as soon as the send window allows, after N 678 * minutes, or at the next window start (the next-morning nudge). */ 679export type NbaSlotPolicy = 'asap' | 'next_morning' | { afterMinutes: number }; 680 681/** Endpoint-first binding (brief: "actions should link to the toolcall API endpoint"). */ 682export interface NbaBinding { 683 /** The toolcall route that executes the entry, e.g. \`/toolcall/conversation-turn\`. */ 684 route: string; 685 /** The runtime skill / tool the route dispatches for conversational entries. */ 686 skill?: AiCapabilityId; 687 tool?: string; 688 /** The workflow row a derived entry is bound to (\`/toolcall/run-workflow\`). */ 689 campaignId?: string; 690 note?: string; 691} 692 693export interface NbaEntryRequirements { 694 capability?: AiCapabilityId; 695 provider?: AiContextProviderId; 696 campaignId?: string; 697 /** \`content\` = a content app (courses / lessons) whose chat thread the tenant's own app owns (Delta X). */ 698 integration?: 'winnings' | 'shopify' | 'reseller' | 'messenger' | 'content'; 699} 700 701/** One entry of a tenant's action catalogue. Typed entries come from the 702 * platform set; derived entries come from the tenant's live workflow rows; 703 * \`NbaConfig.entries\` overrides either by id. */ 704export interface NbaCatalogueEntry { 705 /** \`offer_slots\`, \`nudge_stalled_thread\`, \`send_offer:<campaignId>\`, ⦠*/ 706 id: string; 707 label: string; 708 owner: NbaOwner; 709 /** The operator-lane type an agent-owned entry raises; \`ai_next_action\` for AI-owned. */ 710 actionType: AgentActionType; 711 /** Preference order; the computer picks the first channel contactability leaves open. */ 712 channels: NbaChannel[]; 713 binding: NbaBinding; 714 /** Sales-cycle stages the entry applies to (absent = any stage but \`sold\`). */ 715 stages?: SalesCycleStage[]; 716 /** Added to the prioritisation score (default 20). */ 717 weight?: number; 718 /** Agent-owned: minutes before the AI may take over. */ 719 expiryMinutes?: number; 720 /** Agent-owned: the AI entry that runs at expiry (null = no takeover). */ 721 aiFallbackCatalogueId?: string | null; 722 slotPolicy?: NbaSlotPolicy; 723 /** May be computed for an anonymous visitor (no lead). */ 724 anonymous?: boolean; 725 /** Tenant switch (default true). */ 726 enabled?: boolean; 727 requires?: NbaEntryRequirements; 728 provenance: 'typed' | 'derived'; 729 /** Entries whose rule is built by a later piece are present but dormant. */ 730 builtBy?: 'D' | 'C2'; 731} 732 733/** Expiry, then AI takeover, for an existing human action type. */ 734export interface NbaTakeoverRule { 735 expiryMinutes: number; 736 aiFallbackCatalogueId: string | null; 737} 738 739export interface NbaSendWindow { 740 /** \`HH:MM\` local. */ 741 start: string; 742 end: string; 743 /** Defaults to \`appointmentHours.timezone\`, then Australia/Melbourne. */ 744 timezone?: string; 745} 746 747export interface NbaConfig { 748 enabled: boolean; 749 /** AI-owned actions run only inside this window (default 09:00 to 18:00). */ 750 sendWindow?: NbaSendWindow; 751 /** Slot grid in minutes (default 15). */ 752 slotMinutes?: number; 753 /** Per-tenant daily ceiling on AI-owned executions (default 200). */ 754 maxAiActionsPerDay?: number; 755 /** Overrides and additions by id. */ 756 entries?: Array<Partial<NbaCatalogueEntry> & { id: string }>; 757 /** Which human action types the AI may take over at expiry, and with what. */ 758 takeover?: Partial<Record<AgentActionType, NbaTakeoverRule>>; 759 /** Allow AI-owned \`phone\` actions (voice agent). Default false. */ 760 aiVoice?: boolean; 761 /** \`suggest\` (default): compute, store and show, execute nothing. \`live\` = piece D-exec. */ 762 mode?: 'suggest' | 'live'; 763 /** Best-time slot choice (decision 9). Default on, 4-hour horizon. */ 764 bestTimes?: { enabled?: boolean; horizonHours?: number }; 765 /** Hours after an agent's attempt (or any outbound) before the AI may send a contact entry such as 766 * \`offer_slots\` (default 2). Set from the tenant's measured time to first agent attempt (backtest 767 * 20 Sep: MMC median 3.8 h, p75 16 h) so the AI does not pre-empt the agents' own first call. */ 768 contactStandDownHours?: number; 769 /** The tenant's own app owns the chat thread (Delta X Mini Jani): chat is always reachable, so 770 * chat-only entries need no live site session, and the content entries (recommend the next 771 * program, teaser, premium package, hand injury or refund questions to the coach) switch on. 772 * Delivery is piece D-exec (in-app, push later). Default false. */ 773 appChat?: boolean; 774} 775`,Se=`/** 776 * AI Agent registry for the workflow engine's \`run_ai_workload\` step. 777 * 778 * Each entry binds a stable \`agentId\` (referenced from a \`RunAiWorkloadStep\`) 779 * to a target Lambda function (resolved at runtime from an env var on the 780 * campaign-execution-engine Lambda) plus a default payload shape. 781 * 782 * v1: hardcoded list. When the platform grows tenant-specific agents, swap 783 * this for a \`getAgentsForTenant(tenantId)\` reader against the \`agents\` DDB 784 * table â the call sites in \`tools/run-ai-workload.ts\` and the workflow 785 * builder UI agent picker stay unchanged. 786 */ 787 788export interface AiAgent { 789 /** Stable identifier referenced from \`RunAiWorkloadStep.agentId\`. Treat as a 790 * schema field â never rename, only deprecate + add new. */ 791 agentId: string; 792 /** Display label for the workflow-builder agent picker. */ 793 label: string; 794 /** One-paragraph description for the agent picker preview card. */ 795 description: string; 796 /** Name of the env var on the campaign-execution-engine Lambda that holds 797 * the target Lambda's function name (or ARN). The engine resolves this at 798 * runtime so we don't bake function suffixes into shared types. */ 799 lambdaFunctionEnvVar: string; 800 /** Payload baseline forwarded to the target Lambda. Step-level 801 * \`RunAiWorkloadStep.payload\` is merged on top. \`tenantId\` is supplied by 802 * the engine from the workflow's tenant â do not include it here. */ 803 defaultPayload: Record<string, unknown>; 804 /** Optional. Marks per-event agents that need a \`leadId\` from the 805 * triggering event's context. When the workflow fires from a scheduled 806 * trigger (no leadId), the runAiWorkload tool logs and skips. Default: false. */ 807 requiresLead?: boolean; 808 /** Optional. 'direct' (default) sends the resolved payload as-is; 'sqs' 809 * wraps it as \`{Records:[{body: JSON.stringify(payload)}]}\` so Lambdas 810 * whose handlers parse SQS events (e.g. CallAnalysisFunction) can be 811 * invoked from the workflow engine without changing their handler. */ 812 payloadShape?: 'direct' | 'sqs'; 813} 814 815export const AI_AGENTS: AiAgent[] = [ 816 // ââ Meta Expert (per-tenant) âââââââââââââââââââââââââââââââââââââââââââââââ 817 { 818 agentId: 'meta-expert-review', 819 label: 'Meta Expert (Full Review)', 820 description: 821 'Senior performance marketing strategist. Analyses Meta ad performance over the configured window and emits prioritised STOP/KEEP/GROW recommendations plus a traffic-light health snapshot. Output lands in the CEO dashboard.', 822 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 823 defaultPayload: { 824 analysisType: 'meta-review', 825 trigger: 'scheduled', 826 dateRange: { days: 7 }, 827 }, 828 }, 829 { 830 agentId: 'meta-expert-health', 831 label: 'Meta Expert (Health Check)', 832 description: 833 'Quick traffic-light check on Meta ad performance. Lighter-weight than the full review â use for more frequent runs.', 834 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 835 defaultPayload: { 836 analysisType: 'meta-health', 837 trigger: 'scheduled', 838 dateRange: { days: 7 }, 839 }, 840 }, 841
842 // ââ Meta Expert cross-tenant fan-out (legacy 6am AEST behaviour as a workflow) ââ 843 { 844 agentId: 'meta-expert-fanout', 845 label: 'Meta Expert (All Tenants Fan-out)', 846 description: 847 'Scans every tenant with Meta + Anthropic enabled and async-invokes Meta Expert review for each. Use this when you want one workflow to drive cross-tenant runs; otherwise use the per-tenant Meta Expert agents.', 848 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_SCHEDULER_FUNCTION_NAME', 849 defaultPayload: {}, 850 }, 851 852 // ââ Messaging Expert (SMS/MMS, per-tenant) ââââââââââââââââââââââââââââââââââ 853 { 854 agentId: 'messaging-expert-review', 855 label: 'Messaging Expert (SMS/MMS Review)', 856 description: 857 'Senior messaging strategist. Audits every SMS/MMS template + the workflows using them â content, frequency, click rates, opt-outs, MMS-vs-SMS ROI, eligibility tightness â and emits prioritised STOP/KEEP/GROW recommendations with one-click "Take Action" payloads (rewrite template, toggle eligibility check, pause workflow, etc.).', 858 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 859 defaultPayload: { 860 analysisType: 'messaging-review', 861 trigger: 'scheduled', 862 dateRange: { days: 14 }, 863 }, 864 }, 865 { 866 agentId: 'messaging-expert-health', 867 label: 'Messaging Expert (Health Check)', 868 description: 869 'Quick traffic-light check on SMS/MMS health â frequency, opt-outs, delivery failures, eligibility coverage. Up to 3 most pressing recommendations.', 870 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 871 defaultPayload: { 872 analysisType: 'messaging-health', 873 trigger: 'scheduled', 874 dateRange: { days: 14 }, 875 }, 876 }, 877 878 // ââ Workflows Expert (per-tenant) âââââââââââââââââââââââââââââââââââââââââââ 879 { 880 agentId: 'workflows-expert-review', 881 label: 'Workflows Expert (Full Review)', 882 description: 883 'Senior automation strategist. Audits every workflow on the tenant â coverage, send volume, channel effectiveness, dedupe, approval flow, discount ROI, cart recovery â and emits prioritised STOP/KEEP/GROW recommendations. Output lands in the CEO dashboard.', 884 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 885 defaultPayload: { 886 analysisType: 'workflows-review', 887 trigger: 'scheduled', 888 dateRange: { days: 30 }, 889 }, 890 }, 891 { 892 agentId: 'workflows-expert-health', 893 label: 'Workflows Expert (Health Check)', 894 description: 895 'Quick traffic-light check on workflow health. Lighter than the full review â use for more frequent runs.', 896 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 897 defaultPayload: { 898 analysisType: 'workflows-health', 899 trigger: 'scheduled', 900 dateRange: { days: 30 }, 901 }, 902 }, 903 904 // ââ Tenant-scoped (suitable for scheduled or event triggers) ââââââââââââââ 905 { 906 agentId: 'knowledge-gatherer', 907 label: 'Knowledge Gatherer', 908 description: 909 'Refreshes Shopify products / collections / pages / policies into the knowledge S3 bucket. Powers downstream prompts.', 910 lambdaFunctionEnvVar: 'KNOWLEDGE_GATHERER_FUNCTION_NAME', 911 defaultPayload: {}, 912 }, 913 { 914 agentId: 'template-generator', 915 label: 'Template Generator', 916 description: 917 'Generates SMS / email / site-modal templates for campaigns. Tenant-scoped; typically invoked on-demand from the UI but available for scheduled batch generation.', 918 lambdaFunctionEnvVar: 'TEMPLATE_GENERATOR_FUNCTION_NAME', 919 defaultPayload: { 920 templateType: 'sms', 921 tone: 'professional', 922 length: 'medium', 923 }, 924 }, 925 926 // ââ Per-lead (suitable for event-triggered workflows) âââââââââââââââââââââ 927 { 928 agentId: 'lead-analysis', 929 label: 'Lead Analysis (Moonshot)', 930 description: 931 'Per-lead AI analysis. Reads lead history + events; writes leadData on the Lead row. Pair with lead-event triggers (e.g. customer.created, lead.reopened).', 932 lambdaFunctionEnvVar: 'AI_LEAD_ANALYSIS_FUNCTION_NAME', 933 defaultPayload: {}, 934 requiresLead: true, 935 }, 936 { 937 agentId: 'sales-insights', 938 label: 'Sales Insights (Pre-call brief)', 939 description: 940 'Per-lead pre-call brief: objection handling, product fit, next steps. Pair with call/lead triggers (e.g. inbound.call, agent.review).', 941 lambdaFunctionEnvVar: 'SALES_INSIGHTS_FUNCTION_NAME', 942 defaultPayload: {}, 943 requiresLead: true, 944 }, 945 946 // ââ Conversation runtime, chat / push channels (plan A §7, 20 Sep 2026) ââââ 947 { 948 agentId: 'conversation-agent', 949 label: 'Conversation Agent (chat / app turn)', 950 description: 951 'The standard AI sales workload on a non-SMS channel: runs one conversation turn for the lead through the shared runtime (persona, skills, review layer, provenance). Pair with nba.action_due for a due next-best-action on chat or push; the SMS channel uses the run_conversation_turn engine step instead.', 952 lambdaFunctionEnvVar: 'CONVERSATION_AGENT_FUNCTION_NAME', 953 defaultPayload: { mode: 'turn', surface: 'app' }, 954 requiresLead: true, 955 }, 956 957 // ââ Next best action (piece D, 20 Sep 2026, plan \`next-best-action-engine.md\`) ââ 958 { 959 agentId: 'next-best-action', 960 label: 'Next Best Action (compute for this lead)', 961 description: 962 'Computes the lead\\'s next best action now (catalogue candidates, hard guards, the model\\'s choice and rationale, the best-time slot, best contact times per channel) and records it on the lead and as nba.computed. In suggest mode nothing executes. Pair with any lead-scoped trigger; the 15-minute tick already covers quiet leads.', 963 lambdaFunctionEnvVar: 'NBA_ENGINE_FUNCTION_NAME', 964 defaultPayload: { mode: 'compute' }, 965 requiresLead: true, 966 }, 967 968 // ââ SQS-shaped (CallAnalysis is normally driven by recording-fetch-processor; 969 // available here for manual / workflow-driven re-runs of a specific recording) ââ 970 { 971 agentId: 'call-analysis', 972 label: 'Call Analysis', 973 description: 974 'Post-call summary + coaching + PII extraction from a diarized recording. Typically fired by the recording-fetch SQS pipeline; available here for re-runs (provide bucket + key in step.payload).', 975 lambdaFunctionEnvVar: 'CALL_ANALYSIS_FUNCTION_NAME', 976 defaultPayload: {},
977 payloadShape: 'sqs', 978 }, 979 980 // ââ Site Review + Marketing Package (per-tenant; tied to @bigm/prompts) âââ 981 { 982 agentId: 'site-review', 983 label: 'Site Review', 984 description: 985 'Senior product/web strategist. Finds 3-5 best-in-class competitors in your niche, audits their websites against yours, and emits prioritised, evidence-backed recommendations (hero, IA, conversion paths, sign-up, dashboard, trust, copy, mobile, performance). Powered by site-review/website.system.v1 + Anthropic-hosted web_search.', 986 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 987 defaultPayload: { 988 analysisType: 'site-review-website', 989 trigger: 'manual', 990 focusAreas: ['trust', 'copy', 'onboarding', 'mobile', 'CTAs'], 991 }, 992 }, 993 { 994 agentId: 'marketing-package', 995 label: 'Marketing Package', 996 description: 997 'Senior brand strategist + direct-response copywriter. Turns a Site Review strategy doc + tenant context + brand voice into a launch package: hero variants, taglines, Meta image/video creative, Google search ads, email subject A/B sets, Instagram carousels, and a landing-page section draft.', 998 lambdaFunctionEnvVar: 'EXPERT_ANALYSIS_FUNCTION_NAME', 999 defaultPayload: { 1000 analysisType: 'marketing-package-launch', 1001 trigger: 'manual', 1002 channelsInScope: [ 1003 'site-hero', 1004 'taglines', 1005 'meta-image', 1006 'meta-video', 1007 'google-search', 1008 'email-subject', 1009 'instagram-organic', 1010 'landing-section', 1011 ], 1012 }, 1013 }, 1014]; 1015 1016/** Look up an agent by ID. Returns undefined if not found â callers must handle. */ 1017export function getAiAgent(agentId: string): AiAgent | undefined { 1018 return AI_AGENTS.find((a) => a.agentId === agentId); 1019} 1020`,Ce=`/** 1021 * The business-level rules the sales AI follows, edited on Inventory â Business rules (Chris, 29 Sep 2026: 1022 * "the pricing and other rules around finance and delivery ... done at an inventory level"). 1023 * 1024 * Stored on the tenant row under \`aiAgent.aiSale\` (price hold, units) and \`aiAgent.finance\` (the default 1025 * finance terms a product falls back to). Each product can override finance on its own offer config 1026 * (\`ProductOfferConfig.financePrice\`). Every default here was seeded from real staff texts, calls and 1027 * paid orders and carries its evidence until someone reviews it. 1028 * 1029 * Standalone on purpose: ShopDash carries a copy of this file, and it has no copy of \`ai-agent.ts\`. 1030 */ 1031 1032export interface AiPriceHold { 1033 /** Hour of the day (business time zone) from which the later hold applies. */ 1034 cutoffHour: number; 1035 /** What the AI says a price is held until, before the cutoff ("11:59pm tonight"). */ 1036 beforeCutoff: string; 1037 /** From the cutoff on ("5pm tomorrow"). */ 1038 afterCutoff: string; 1039} 1040 1041/** The finance terms a product without its own falls back to (mirrors \`FinanceTerms\` in @bigm/shared). */ 1042export interface AiFinanceDefaults { 1043 provider: string; 1044 deposit: number; 1045 /** Repayments are fortnightly: 30 months = 65 fortnights. */ 1046 fortnights: number; 1047 establishmentFee: number; 1048 /** How staff say the term: "2.5 years". */ 1049 termLabel: string; 1050 source?: string; 1051} 1052 1053/** 1054 * The standard facts about the business that the sales team says on every call (Chris, 29 Sep 2026: 1055 * "all of the details that are standard like years in business etc to be added into business rules"). 1056 * Seeded from the team's Ice Breaker Script. Every one is a claim about a REAL business, so nothing 1057 * here is ever defaulted: an unset fact is simply never said. 1058 */ 1059export interface AiReturnPolicy { 1060 /** "40 Day Hassle Free Guarantee" â 40. */ 1061 days: number; 1062 /** Restocking fee as a percentage of the price (8.5). */ 1063 restockingFeePct?: number; 1064 /** Items that are never refunded ("White Glove Installation"). */ 1065 nonRefundable?: string[]; 1066 /** Who pays to send it back. */ 1067 returnShippingPaidBy?: 'customer' | 'business'; 1068} 1069 1070export interface AiBusinessFacts { 1071 /** Years trading, as staff say it ("18 years"). */ 1072 yearsInBusiness?: number; 1073 familyOwned?: boolean; 1074 australianOwned?: boolean; 1075 /** "Over 2,000 five star Google reviews": the count staff quote, and the star rating. */ 1076 googleReviews?: { count: number; rating?: number }; 1077 returns?: AiReturnPolicy; 1078 /** The sale the team is running, as they name it ("birthday sale"). */ 1079 currentSaleName?: string; 1080 /** People or teams who use the products, only as the business states them. */ 1081 endorsements?: string[]; 1082 /** Anything else the team always says that has no field of its own. Verbatim, GSM-7, no dashes. */ 1083 other?: string[]; 1084} 1085 1086export interface AiBusinessRules { 1087 priceHold?: AiPriceHold; 1088 /** Most units of one product the AI may sell in one order; more goes to a person. Absent = 1. */ 1089 maxUnitsByAi?: number; 1090 finance?: AiFinanceDefaults; 1091 facts?: AiBusinessFacts; 1092 evidence?: string; 1093 reviewedBy?: string; 1094 reviewedAt?: string; 1095} 1096 1097/** What the AI does when nothing is set: the rules in force before 29 Sep 2026. */ 1098export const DEFAULT_PRICE_HOLD: AiPriceHold = { cutoffHour: 17, beforeCutoff: '11:59pm tonight', afterCutoff: '5pm tomorrow' }; 1099export const DEFAULT_MAX_UNITS_BY_AI = 1; 1100 1101/** 1102 * People who may edit product pricing and the AI's commercial rules on top of the editing roles 1103 * (tenant_admin, tenant_operations, app_admin). Chris, 29 Sep 2026: hand the Inventory config to Steve, 1104 * whose role is tenant_sales. Same pattern as \`AI_AGENT_EXTRA_REVIEWERS\`. Lowercase emails. 1105 */ 1106export const PRICING_EXTRA_EDITORS: readonly string[] = ['[email protected]'] as const; 1107export function canEditPricing(isAdmin: boolean, role: string | null | undefined, email: string | null | undefined): boolean { 1108 if (isAdmin || role === 'tenant_admin' || role === 'app_admin' || role === 'tenant_operations') return true; 1109 return !!email && PRICING_EXTRA_EDITORS.includes(email.trim().toLowerCase()); 1110} 1111 1112const txt = (v: unknown, max: number): string | undefined => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : undefined); 1113const num = (v: unknown): number | undefined => { const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN; return Number.isFinite(n) ? n : undefined; }; 1114 1115/** 1116 * Customer-facing text: GSM-7 only (a dash or a smart quote doubles the SMS cost and the house style
1117 * forbids dashes), trimmed, capped. Empty after cleaning = absent. 1118 */ 1119const sms = (v: unknown, max: number): string | undefined => { 1120 const t = txt(v, max * 2); 1121 if (!t) return undefined; 1122 const clean = t.replace(/[â-ââ]/g, ',').replace(/\\s-\\s/g, ', ').replace(/[ââ]/g, "'").replace(/[ââ]/g, '"') 1123 .replace(/[^\\x20-\\x7E\\n]/g, '').replace(/\\s+,/g, ',').replace(/,\\s*,/g, ',').replace(/\\s+/g, ' ').trim().slice(0, max); 1124 return clean || undefined; 1125}; 1126const smsList = (v: unknown, max: number, cap: number): string[] | undefined => { 1127 if (!Array.isArray(v)) return undefined; 1128 const l = v.map((x) => sms(x, max)).filter((x): x is string => !!x).slice(0, cap); 1129 return l.length ? l : undefined; 1130}; 1131 1132/** Clean the business facts; a fact that is not clearly valid is dropped, never guessed. */ 1133export function parseAiBusinessFacts(raw: unknown): AiBusinessFacts { 1134 const o = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>; 1135 const out: AiBusinessFacts = {}; 1136 const y = num(o.yearsInBusiness); if (y !== undefined && y >= 1 && y <= 200) out.yearsInBusiness = Math.round(y); 1137 if (typeof o.familyOwned === 'boolean') out.familyOwned = o.familyOwned; 1138 if (typeof o.australianOwned === 'boolean') out.australianOwned = o.australianOwned; 1139 const g = o.googleReviews as Record<string, unknown> | undefined; 1140 if (g && typeof g === 'object') { 1141 const c = num(g.count); const r = num(g.rating); 1142 if (c !== undefined && c >= 1 && c <= 1_000_000) out.googleReviews = { count: Math.round(c), ...(r !== undefined && r >= 1 && r <= 5 ? { rating: Math.round(r * 10) / 10 } : {}) }; 1143 } 1144 const rp = o.returns as Record<string, unknown> | undefined; 1145 if (rp && typeof rp === 'object') { 1146 const d = num(rp.days); const fee = num(rp.restockingFeePct); 1147 if (d !== undefined && d >= 1 && d <= 365) { 1148 out.returns = { 1149 days: Math.round(d), 1150 ...(fee !== undefined && fee >= 0 && fee <= 100 ? { restockingFeePct: Math.round(fee * 10) / 10 } : {}), 1151 ...(smsList(rp.nonRefundable, 60, 6) ? { nonRefundable: smsList(rp.nonRefundable, 60, 6) } : {}), 1152 ...(rp.returnShippingPaidBy === 'customer' || rp.returnShippingPaidBy === 'business' ? { returnShippingPaidBy: rp.returnShippingPaidBy } : {}), 1153 }; 1154 } 1155 } 1156 const sale = sms(o.currentSaleName, 40); if (sale) out.currentSaleName = sale; 1157 const en = smsList(o.endorsements, 80, 6); if (en) out.endorsements = en; 1158 const other = smsList(o.other, 200, 6); if (other) out.other = other; 1159 return out; 1160} 1161 1162/** Clean an untrusted business-rules object; drops anything that would put a wrong number in front of a customer. */ 1163export function parseAiBusinessRules(raw: unknown): AiBusinessRules { 1164 const o = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>; 1165 const out: AiBusinessRules = {}; 1166 const ph = o.priceHold as Record<string, unknown> | undefined; 1167 if (ph && typeof ph === 'object') { 1168 const h = num(ph.cutoffHour); const b = txt(ph.beforeCutoff, 60); const a = txt(ph.afterCutoff, 60); 1169 if (h !== undefined && h >= 0 && h <= 23 && b && a) out.priceHold = { cutoffHour: Math.round(h), beforeCutoff: b, afterCutoff: a }; 1170 } 1171 const mu = num(o.maxUnitsByAi); if (mu !== undefined && mu >= 1 && mu <= 20) out.maxUnitsByAi = Math.round(mu); 1172 const f = o.finance as Record<string, unknown> | undefined; 1173 if (f && typeof f === 'object') { 1174 const provider = txt(f.provider, 40); const deposit = num(f.deposit); const fortnights = num(f.fortnights); const fee = num(f.establishmentFee); const termLabel = txt(f.termLabel, 30); 1175 if (provider && deposit !== undefined && deposit >= 0 && fortnights !== undefined && fortnights > 0 && fortnights <= 260 && fee !== undefined && fee >= 0 && termLabel) { 1176 out.finance = { provider, deposit, fortnights: Math.round(fortnights), establishmentFee: fee, termLabel, ...(txt(f.source, 300) ? { source: txt(f.source, 300) } : {}) }; 1177 } 1178 } 1179 const facts = parseAiBusinessFacts(o.facts); 1180 if (Object.keys(facts).length) out.facts = facts; 1181 const ev = txt(o.evidence, 2000); if (ev) out.evidence = ev; 1182 const rb = txt(o.reviewedBy, 120); if (rb) out.reviewedBy = rb; 1183 const ra = txt(o.reviewedAt, 40); if (ra) out.reviewedAt = ra; 1184 return out; 1185} 1186`,Ae=`/** 1187 * AI turn feedback â an expert's verdict on ONE SMS the appointment AI wrote. 1188 * 1189 * Captured on the ShopDash /ai-agent page when a tenant admin expands a lead's 1190 * thread and grades a bubble. Offered only where the outbound_sms row carries 1191 * \`eventData.generation.kind === 'model'\` (see \`SmsGeneration\`): template and 1192 * code-built bodies are not the model's work and are refused server-side. 1193 * 1194 * This is the label source for the appointment prompt's improvement loop 1195 * (golden cases â offline eval â next prompt version), never a live input to 1196 * the prompt. Same vocabulary as \`PiiFeedbackRow\` so the two loops read alike. 1197 * 1198 * Table \`ai-turn-feedback\`: 1199 * - PK eventId (the outbound \`twilio_outbound_<sid>\`), SK reviewerSub â one row 1200 * per turn per expert, so two experts can disagree and we can see it. 1201 * - GSI feedbackByPromptVersion: promptIdVersion, createdAt (eval rollup) 1202 * - GSI feedbackByTenantDay: tenantName, createdAt (page stats) 1203 * - GSI feedbackByLead: leadId, createdAt (thread view
1203) 1204 */ 1205 1206export type AiTurnFeedbackVerdict = 'good' | 'bad' | 'unsure'; 1207 1208/** 1209 * Closed reason set for a \`bad\` verdict, written from the failures already 1210 * seen live (review layer, 18 Sep 2026). These are the isolated dimensions the 1211 * rollup counts; the free-text note carries the why. 1212 */ 1213export type AiTurnFeedbackReason = 1214 | 'wrong_facts' // price / time / product / policy stated wrongly 1215 | 'missed_the_question' // did not answer what the customer asked 1216 | 'should_have_booked' // customer agreed a time and nothing was booked 1217 | 'should_have_handed_off' // asked for a person / promised a follow-up nobody owns 1218 | 'wrong_action' // booked, cancelled or opted out when it should not have 1219 | 'tone_or_length' // pushy, robotic, too long, wrong register 1220 | 'gave_or_withheld_wrongly' // e.g. refused the business phone number, or over-shared 1221 // Blind-spot reasons (plan WP7, 21 Sep 2026): the AI was never SHOWN the thing. Each maps to a 1222 // context provider, so the grade says which one is missing or off for the tenant. 1223 | 'didnt_know_call' // a call the team had with the customer (activity provider) 1224 | 'didnt_know_agent_activity' // a person was working the lead / a note / an action (activity) 1225 | 'didnt_know_order' // what the customer bought or their delivery (orders / deliveries) 1226 | 'didnt_know_product_fact' // a limit, warranty, model age, spec (catalog sales facts) 1227 | 'didnt_know_showroom_stock' // which models a showroom carries (reseller stock) 1228 | 'didnt_know_earlier_message'// something the customer said earlier in this thread (thread) 1229 | 'didnt_know_showroom' // booked or offered a phone call in a showroom conversation (reseller mode, live 20 Sep 2026) 1230 | 'other' // note required 1231 // âââ Next best action decisions (piece D, /ai-agent â AI Lead Manager) âââ 1232 | 'should_have_left_alone' // the AI planned an action; the right call was nothing (cancels a scheduled suggestion) 1233 | 'should_have_acted' // the AI left the lead alone; a person can see an obvious next step 1234 | 'wrong_action' // reused: the wrong entry was chosen among the candidates 1235 | 'wrong_time' // right action, wrong slot (too soon after an agent, wrong hour) 1236 | 'wrong_channel' // right action, wrong channel (texted when they only answer email) 1237 | 'agents_call' // this lead belongs to the agent's own follow-up; the AI should stand down 1238 | 'weak_rationale' // the stated reason does not match the history 1239 // âââ AI Service agent turns (existing customers; /ai-agent â AI Service, 1 Oct 2026) âââ 1240 | 'wrong_delivery_info' // stated a delivery date / carrier / status the order does not carry 1241 | 'should_have_raised_action' // the customer needed something done and no service action was raised 1242 | 'promised_a_time' // committed to a callback / delivery / fix time nobody owns 1243 | 'tried_to_sell'; // pitched a product or upgrade in a service conversation 1244 1245export const AI_TURN_FEEDBACK_REASONS: readonly AiTurnFeedbackReason[] = [ 1246 'wrong_facts', 1247 'missed_the_question', 1248 'should_have_booked', 1249 'should_have_handed_off', 1250 'wrong_action', 1251 'tone_or_length', 1252 'gave_or_withheld_wrongly', 1253 'didnt_know_call', 1254 'didnt_know_agent_activity', 1255 'didnt_know_order', 1256 'didnt_know_product_fact', 1257 'didnt_know_showroom_stock', 1258 'didnt_know_earlier_message', 1259 'didnt_know_showroom', 1260 'other', 1261] as const; 1262 1263/** The "the AI did not know about â¦" group, rendered as its own row of chips. */ 1264export const AI_BLIND_SPOT_REASONS: readonly AiTurnFeedbackReason[] = [ 1265 'didnt_know_call', 'didnt_know_agent_activity', 'didnt_know_order', 'didnt_know_product_fact', 'didnt_know_showroom_stock', 'didnt_know_earlier_message', 'didnt_know_showroom', 1266] as const; 1267/** Which context provider each blind-spot reason points at (for the analysis block). */ 1268export const AI_BLIND_SPOT_PROVIDER: Readonly<Partial<Record<AiTurnFeedbackReason, string>>> = { 1269 didnt_know_call: 'activity', didnt_know_agent_activity: 'activity', didnt_know_order: 'orders', didnt_know_product_fact: 'catalog', didnt_know_showroom_stock: 'showrooms', didnt_know_earlier_message: 'thread', didnt_know_showroom: 'showrooms', 1270}; 1271 1272/** 1273 * Closed reason set for a \`bad\` verdict on a NEXT BEST ACTION decision (piece D). Graded on 1274 * the AI Lead Manager tab; \`should_have_left_alone\` also cancels a still-scheduled suggestion. 1275 */ 1276export const NBA_FEEDBACK_REASONS: readonly AiTurnFeedbackReason[] = [ 1277 'should_have_left_alone', 1278 'should_have_acted', 1279 'wrong_action', 1280 'wrong_time', 1281 'wrong_channel', 1282 'agents_call', 1283 'weak_rationale', 1284 'other', 1285] as const; 1286 1287/** 1288 * Extra chips offered on an AI SERVICE turn (\`generation.source === 'service_turn'\`), on top of 1289 * the standard turn reasons. Service staff grade these on the /ai-agent â AI Service tab. 1290 */ 1291export const SERVICE_FEEDBACK_REASONS: readonly AiTurnFeedbackReason[] = [ 1292 'wrong_delivery_info', 1293 'should_have_raised_action', 1294 'promised_a_time', 1295 'tried_to_sell', 1296] as const; 1297 1298/** Every reason id any list accepts (the server-side validator's set). */ 1299export const ALL_AI_FEEDBACK_REASONS: readonly AiTurnFeedbackReason[] = [ 1300 ...AI_TURN_FEEDBACK_REASONS, 1301 ...NBA_FEEDBACK_REASONS.filter((r) => !(AI_TURN_FEEDBACK_REASONS as readonly string[]).includes(r)), 1302 ...SERVICE_FEEDBACK_REASONS, 1303] as const; 1304 1305/** 1306 * Who sees the /ai-agent page and grades the AI (Chris, 21 Sep 2026): tenant 1307 * admins and app admins by role, plus these named operators as an extra â the 1308 * first is Steve at Masseuse Massage, who looks after the resellers and needs 1309 * the reseller-booking conversations without a tenant-admin login. ShopDash's 1310 * nav / router and the dataApi both read this one list, so a name added here 1311 * opens the page and its data together. Lowercase emails. 1312 */ 1313export const AI_AGENT_EXTRA_REVIEWERS: readonly string[] = ['[email protected]'] as const; 1314export function canViewAiAgentPage(isAdmin: boolean, role: string | null | undefined, email: string | null | undefined): boolean { 1315 if (isAdmin || role === 'app_admin' || role === 'tenant_admin') return true; 1316 return !!email && AI_AGENT_EXTRA_REVIEWERS.includes(email.trim().toLowerCase()); 1317} 1318 1319/** Human words for the reason chips (ShopDash renders these, never the ids). */ 1320export const AI_TURN_FEEDBACK_REASON_LABELS: Readonly<Record<AiTurnFeedbackReason, string>> = { 1321 wrong_facts: 'Wrong facts', 1322 missed_the_question: 'Missed the question', 1323 should_have_booked: 'Should have booked', 1324 should_have_handed_off: 'Should have handed off', 1325 wrong_action: 'Wrong action', 1326 tone_or_length: 'Tone or length', 1327 gave_or_withheld_wrongly: 'Gave or withheld wrongly', 1328 didnt_know_call: 'Did not know about a call', 1329 didnt_know_agent_activity: 'Did not know a person was on it', 1330 didnt_know_order: 'Did not know their order or delivery', 1331 didnt_know_product_fact: 'Did not know a product fact', 1332 didnt_know_showroom_stock: 'Did not know what the showroom stocks',
1333 didnt_know_earlier_message: 'Did not know what they said earlier', 1334 didnt_know_showroom: 'Booked a phone call in a showroom conversation', 1335 other: 'Other', 1336 should_have_left_alone: 'Should have left alone', 1337 should_have_acted: 'Should have acted', 1338 wrong_time: 'Wrong time', 1339 wrong_channel: 'Wrong channel', 1340 agents_call: "Agent's call, not the AI's", 1341 weak_rationale: 'Weak rationale', 1342 wrong_delivery_info: 'Wrong delivery info', 1343 should_have_raised_action: 'Should have raised an action', 1344 promised_a_time: 'Promised a time', 1345 tried_to_sell: 'Tried to sell', 1346}; 1347 1348/** What was graded: an outbound turn the model wrote, or a next-best-action decision. */ 1349export type AiFeedbackKind = 'turn' | 'nba'; 1350 1351/** 1352 * Max length for the free-text note and the "what it should have said" rewrite. 1353 * 21 Sep 2026: raised from 1000 / 320 â the rewrite cap was two SMS segments, and 1354 * reviewers were cut off mid-sentence in both boxes. The GSM-7 segment counter 1355 * still tells the reviewer what a rewrite would cost as a text. 1356 */ 1357export const AI_TURN_FEEDBACK_NOTE_MAX = 4000; 1358export const AI_TURN_FEEDBACK_REWRITE_MAX = 4000; 1359 1360/** One row in the \`ai-turn-feedback\` table. */ 1361export interface AiTurnFeedbackRow { 1362 /** PK â the outbound_sms events-customer id (\`twilio_outbound_<sid>\`), or the 1363 * \`nba.computed\` row id for a next-best-action decision. */ 1364 eventId: string; 1365 /** SK â Cognito sub of the reviewer (server-derived, never from the body). */ 1366 reviewerSub: string; 1367 reviewerEmail?: string; 1368 /** \`turn\` (default, absent on rows before 21 Sep 2026) or \`nba\` (piece D decision). */ 1369 kind?: AiFeedbackKind; 1370 /** nba rows: the decision graded (\`ai\` / \`none\` / \`keep_pending\` / \`would_take_over\`) and the entry. */ 1371 nbaDecision?: string; 1372 nbaCatalogueId?: string; 1373 /** nba rows: true when the verdict cancelled a still-scheduled suggestion (\`should_have_left_alone\`). */ 1374 nbaCancelled?: boolean; 1375 1376 tenantName: string; 1377 leadId: string; 1378 /** The inbound_sms this turn answered, from \`eventData.generation.inboundEventId\`. */ 1379 inboundEventId?: string; 1380 1381 /** Pinned from the event's \`generation\` block at review time. */ 1382 promptId: string; 1383 promptVersion: string; 1384 /** \`\${promptId}#\${promptVersion}\` â GSI key for the eval rollup. */ 1385 promptIdVersion: string; 1386 model?: string; 1387 source: string; 1388 action?: string; 1389 1390 verdict: AiTurnFeedbackVerdict; 1391 /** Non-empty when \`verdict === 'bad'\` unless a note of >= 10 chars was given. */ 1392 reasons: AiTurnFeedbackReason[]; 1393 /** Free text on any verdict â the part a prompt author acts on. */ 1394 note?: string; 1395 /** "What it should have said" â the expected output for a golden case. */ 1396 rewrite?: string; 1397 1398 /** Snapshot of the graded turn and the inbound that triggered it (<= 320 chars each). */ 1399 aiText: string; 1400 customerText?: string; 1401 /** Ids of the surrounding SMS turns (up to 8 either side) â text is fetched at eval-build time, not stored. */ 1402 threadEventIds: string[]; 1403 1404 /** The /ai-agent bucket the conversation sat in when graded (booked_phone | ... | no_reply). */ 1405 outcomeBucket?: string; 1406 /** What the deterministic review layer said about this thread, for the expert-vs-review agreement metric. */ 1407 reviewFlags?: string[]; 1408 /** True when the turn came up through "Review 5 at random" rather than being self-selected. */ 1409 sampled: boolean; 1410 1411 createdAt: string; 1412 updatedAt: string; 1413} 1414 1415/** Body ShopDash POSTs to \`/data/ai-feedback/turn\`. Everything else is server-filled. */ 1416export interface CreateAiTurnFeedbackInput { 1417 eventId: string; 1418 leadId: string; 1419 tenantName: string; 1420 verdict: AiTurnFeedbackVerdict; 1421 reasons?: AiTurnFeedbackReason[]; 1422 note?: string; 1423 rewrite?: string; 1424 outcomeBucket?: string; 1425 reviewFlags?: string[]; 1426 sampled?: boolean; 1427 /** \`nba\` when grading a next-best-action decision (the eventId is an \`nba.computed\` row). */ 1428 kind?: AiFeedbackKind; 1429} 1430 1431/** Output of \`summarizeAiTurnFeedback\` (@bigm/shared) â everything here is computed, nothing inferred. */ 1432export interface AiTurnFeedbackSummary { 1433 graded: number; 1434 good: number; 1435 bad: number; 1436 unsure: number; 1437 /** Distinct outbound turns graded (rows may exceed this when two experts grade one turn). */ 1438 turns: number; 1439 byReason: Partial<Record<AiTurnFeedbackReason, number>>; 1440 byPromptVersion: Record<string, { graded: number; good: number; bad: number; unsure: number }>
1440; 1441 /** Per prompt version, how often each reason was given (the "what changed between versions" table 1442 * on /ai-agent; plan sales-ai-v9-â¦, WP6). Keyed like \`byPromptVersion\`. */ 1443 reasonsByPromptVersion: Record<string, Partial<Record<AiTurnFeedbackReason, number>>>; 1444 byReviewer: Record<string, { graded: number; good: number; bad: number; unsure: number }>; 1445 /** Rows that came from the random draw vs self-selected. */ 1446 sampled: number; 1447 selfSelected: number; 1448 /** Turns graded by >= 2 reviewers, and how many of those disagree on good/bad. */ 1449 multiReviewed: number; 1450 disagreements: number; 1451 /** 1452 * Expert-vs-review-layer agreement: over verdicts on threads the review 1453 * layer flagged as an AI failure (promise / asked for a person / agreed time 1454 * not booked / stalled / raise), the share graded bad or unsure. A tenant 1455 * admin clicking ð down the page shows up here. Unflagged threads are not 1456 * scored â a bad there is signal the rules cannot see, not disagreement. 1457 */ 1458 reviewFlagRows: number; 1459 reviewFlagAgreement: number | null; 1460 /** Most recent notes, newest first, verbatim (capped by the caller). */ 1461 recentNotes: Array<{ eventId: string; verdict: AiTurnFeedbackVerdict; note: string; createdAt: string; reviewerEmail?: string }>; 1462} 1463`,ke=`/** 1464 * AI Workload Run â typed wrapper around rows stored in \`expert-analysis-results\`. 1465 * 1466 * The DDB table already discriminates rows by \`analysisType\` (which composes the 1467 * sort key as \`\${analysisType}#\${analysisDate}#\${analysisId}\`). This module adds 1468 * a typed discriminator + payload shapes so the dataApi handler and the UI can 1469 * speak the same language without each side reinventing the rec/output schema. 1470 * 1471 * Adding a new kind = (1) add to \`AiWorkloadKind\` here; (2) add the matching 1472 * input + output interfaces below; (3) extend the \`AiWorkloadRun\` discriminated 1473 * union; (4) add it to \`AGENT_TO_WORKLOAD_KIND\` in your Lambda handler. Existing 1474 * readers get type-safe narrowing for free. 1475 */ 1476 1477// ---------------------------------------------------------------------------- 1478// Discriminator 1479// ---------------------------------------------------------------------------- 1480 1481/** 1482 * Stable identifier for the kind of AI workload that produced a run row. Maps 1483 * 1:1 to the row's \`analysisType\` field. Treat as a schema field â never rename; 1484 * deprecate + add new. 1485 */ 1486export type AiWorkloadKind = 1487 | 'site-review-website' 1488 | 'marketing-package-launch' 1489 // Existing expert-analysis kinds (so AiWorkloadRun is the umbrella for 1490 // everything in expert-analysis-results) 1491 | 'meta-review' 1492 | 'meta-health' 1493 | 'messaging-review' 1494 | 'messaging-health' 1495 | 'workflows-review' 1496 | 'workflows-health'; 1497 1498// ---------------------------------------------------------------------------- 1499// Inputs (per kind) â what the user submitted to kick off the run 1500// ---------------------------------------------------------------------------- 1501 1502export interface SiteReviewInput { 1503 brandName: string; 1504 baseUrl: string; 1505 niche: string; 1506 targetAudience: string; 1507 valueProp?: string; 1508 knownCompetitors?: string[]; 1509 focusAreas?: string[]; 1510 /** Optional pre-fetched homepage snapshot. If absent the runner pre-fetches. */ 1511 currentSiteSnapshot?: string; 1512} 1513 1514export interface MarketingPackageInput { 1515 brandName: string; 1516 baseUrl: string; 1517 niche: string; 1518 targetAudience: string; 1519 valueProp?: string; 1520 /** Recommended: the strategy doc Markdown produced by a prior site-review run. */ 1521 siteReviewRecommendations?: string; 1522 /** runId of the site-review run the user picked from the dropdown. */ 1523 siteReviewSourceRunId?: string; 1524 channelsInScope?: string[]; 1525} 1526 1527// ---------------------------------------------------------------------------- 1528// Outputs (per kind) â what the run produced 1529// ---------------------------------------------------------------------------- 1530 1531/** 1532 * Site-review structured recommendation. Fields mirror the \`record_site_recommendation\` 1533 * tool schema in \`site-review/website.system.v1.md\`. Lower priority numbers = higher urgency. 1534 */ 1535export interface SiteReviewRecommendation { 1536 area: 1537 | 'hero' 1538 | 'nav' 1539 | 'IA' 1540 | 'sign-up' 1541 | 'onboarding' 1542 | 'dashboard' 1543 | 'content' 1544 | 'trust' 1545 | 'mobile' 1546 | 'speed' 1547 | 'retention' 1548 | 'other'; 1549 what: string; 1550 why: string; 1551 evidence: string; 1552 competitorSource?: string; 1553 effort: 'low' | 'medium' | 'high'; 1554 impact: 'low' | 'medium' | 'high'; 1555 priority: number; 1556 /** Set by the user in the UI. Doesn't affect re-runs. */ 1557 userStatus?: 'pending' | 'accepted' | 'rejected' | 'done'; 1558} 1559 1560export interface SiteReviewOutput { 1561 /** Full strategy doc (exec summary + competitor matrix + recs + already-doing-well). */
1562 reportMarkdown: string; 1563 recommendations: SiteReviewRecommendation[]; 1564 /** Provenance â competitors visited and pages audited. */ 1565 fetchedUrls: { url: string; length: number }[]; 1566 knowledgeSources: { path: string; name: string }[]; 1567} 1568 1569export interface MarketingPackageOutput { 1570 /** Full launch package (positioning + hero + taglines + ads + email + IG + landing + notes). */ 1571 packageMarkdown: string; 1572 knowledgeSources: { path: string; name: string }[]; 1573 /** Set by the UI when the user pins a section, copies an asset, etc. â not load-bearing. */ 1574 userMarks?: Record<string, string | number | boolean>; 1575} 1576 1577// ---------------------------------------------------------------------------- 1578// Common shape (one row per run in \`expert-analysis-results\`) 1579// ---------------------------------------------------------------------------- 1580 1581/** Lifecycle status of a single run. Mirrors \`expert-analysis-results.status\`. */ 1582export type AiWorkloadRunStatus = 'running' | 'completed' | 'failed'; 1583 1584export interface AiWorkloadRunCommon { 1585 /** Hash key on \`expert-analysis-results\`. */ 1586 tenantId: string; 1587 /** Sort-key composition: \`\${analysisType}#\${analysisDate}#\${analysisId}\`. */ 1588 analysisKey: string; 1589 /** Stable per-run identifier; same value as the suffix of \`analysisKey\`. */ 1590 analysisId: string; 1591 /** ISO date the run was kicked off (YYYY-MM-DD). */ 1592 analysisDate: string; 1593 /** Same value as \`kind\` â kept under both names for back-compat with expert-analysis-results. */ 1594 analysisType: AiWorkloadKind; 1595 /** Trigger source: 'manual' from the UI, 'scheduled' from a workflow, etc. */ 1596 trigger: 'manual' | 'scheduled' | 'event'; 1597 /** Cognito sub of the user who kicked it off (UI-triggered runs only). */ 1598 triggeredBy?: string; 1599 status: AiWorkloadRunStatus; 1600 /** ISO timestamp run started. */ 1601 createdAt: string; 1602 /** ISO timestamp run completed (set on terminal status). */ 1603 completedAt?: string; 1604 /** Free-form error message when status === 'failed'. */ 1605 errorMessage?: string; 1606 /** Anthropic model that ran. */ 1607 model?: string; 1608 /** Wall-clock ms of the agent run. */ 1609 durationMs?: number; 1610 /** Anthropic cost estimate in cents. */ 1611 costCents?: number; 1612 /** Token usage so the UI can show "this run cost N tokens". */ 1613 tokensIn?: number; 1614 tokensOut?: number; 1615} 1616 1617// ---------------------------------------------------------------------------- 1618// Discriminated union â the typed view the dataApi + UI both use 1619// ---------------------------------------------------------------------------- 1620 1621export interface SiteReviewRun extends AiWorkloadRunCommon { 1622 kind: 'site-review-website'; 1623 analysisType: 'site-review-website'; 1624 input: SiteReviewInput; 1625 output?: SiteReviewOutput; 1626} 1627 1628export interface MarketingPackageRun extends AiWorkloadRunCommon { 1629 kind: 'marketing-package-launch'; 1630 analysisType: 'marketing-package-launch'; 1631 input: MarketingPackageInput; 1632 output?: MarketingPackageOutput; 1633} 1634 1635/** Forward-compat â existing expert-analysis kinds. Output shape varies; treat as opaque 1636 * from this domain's perspective (the legacy expert-analysis UI handles them directly). */ 1637export interface LegacyExpertAnalysisRun extends AiWorkloadRunCommon { 1638 kind: 1639 | 'meta-review' 1640 | 'meta-health' 1641 | 'messaging-review' 1642 | 'messaging-health' 1643 | 'workflows-review' 1644 | 'workflows-health'; 1645 analysisType: 1646 | 'meta-review' 1647 | 'meta-health' 1648 | 'messaging-review' 1649 | 'messaging-health' 1650 | 'workflows-review' 1651 | 'workflows-health'; 1652 /** Free-form for the legacy UI; do not introspect from new code. */ 1653 input?: Record<string, unknown>; 1654 output?: Record<string, unknown>; 1655} 1656 1657export type AiWorkloadRun = SiteReviewRun | MarketingPackageRun | LegacyExpertAnalysisRun; 1658 1659// ---------------------------------------------------------------------------- 1660// Helpers 1661// ---------------------------------------------------------------------------- 1662 1663/** Compose the DDB sort key for a run. */ 1664export function buildAnalysisKey( 1665 kind: AiWorkloadKind, 1666 analysisDate: string, 1667 analysisId: string 1668): string { 1669 return \`\${kind}#\${analysisDate}#\${analysisId}\`; 1670} 1671 1672/** Type guard â narrows \`AiWorkloadRun\` to a SiteReviewRun. */ 1673export function isSiteReviewRun(run: AiWorkloadRun): run is SiteReviewRun { 1674 return run.kind === 'site-review-website'; 1675} 1676 1677/** Type guard â narrows \`AiWorkloadRun\` to a MarketingPackageRun. */ 1678export function isMarketingPackageRun(run: AiWorkloadRun): run is MarketingPackageRun { 1679 return run.kind === 'marketing-package-launch'; 1680} 1681
1682/** Map AiAgent.agentId â AiWorkloadKind (mirrors the registry's analysisType payloads). */ 1683export const AGENT_ID_TO_WORKLOAD_KIND: Record<string, AiWorkloadKind> = { 1684 'site-review': 'site-review-website', 1685 'marketing-package': 'marketing-package-launch', 1686 'meta-expert-review': 'meta-review', 1687 'meta-expert-health': 'meta-health', 1688 'messaging-expert-review': 'messaging-review', 1689 'messaging-expert-health': 'messaging-health', 1690 'workflows-expert-review': 'workflows-review', 1691 'workflows-expert-health': 'workflows-health', 1692}; 1693`,Te=`export type AlarmTopicId = 'refunds' | 'missed_calls' | 'agam' | 'cancellations' | 'agent_alarms' 1694 1695export type AlarmSeverity = 1 | 2 | 3 1696 1697export type ThresholdOperator = '>' | '>=' | '<' | '<=' 1698 1699export interface AlarmTopicConfig { 1700 topicId: AlarmTopicId 1701 enabled: boolean 1702 severity: AlarmSeverity 1703 thresholdValue: number 1704 thresholdOperator: ThresholdOperator 1705 sevenDayThresholdValue?: number 1706 sevenDayThresholdOperator?: ThresholdOperator 1707 thirtyDayThresholdValue?: number 1708 thirtyDayThresholdOperator?: ThresholdOperator 1709 emailNotifications: boolean 1710 recipients: string[] 1711} 1712 1713/** Default configs for all topics (used when tenant has no saved config) */ 1714export const DEFAULT_ALARM_TOPIC_CONFIGS: AlarmTopicConfig[] = [ 1715 { topicId: 'refunds', enabled: true, severity: 2, thresholdValue: 1, thresholdOperator: '>=', emailNotifications: false, recipients: [] }, 1716 { topicId: 'cancellations', enabled: true, severity: 2, thresholdValue: 1, thresholdOperator: '>=', emailNotifications: false, recipients: [] }, 1717 { topicId: 'missed_calls', enabled: true, severity: 1, thresholdValue: 1, thresholdOperator: '>=', emailNotifications: false, recipients: [] }, 1718 { topicId: 'agam', enabled: true, severity: 1, thresholdValue: 1, thresholdOperator: '>=', emailNotifications: false, recipients: [] }, 1719 { topicId: 'agent_alarms', enabled: true, severity: 3, thresholdValue: 1, thresholdOperator: '>=', emailNotifications: false, recipients: [] }, 1720] 1721`,Ie=`/** 1722 * Unified App Configuration Types (v2.0) 1723 * 1724 * Type definitions for per-tenant application configuration stored in DynamoDB app-templates table. 1725 * Source of truth: terraform/SHARED/create-dynamo-app-templates/templates/ 1726 * 1727 * This unified structure replaces the previous fragmented config system 1728 * (AppConfig, StoreAiConfig, AgentConfig) with a single extensible interface. 1729 */ 1730 1731// ============================================================================ 1732// Schema Version 1733// ============================================================================ 1734 1735export type SchemaVersion = '1.0' | '2.0'; 1736export const CURRENT_SCHEMA_VERSION: SchemaVersion = '2.0'; 1737 1738// ============================================================================ 1739// App Types 1740// ============================================================================ 1741 1742export type AppType = 1743 | 'ai-call-analysis' 1744 | 'conversational-agent' 1745 | 'marketing-executor' 1746 | 'transactional-executor' 1747 | 'lead-analysis' 1748 | 'template-generator' 1749 | 'expert-analysis'; 1750 1751export const VALID_APP_TYPES: AppType[] = [ 1752 'ai-call-analysis', 1753 'conversational-agent', 1754 'marketing-executor', 1755 'transactional-executor', 1756 'lead-analysis', 1757 'template-generator', 1758 'expert-analysis', 1759]; 1760 1761// ============================================================================ 1762// Tool Configuration (shared with tenant tools) 1763// ============================================================================ 1764 1765export interface AppToolParameter { 1766 type: 'string' | 'number' | 'boolean'; 1767 format?: 'phone' | 'email'; 1768 description: string; 1769 required: boolean; 1770 default?: unknown; 1771 options?: unknown[]; 1772} 1773 1774export interface AppToolDefinition { 1775 name: string; 1776 description: string; 1777 type: 'function'; 1778 parameters: Record<string, AppToolParameter>; 1779} 1780 1781// ============================================================================ 1782// LLM Configuration (all apps have this) 1783// ============================================================================ 1784 1785/** 1786 * LLM configuration for AI-powered apps. 1787 * Contains model selection and per-task model overrides. 1788 * 1789 * NOTE: API credentials (apiKeySsm, apiBaseUrl) are now stored in 1790 * tenant.integrations.ai.moonshot, NOT in app config. 1791 */ 1792export interface LlmConfig { 1793 /** Default model to use for generation tasks */ 1794 defaultModel: string; 1795 /** Temperature for model generation (0.0 - 2.0) */ 1796 temperature: number; 1797 /** Optional base URL for API (for non-OpenAI providers) - legacy, prefer tenant integrations */ 1798 apiBaseUrl?: string; 1799 /** Optional per-task model overrides */ 1800 models?: { 1801 classifier?: string; 1802 pii?: string; 1803 summary?: string; 1804 coaching?: string; 1805 generation?: string; 1806 }; 1807} 1808 1809// ============================================================================ 1810// Prompts Configuration 1811// ============================================================================ 1812 1813/**
1814 * Prompts configuration for AI-powered apps. 1815 * References S3 paths to prompt files. 1816 */ 1817export interface PromptsConfig { 1818 /** S3 path to system prompt */ 1819 system?: string; 1820 /** Additional S3 prompt paths */ 1821 paths?: string[]; 1822} 1823 1824// ============================================================================ 1825// Knowledge Configuration 1826// ============================================================================ 1827 1828import type { KnowledgeCategory } from './knowledge.js'; 1829 1830/** 1831 * Knowledge sources for AI-powered apps. 1832 * Supports both explicit S3 paths and category-based subscriptions. 1833 */ 1834export interface KnowledgeConfig { 1835 /** Explicit S3 paths to knowledge files (e.g., "global/brand-guidelines.md") */ 1836 sources: string[]; 1837 /** Category subscriptions - loads files from manifest matching these categories */ 1838 categories?: KnowledgeCategory[]; 1839} 1840 1841// ============================================================================ 1842// Tools Configuration 1843// ============================================================================ 1844 1845/** 1846 * Tools configuration for apps that use function calling. 1847 */ 1848export interface AppToolsConfig { 1849 /** List of enabled tool names */ 1850 enabled: string[]; 1851 /** S3 path to tools.json (for large configs) */ 1852 definitionsPath?: string; 1853 /** Inline tool definitions (OR use definitionsPath) */ 1854 definitions?: AppToolDefinition[]; 1855} 1856 1857// ============================================================================ 1858// Media Configuration 1859// ============================================================================ 1860 1861export interface MediaConfig { 1862 service: string; 1863} 1864 1865// ============================================================================ 1866// Display / Persona 1867// ============================================================================ 1868 1869/** 1870 * Display information for app personas. 1871 * Gives each app a human-friendly identity in the UI. 1872 */ 1873export interface AppDisplayInfo { 1874 /** Human-friendly name for the app (e.g., "Sophie") */ 1875 name: string; 1876 /** Job role description (e.g., "Quality Analyst") */ 1877 role: string; 1878 /** Icon emoji */ 1879 icon: string; 1880 /** Short tagline describing what the app does */ 1881 tagline: string; 1882} 1883 1884/** 1885 * Default display personas for each app type. 1886 * Used when display info is not specified in the template. 1887 */ 1888export const DEFAULT_APP_PERSONAS: Record<AppType, AppDisplayInfo> = { 1889 'ai-call-analysis': { 1890 name: 'Sophie', 1891 role: 'Quality Analyst', 1892 icon: 'ð', 1893 tagline: 'Reviews every call so you don\\'t have to', 1894 }, 1895 'conversational-agent': { 1896 name: 'Max', 1897 role: 'Receptionist', 1898 icon: 'ðï¸', 1899 tagline: 'Answers the phone, day or night', 1900 }, 1901 'marketing-executor': { 1902 name: 'Mia', 1903 role: 'Marketing Coordinator', 1904 icon: 'ð£', 1905 tagline: 'Gets the word out to your customers', 1906 }, 1907 'transactional-executor': { 1908 name: 'Sam', 1909 role: 'Dispatch Coordinator', 1910 icon: 'ð¨', 1911 tagline: 'Sends confirmations and updates', 1912 }, 1913 'lead-analysis': { 1914 name: 'Lily', 1915 role: 'Sales Prep Assistant', 1916 icon: 'ð¡', 1917 tagline: 'Prepares insights before every call', 1918 }, 1919 'template-generator': { 1920 name: 'Mia', 1921 role: 'Template Designer', 1922 icon: 'â¨', 1923 tagline: 'Creates professional templates in seconds', 1924 }, 1925 'expert-analysis': { 1926 name: 'Alex', 1927 role: 'Marketing Analyst', 1928 icon: 'ð', 1929 tagline: 'Analyzes ad performance and finds opportunities', 1930 }, 1931}; 1932 1933// ============================================================================ 1934// Metadata 1935// ============================================================================ 1936 1937export type MetadataCategory = 'analysis' | 'conversational' | 'marketing' | 'transactional'; 1938export type MetadataCriticality = 'low' | 'medium' | 'high' | 'critical'; 1939export type MetadataChannel = 'sms' | 'email' | 'voice'; 1940 1941export const VALID_METADATA_CATEGORIES: MetadataCategory[] = [ 1942 'analysis', 1943 'conversational', 1944 'marketing', 1945 'transactional', 1946]; 1947 1948export const VALID_METADATA_CRITICALITIES: MetadataCriticality[] = [ 1949 'low', 1950 'medium', 1951 'high', 1952 'critical', 1953]; 1954 1955export const VALID_METADATA_CHANNELS: MetadataChannel[] = ['sms', 'email', 'voice']; 1956 1957export interface AppMetadata { 1958 category: MetadataCategory; 1959 owner: string; 1960 criticality: MetadataCriticality; 1961 channels?: MetadataChannel[]; 1962} 1963 1964// ============================================================================ 1965// Extension Types (app-specific, easily add new ones) 1966// ============================================================================ 1967 1968export type AnalysisRunMode = 'full' | 'partial' | 'disabled'; 1969 1970export const VALID_ANALYSIS_RUN_MODES: AnalysisRunMode[] = ['full', 'partial', 'disabled']; 1971 1972/** 1973 * Analysis extension for ai-call-analysis apps. 1974 * Contains analysis-specific configuration like timeouts and prompt paths. 1975 */ 1976export interface AnalysisExtension { 1977 /** Run mode: full analysis, partial, or disabled */ 1978 runMode: AnalysisRunMode; 1979 /** S3 path to summary prompt */ 1980 summaryPromptPath: string; 1981 /** S3 path to coaching prompt */ 1982 coachingPromptPath: string;
1983 /** S3 path to PII schema */ 1984 piiSchemaPath: string; 1985 /** Timeout settings per analysis task */ 1986 timeoutSeconds: { 1987 classification: number; 1988 pii: number; 1989 summary: number; 1990 coaching: number; 1991 }; 1992} 1993 1994/** 1995 * Grounded facts a voice agent may state on a call. 1996 * 1997 * â This is the ONLY source a voice agent has for prices, finance terms, 1998 * warranty and delivery claims. Facts are injected into the model as data, never 1999 * baked into persona prose â a figure that lives in a prompt survives every 2000 * price change, and the case suite caught the persona confidently quoting a 2001 * range nobody had verified. Free-text \`notes\` are rendered verbatim. 2002 */ 2003export interface VoiceAgentFacts { 2004 /** e.g. "eleven chairs, from around three and a half thousand to eight thousand dollars" */ 2005 priceRange?: string; 2006 products?: Array<{ 2007 name: string; 2008 /** Already in speakable words: "five thousand five hundred dollars" */ 2009 price?: string; 2010 oneLiner?: string; 2011 }>; 2012 finance?: { 2013 provider?: string; 2014 /** Speakable summary of the plan and fees. */ 2015 summary?: string; 2016 }; 2017 warranty?: string; 2018 delivery?: string; 2019 /** Active promotion, with its end framing ("Father's Day sale, ends Sunday"). */ 2020 promo?: string; 2021 showrooms?: string; 2022 notes?: string[]; 2023} 2024 2025/** 2026 * Per-tenant ConversationRelay turn-taking and opening tuning. 2027 * 2028 * These are TwiML attributes, moved into tenant config so one handler serves 2029 * every tenant and tuning never needs a deploy. All optional â the TwiML 2030 * builder owns the defaults. 2031 */ 2032export interface ConversationRelayTuning { 2033 /** Flux end-of-turn confidence, 0.5â0.9. Only applies on speechModel="flux". */ 2034 eotThreshold?: number; 2035 interruptSensitivity?: 'low' | 'medium' | 'high'; 2036 ignoreBackchannel?: boolean; 2037 /** Twilio's tool-time controls: whether queued speech can be pre-empted by 2038 * new tokens, and what may interrupt playback. Twilio's CR function-calling 2039 * guidance says to set these deliberately when tools run mid-call. */ 2040 preemptible?: boolean; 2041 interruptible?: 'none' | 'dtmf' | 'speech' | 'any'; 2042 /** 2043 * Spoken by Twilio the instant the call connects, before the WebSocket has 2044 * responded â the fix for dead air at the top of an outbound call. Inbound 2045 * and outbound get separate lines because "thanks for calling" is wrong on a 2046 * call WE placed. 2047 */ 2048 welcomeGreetingOutbound?: string; 2049 welcomeGreetingInbound?: string; 2050} 2051 2052/** 2053 * Agent extension for conversational-agent apps. 2054 * Contains voice agent configuration like persona, voice, and features. 2055 */ 2056export interface AgentExtension { 2057 /** Agent persona for greetings and personality */ 2058 persona: { 2059 name: string; 2060 instructions: string; 2061 }; 2062 /** 2063 * Pointer to a versioned persona in the \`@bigm/prompts\` registry 2064 * (\`s3://bigm-prompts/<id>.<version>.md\`). Preferred over 2065 * \`persona.instructions\`; an eval result must be attributable to exact text. 2066 */ 2067 personaPrompt?: { id: string; version?: string }; 2068 /** Grounded facts the agent may state. See VoiceAgentFacts. */ 2069 facts?: VoiceAgentFacts; 2070 /** Per-tenant CR turn-taking + greeting tuning. */ 2071 conversationRelay?: ConversationRelayTuning; 2072 /** Voice synthesis configuration */ 2073 voice: { 2074 provider: string; 2075 name: string; 2076 language: string; 2077 rate?: number; 2078 }; 2079 /** Feature flags for voice agent */ 2080 features: { 2081 vad: boolean; 2082 bargeIn: boolean; 2083 consentPrompt?: string; 2084 }; 2085} 2086 2087/** 2088 * Lead analysis extension for lead-analysis apps. 2089 * Contains configuration for generating pre-call sales insights. 2090 */ 2091export interface LeadAnalysisExtension { 2092 /** S3 path to insights generation prompt */ 2093 insightsPromptPath: string; 2094 /** Maximum number of events to include in analysis */ 2095 maxEventsToAnalyze: number; 2096 /** Whether to include call transcript summaries if available */ 2097 includeCallTranscripts: boolean; 2098 /** Whether to include purchase history in analysis */ 2099 includePurchaseHistory: boolean; 2100 /** Timeout in seconds for insights generation */ 2101 timeoutSeconds: number; 2102} 2103 2104/** 2105 * Expert analysis extension for expert-analysis apps. 2106 * Contains per-analysis-type configuration for AI-driven data analysis. 2107 */ 2108export interface ExpertAnalysisExtension { 2109 analysisTypes: Record<string, { 2110 model: string; 2111 systemPromptPath: string; 2112 maxIterations: number; 2113 temperature: number; 2114 }>; 2115} 2116 2117// Add new extensions here as needed: 2118// interface RateLimitExtension { maxPerHour: number; maxPerDay: number; } 2119// interface SchedulingExtension { timezone: string; windows: TimeWindow[]; } 2120 2121// ============================================================================ 2122// Extensions Map 2123// ============================================================================ 2124 2125/** 2126 * Map of app-specific extensions. 2127 * Add new extension types here as they're needed. 2128 */ 2129export interface AppExtensions { 2130 analysis?: AnalysisExtension; 2131 agent?: AgentExtension; 2132 leadAnalysis?: LeadAnalysisExtension; 2133 expertAnalysis?: ExpertAnalysisExtension; 2134 // Future extensions - just add here: 2135 // rateLimit?: RateLimitExtension; 2136 // scheduling?: SchedulingExtension; 2137} 2138 2139// ============================================================================ 2140// Unified App Config (Main Type - v2.0) 2141// ============================================================================ 2142 2143/** 2144 * Unified App Configuration (v2.0) 2145 * 2146 * This is the main configuration type for all app templates. 2147 * It has a common structure for all apps with app-specific extensions. 2148 * 2149 * @example 2150 * \`\`\`typescript 2151 * const config: UnifiedAppConfig = { 2152 * schemaVersion: '2.0', 2153 * appType: 'marketing-executor', 2154 * version: '2026-01-21', 2155 * enabled: true, 2156 * llm: { defaultModel: 'gpt-4', temperature: 0.7, apiKeySsmPath: '' }, 2157 * prompts: { system: 'templates/marketing-executor/system.md' }, 2158 * knowledge: { sources: [] }, 2159 * tools: { enabled: ['sendSms', 'sendEmail'] }, 2160 * metadata: { category: 'marketing', owner: 'growth', criticality: 'high' } 2161 * }; 2162 * \`\`\` 2163 */ 2164export interface UnifiedAppConfig { 2165 /** Schema version for migration support */ 2166 schemaVersion: SchemaVersion; 2167 2168 /** App type identifier */ 2169 appType: AppType; 2170 2171 /** Version string (e.g., '2026-01-21') */ 2172 version: string; 2173 2174 /** Whether app is enabled (can be overridden per-tenant) */ 2175 enabled: boolean; 2176 2177 /** Display persona for UI (name, role, icon, tagline) */ 2178 display?: AppDisplayInfo; 2179 2180 /** LLM configuration (all apps have this) */ 2181 llm: LlmConfig; 2182 2183 /** Prompts configuration (S3 paths to prompt files) */ 2184 prompts: PromptsConfig; 2185 2186 /** Knowledge sources (S3 paths to knowledge files) */ 2187 knowledge: KnowledgeConfig; 2188 2189 /** Tools configuration (function calling) */ 2190 tools: AppToolsConfig; 2191 2192 /** Media service configuration (optional) */ 2193 media?: MediaConfig; 2194 2195 /** App metadata (category, owner, criticality) */ 2196 metadata: AppMetadata; 2197 2198 /** App-specific extensions (optional) */ 2199 extensions?: AppExtensions; 2200} 2201 2202// ============================================================================ 2203// App Type Aliases (for convenience and type safety) 2204// ============================================================================ 2205 2206/** 2207 * AI Call Analysis app configuration. 2208 * Requires the analysis extension. 2209 */ 2210export type AiCallAnalysisConfig = UnifiedAppConfig & { 2211 appType: 'ai-call-analysis'; 2212 extensions: { analysis: AnalysisExtension }; 2213}; 2214 2215/** 2216 * Conversational Agent app configuration. 2217 * Requires the agent extension. 2218 */ 2219export type ConversationalAgentConfig = UnifiedAppConfig & { 2220 appType: 'conversational-agent'; 2221 extensions: { agent: AgentExtension }; 2222}; 2223 2224/** 2225 * Marketing Executor app configuration. 2226 * No required extensions. 2227 */ 2228export type MarketingExecutorConfig = UnifiedAppConfig & { 2229 appType: 'marketing-executor'; 2230}; 2231 2232/** 2233 * Transactional Executor app configuration. 2234 * No required extensions. 2235 */ 2236export type TransactionalExecutorConfig = UnifiedAppConfig & { 2237 appType: 'transactional-executor'; 2238}; 2239 2240/** 2241 * Lead Analysis app configuration. 2242 * Requires the leadAnalysis extension. 2243 */ 2244export type LeadAnalysisConfig = UnifiedAppConfig & { 2245 appType: 'lead-analysis'; 2246 extensions: { leadAnalysis: LeadAnalysisExtension }; 2247}; 2248 2249/** 2250 * Expert Analysis app configuration. 2251 * Requires the expertAnalysis extension. 2252 */ 2253export type ExpertAnalysisConfig = UnifiedAppConfig & { 2254 appType: 'expert-analysis'; 2255 extensions: { expertAnalysis: ExpertAnalysisExtension }; 2256}; 2257 2258// ============================================================================ 2259// Legacy Types (v1.0 - for backward compatibility) 2260// ============================================================================ 2261 2262/** 2263 * @deprecated Use UnifiedAppConfig instead. This is kept for backward compatibility. 2264 */ 2265export interface AnalysisTimeouts { 2266 classification: number; 2267 pii: number; 2268 summary: number; 2269 coaching: number; 2270} 2271 2272/** 2273 * @deprecated Use UnifiedAppConfig.extensions.analysis instead. 2274 */ 2275export interface AnalysisConfig { 2276 enabled: boolean; 2277 runMode: AnalysisRunMode; 2278 classifierModel: string; 2279 piiModel: string;
2280 summaryModel: string; 2281 coachingModel: string; 2282 temperature: number; 2283 apiKeySsmPath: string; 2284 apiBaseUrl: string; 2285 summaryPromptPath: string; 2286 coachingPromptPath: string; 2287 piiSchemaPath: string; 2288 timeoutSeconds: AnalysisTimeouts; 2289} 2290 2291/** 2292 * @deprecated Use UnifiedAppConfig instead. 2293 */ 2294export interface AppConfigBase { 2295 appId: string; 2296 appType: AppType; 2297 tenant: string; 2298 metadata: AppMetadata; 2299} 2300 2301/** 2302 * @deprecated Use AiCallAnalysisConfig instead. 2303 */ 2304export interface AiCallAnalysisAppConfig extends AppConfigBase { 2305 appType: 'ai-call-analysis'; 2306 analysisVersion: string; 2307 analysis: AnalysisConfig; 2308} 2309 2310/** 2311 * @deprecated Use ConversationalAgentConfig instead. 2312 */ 2313export interface ConversationalAgentAppConfig extends AppConfigBase { 2314 appType: 'conversational-agent'; 2315} 2316 2317/** 2318 * @deprecated Use MarketingExecutorConfig or TransactionalExecutorConfig instead. 2319 */ 2320export interface ExecutorAppConfig extends AppConfigBase { 2321 appType: 'marketing-executor' | 'transactional-executor'; 2322 tools?: AppToolsConfig; 2323 knowledge?: KnowledgeConfig; 2324 media?: MediaConfig; 2325 agentVersion?: string; 2326 twilioPhoneNumber?: string; 2327 emailFrom?: string; 2328 emailFromName?: string; 2329 configurationSetName?: string; 2330} 2331 2332/** 2333 * @deprecated Use UnifiedAppConfig instead. 2334 * Union type for all legacy app configurations. 2335 */ 2336export type AppConfig = 2337 | AiCallAnalysisAppConfig 2338 | ConversationalAgentAppConfig 2339 | ExecutorAppConfig; 2340 2341/** 2342 * @deprecated Use UnifiedAppConfig stored directly. 2343 */ 2344export interface AppConfigRecord { 2345 appId: string; 2346 tenant: string; 2347 config: AppConfig; 2348} 2349 2350// ============================================================================ 2351// Resolved App Config (with tenant-specific fields added at runtime) 2352// ============================================================================ 2353 2354/** 2355 * Resolved app configuration with runtime fields. 2356 * This extends UnifiedAppConfig with fields added during resolution. 2357 */ 2358export interface ResolvedAppConfig extends UnifiedAppConfig { 2359 /** Generated app ID: {tenant-short}-{appType}-v1 */ 2360 appId: string; 2361 /** Tenant ID from resolution */ 2362 tenant: string; 2363} 2364 2365// ============================================================================ 2366// Helper Functions 2367// ============================================================================ 2368 2369/** 2370 * Check if a config is a UnifiedAppConfig (v2.0) 2371 */ 2372export function isUnifiedConfig(config: unknown): config is UnifiedAppConfig { 2373 return ( 2374 typeof config === 'object' && 2375 config !== null && 2376 'schemaVersion' in config && 2377 (config as UnifiedAppConfig).schemaVersion === '2.0' 2378 ); 2379} 2380 2381/** 2382 * Check if an app config is an AI Call Analysis config 2383 */ 2384export function isAiCallAnalysisConfig( 2385 config: UnifiedAppConfig | AppConfig 2386): config is AiCallAnalysisConfig | AiCallAnalysisAppConfig { 2387 return config.appType === 'ai-call-analysis'; 2388} 2389 2390/** 2391 * Check if an app config is an Executor config 2392 */ 2393export function isExecutorConfig( 2394 config: UnifiedAppConfig | AppConfig 2395): config is MarketingExecutorConfig | TransactionalExecutorConfig | ExecutorAppConfig { 2396 return ( 2397 config.appType === 'marketing-executor' || 2398 config.appType === 'transactional-executor' 2399 ); 2400} 2401 2402/** 2403 * Check if an app config is a Conversational Agent config 2404 */ 2405export function isConversationalConfig( 2406 config: UnifiedAppConfig | AppConfig 2407): config is ConversationalAgentConfig | ConversationalAgentAppConfig { 2408 return config.appType === 'conversational-agent'; 2409} 2410 2411/** 2412 * Check if a config has the analysis extension 2413 */ 2414export function hasAnalysisExtension( 2415 config: UnifiedAppConfig 2416): config is UnifiedAppConfig & { extensions: { analysis: AnalysisExtension } } { 2417 return ( 2418 config.extensions?.analysis !== undefined && 2419 typeof config.extensions.analysis.runMode === 'string' 2420 ); 2421} 2422 2423/** 2424 * Check if a config has the agent extension 2425 */ 2426export function hasAgentExtension( 2427 config: UnifiedAppConfig 2428): config is UnifiedAppConfig & { extensions: { agent: AgentExtension } } { 2429 return ( 2430 config.extensions?.agent !== undefined && 2431 typeof config.extensions.agent.persona === 'object' 2432 ); 2433} 2434 2435/** 2436 * Check if an app config is a Lead Analysis config 2437 */ 2438export function isLeadAnalysisConfig( 2439 config: UnifiedAppConfig | AppConfig 2440): config is LeadAnalysisConfig { 2441 return config.appType === 'lead-analysis'; 2442} 2443 2444/** 2445 * Check if a config has the leadAnalysis extension 2446 */ 2447export function hasLeadAnalysisExtension( 2448 config: UnifiedAppConfig 2449): config is UnifiedAppConfig & { extensions: { leadAnalysis: LeadAnalysisExtension } } { 2450 return ( 2451 config.extensions?.leadAnalysis !== undefined && 2452 typeof config.extensions.leadAnalysis.insightsPromptPath === 'string' 2453 ); 2454} 2455 2456/** 2457 * Check if an app config is an Expert Analysis config 2458 */ 2459export function isExpertAnalysisConfig( 2460 config: UnifiedAppConfig | AppConfig 2461): config is ExpertAnalysisConfig { 2462 return config.appType === 'expert-analysis'; 2463} 2464 2465/** 2466 * Check if a config has the expertAnalysis extension 2467 */ 2468export function hasExpertAnalysisExtension( 2469 config: UnifiedAppConfig 2470): config is UnifiedAppConfig & { extensions: { expertAnalysis: ExpertAnalysisExtension } } { 2471 return ( 2472 config.extensions?.expertAnalysis !== undefined && 2473 typeof config.extensions.expertAnalysis.analysisTypes === 'object' 2474 ); 2475} 2476`,_e=`/** 2477 * Appointment Type Definitions 2478 * 2479 * SMS-booked consultation appointments. Table: \`appointments\` 2480 * (PK: appointmentId). Terraform: terraform/SHARED/create-dynamo-appointments. 2481 * 2482 * GSIs: 2483 * - appointmentsByTenantStart â PK tenantId, SK startTimeUtc (Calendar 7-day window) 2484 * - appointmentsByLeadStart â PK leadId, SK startTimeUtc (find a lead's active appt) 2485 * 2486 * Design notes: 2487 * - NO status lifecycle. A non-cancelled row IS a booked appointment. \`cancelled\` 2488 * (filtered out of the calendar) is the only terminal flag. 2489 * - INVARIANT: at most ONE active (non-cancelled) appointment per lead. 2490 * - Times are stored in UTC; the UI renders Australia/Melbourne. Slots are 30 min, 2491 * starting on the hour or half-hour, inside business hours (see below). 2492 * - Per-change provenance lives on the \`appointment.*\` events in \`events-customer\` 2493 * (immutable audit), so there is intentionally no \`lastChangeSource\` field here. 2494 */ 2495 2496import type { AuState } from './lead.js'; 2497 2498/** How an appointment row was created (quick display origin; full audit is event-side). */
2499export type AppointmentCreatedBy = 'ai_sms' | 'operator_ui'; 2500 2501/** 2502 * Where the appointment happens. 2503 * - \`phone\` â the agent calls the customer (the original + only mode until 2026-07-30). 2504 * - \`showroom\` â the customer comes in to a physical showroom; occupies a longer block. 2505 * - \`reseller\` â the customer visits a third-party reseller showroom (2026-09). The 2506 * reseller's identity is snapshotted onto the row; gated by 2507 * \`TenantConfig.resellerAppointments\` + the reseller's own \`appointmentsEnabled\`. 2508 * 2509 * ABSENT means \`phone\`: every row written before showroom bookings existed has no 2510 * \`mode\`, so readers must treat undefined as phone rather than backfilling. 2511 */ 2512export type AppointmentMode = 'phone' | 'showroom' | 'reseller'; 2513 2514/** Appointment row stored in the \`appointments\` DynamoDB table. */ 2515export interface Appointment { 2516 /** PK â unique appointment id, format \`appt_<base36-ts>_<rand6>\` */ 2517 appointmentId: string; 2518 2519 /** Tenant (shop domain, e.g. "dev-masseuse-massage-store.myshopify.com") */ 2520 tenantId: string; 2521 2522 /** The customer's Lead id */ 2523 leadId: string; 2524 2525 /** Assigned agent id â null/absent until a team leader assigns. Drives tenant_sales scoping. */ 2526 assignedAgentId?: string; 2527 2528 /** ISO 8601 UTC start (on the :00/:30 grid, within business hours) */ 2529 startTimeUtc: string; 2530 2531 /** ISO 8601 UTC end (phone: start + APPOINTMENT_SLOT_MINUTES; showroom: start + the 2532 * tenant's showroom durationMinutes). Readers must use this, not start+30, so a 2533 * 60-min showroom block is not mistaken for a single slot. */ 2534 endTimeUtc: string; 2535 2536 /** Phone call or showroom visit. ABSENT = 'phone' (back compat â see AppointmentMode). */ 2537 mode?: AppointmentMode; 2538 2539 /** 2540 * Location name SNAPSHOTTED at create time, so editing the source config later 2541 * never rewrites what a customer was already told. For \`mode:'showroom'\` it 2542 * comes from \`TenantConfig.showroom\`; for \`mode:'reseller'\` from the reseller 2543 * location. Absent for \`mode:'phone'\`. 2544 */ 2545 locationName?: string; 2546 2547 /** Full one-line address, snapshotted at create time (see locationName). */ 2548 locationAddress?: string; 2549 2550 /** 2551 * Reseller identity, SNAPSHOTTED at create time â only set for 2552 * \`mode:'reseller'\`. \`resellerName\` is the \`resellers\` table PK; the label 2553 * disambiguates multi-location resellers. Contact name/phone are the person 2554 * the customer should ask for (chosen from \`ResellerLocation.people[]\` at 2555 * booking: the primary with a mobile, else the first person with one). 2556 * Snapshotting matches the showroom precedent: a later registry edit must 2557 * never change what either party was already told. 2558 */ 2559 resellerName?: string; 2560 resellerLocationLabel?: string; 2561 resellerContactName?: string; 2562 /** E.164 without the \`+\` (e.g. 61412345678) â the reseller-side SMS destination. */ 2563 resellerContactPhone?: string; 2564 /** IANA zone of the visit location (from the reseller location's hours config 2565 * / state), snapshotted at create. The reminder scheduler evaluates its send 2566 * window here so a WA visit is never texted at 6am local. */ 2567 locationTimezone?: string; 2568 /** Tracked short URLs to the two contact cards, minted at booking so every 2569 * later event (reschedule, cancel) reuses the same links. */ 2570 resellerCardUrl?: string; 2571 customerCardUrl?: string; 2572 /** 2573 * mode:'reseller' only â the agent who actually made the booking, kept for 2574 * provenance because \`assignedAgentId\` is ALWAYS the reseller manager on a 2575 * reseller visit (\`TenantConfig.resellerAppointments.managerAgentEmail\`). 2576 * Absent for AI bookings (see \`createdBy: 'ai_sms'\`). 2577 */ 2578 bookedByAgentId?: string; 2579 2580 /** Phone number the booking conversation happened on (what we text for changes) */ 2581 customerPhone: string; 2582 2583 /** Booking campaign that created/owns this appointment thread */ 2584 bookingCampaignId?: string; 2585 2586 /** 2587 * The OPENER workflow that started the booking conversation (immutable 2588 * cohort attribution â missed-call, out-of-hours form, etc.). 2589 * \`bookingCampaignId\` is the reply-handler workflow after turn 1; this 2590 * field keeps the original trigger for per-trigger funnel reporting. 2591 */ 2592 originCampaignId?: string; 2593 2594 /** When true, the appointment is dropped from the calendar (soft-cancel) */ 2595 cancelled?: boolean; 2596 2597 /** 2598 * True once the ASSIGNED agent has confirmed they'll attend. Cleared on ANY 2599 * modification (time change or reassignment, operator- or AI-driven) so the 2600 * (new) agent must re-accept. Only meaningful while assignedAgentId is set. 2601 */ 2602 accepted?: boolean; 2603 2604 /** ISO 8601 â when the assigned agent accepted (set alongside accepted=true) */ 2605 acceptedAt?: string; 2606 2607 /** ISO 8601 â UTC create timestamp */ 2608 createdAt: string; 2609 2610 /** ISO 8601 â UTC last-edit timestamp */ 2611 updatedAt: string; 2612 2613 /** How this row was created (display only â see event log for full provenance) */ 2614 createdBy?: AppointmentCreatedBy; 2615 2616 /** What the appointment is about, when the AI agent captured it from a 2617 * purchaser (product_service | product_support | new_enquiry | other). 2618 * Also mirrored onto the appointment.created/updated event. */ 2619 topic?: string; 2620 2621 /** 2622 * ISO 8601 â a team leader confirmed (from the calendar) that this lead has 2623 * been taken out of the MaxContact auto-dialler (marked B/C in the MaxContact 2624 * back end, which fires no webhook so the platform never sees it). 2625 * 2626 * DISPLAY-ONLY and appointment-scoped: it suppresses the purple "not marked 2627 * BC" badge/dot on /calendar. It does NOT touch the Lead record â the 2628 * canonical BC pile is still \`Lead.firstBuyerCloseUtc\`/\`bcExitUtc\`, and 2629 * outreach suppression keeps reading the Lead, never this field. 2630 */ 2631 bcMarkedAt?: string; 2632 2633 /** Email of the operator who set \`bcMarkedAt\` (server-derived from the JWT). */ 2634 bcMarkedBy?: string; 2635 2636 /** 2637 * ISO 8601 â a manager silenced this appointment's "due / running late" alert 2638 * from the global alert bar. Only settable once the slot is >10 min past (the 2639 * dataApi 400s before that), so it is an escape hatch for when the automatic 2640 * clear (any dial inside the appointment's call window) fails to fire, not a 2641 * way to mute an appointment before it is even late. 2642 * 2643 * DISPLAY-ONLY and appointment-scoped, exactly like \`bcMarkedAt\`: it hides one 2644 * row in one UI. It does NOT mean the appointment was kept, called or 2645 * cancelled, and NO backend consumer reads it â call-execution grading still 2646 * derives from the call events (\`daily-appointment-email\` classifyExecution, 2647 * \`check_appointment_no_contact\`), never from this field. 2648 */ 2649 alertDismissedAt?: string; 2650 2651 /** Email of the operator who set \`alertDismissedAt\` (server-derived from the JWT). */ 2652 alertDismissedBy?: string; 2653 2654 /** 2655 * ISO 8601 per reminder kind â the reminder IDEMPOTENCY MARKER, written ONLY 2656 * by \`appointment-reminder-scheduler\` via a conditional 2657 * \`attribute_not_exists(remindersSent.<kind>)\` update. Absent â not sent yet. 2658 * 2659 * â Idempotency lives HERE, not in a \`check_already_sent\` eligibility check. 2660 * \`campaign-sends\` claims are only a 1-minute dedup window 2661 * (\`claimCampaignExecution\`), and a lead-level already-sent check would 2662 * wrongly mute the reminder for the customer's SECOND appointment. 2663 */ 2664 remindersSent?: AppointmentRemindersSent; 2665} 2666
2667/** Reminder kinds emitted by \`appointment-reminder-scheduler\`. */ 2668export type AppointmentReminderKind = 'day_before' | 'soon'; 2669 2670/** ISO 8601 send timestamp per reminder kind (see \`Appointment.remindersSent\`). */ 2671export type AppointmentRemindersSent = Partial<Record<AppointmentReminderKind, string>>; 2672 2673/** Server fills appointmentId, endTimeUtc, createdAt, updatedAt. */ 2674export interface CreateAppointmentInput { 2675 tenantId: string; 2676 leadId: string; 2677 customerPhone: string; 2678 startTimeUtc: string; 2679 assignedAgentId?: string; 2680 bookingCampaignId?: string; 2681 createdBy?: AppointmentCreatedBy; 2682 /** Absent = 'phone'. 'showroom' is rejected for tenants with no 2683 * \`TenantConfig.showroom\`; 'reseller' for tenants with no 2684 * \`TenantConfig.resellerAppointments\` (and per-reseller eligibility). */ 2685 mode?: AppointmentMode; 2686 /** mode:'reseller' only â which reseller (table PK) the visit is booked at. */ 2687 resellerName?: string; 2688 /** mode:'reseller' only â which of the reseller's locations (label match; 2689 * omitted when the reseller has exactly one location). */ 2690 resellerLocationLabel?: string; 2691} 2692 2693/** Any field omitted is left unchanged. Setting \`cancelled:true\` removes it from the calendar. */ 2694export interface UpdateAppointmentInput { 2695 startTimeUtc?: string; 2696 assignedAgentId?: string; 2697 cancelled?: boolean; 2698 accepted?: boolean; 2699} 2700 2701/** Generate a unique appointment id. Mirrors \`generateCohortId()\` in cohorts.ts. */ 2702export function generateAppointmentId(): string { 2703 const timestamp = Date.now().toString(36); 2704 const random = Math.random().toString(36).substring(2, 8); 2705 return \`appt_\${timestamp}_\${random}\`; 2706} 2707 2708// âââ Business-hours + slot grid (single source of truth) âââââââââââââââââââââ 2709// 2710// Shared by BOTH the backend \`manage_appointment\` step AND the shopdash dataApi 2711// so the two cannot diverge. Dependency-free: uses Intl with the Melbourne TZ 2712// (DST-correct on every JS runtime â no node:crypto / no date library). 2713 2714/** Booking timezone â every business-hours decision is made in this zone. */ 2715export const APPOINTMENT_TZ = 'Australia/Melbourne'; 2716 2717// âââ Customer display timezone (per Australian state) ââââââââââââââââââââââââ 2718// 2719// Slot VALIDITY is always decided in APPOINTMENT_TZ (when Steve can call). This 2720// map is ONLY for DISPLAYING/parsing a time in the customer's own state so a 2721// cross-state customer isn't told a Melbourne time they'd misread. Each IANA zone 2722// already carries its own DST rules + offset (incl. the half-hour SA/NT zones), 2723// so we hardcode no offset and no transition date â Intl applies them per-instant. 2724 2725/** Australian state/territory code â IANA timezone. Keyed by \`AuState\` so all 8 2726 * are mapped (compile-time exhaustiveness). NSW & ACT share Sydney; DST-observing 2727 * states (NSW/ACT/VIC/TAS/SA) carry their summer offset inside the IANA zone. */ 2728export const AU_STATE_TIMEZONES: Record<AuState, string> = { 2729 NSW: 'Australia/Sydney', 2730 ACT: 'Australia/Sydney', 2731 VIC: 'Australia/Melbourne', 2732 TAS: 'Australia/Hobart', 2733 QLD: 'Australia/Brisbane', // no DST 2734 SA: 'Australia/Adelaide', // +9:30 / +10:30 2735 NT: 'Australia/Darwin', // +9:30, no DST 2736 WA: 'Australia/Perth', // +8:00, no DST 2737}; 2738 2739/** Resolve an AU state/territory code to its IANA timezone. Case-insensitive, 2740 * trimmed; returns null for an unknown/empty code (caller keeps its default). 2741 * Codes only â the SMS model normalises a city/state-name to a code upstream. */ 2742export function auStateToTimezone(code?: string | null): string | null { 2743 if (!code) return null; 2744 const key = code.trim().toUpperCase(); 2745 return (AU_STATE_TIMEZONES as Record<string, string>)[key] ?? null; 2746} 2747 2748/** Fixed slot length (minutes). Slots start on :00 / :30. */ 2749export const APPOINTMENT_SLOT_MINUTES = 30; 2750 2751/** Open/close for one day, minutes-from-midnight. \`null\` = closed that day. */ 2752export type DayHours = { openMin: number; closeMin: number } | null; 2753 2754/** Daily open/close keyed by JS day-of-week (0=Sun..6=Sat). */ 2755export type BusinessHours = Record<number, DayHours>; 2756 2757/** 2758 * Tenant-facing booking-hours config (stored on \`TenantConfig.appointmentHours\`). 2759 * Modelled as weekday (MonâFri) + weekend (Sat/Sun) because that is how operators 2760 * think about the showroom roster; \`businessHoursFromConfig\` expands it to the 2761 * per-day \`BusinessHours\` the slot engine consumes. \`null\` on either = that group 2762 * is closed entirely. \`timezone\` is the IANA zone the minutes are expressed in 2763 * (defaults to Melbourne â the booking grid's frame). 2764 */ 2765export interface AppointmentHoursConfig { 2766 timezone?: string; 2767 weekday: DayHours; 2768 weekend: DayHours; 2769 /** 2770 * Per-day hours, keyed by JS day-of-week (0=Sun..6=Sat). When present this 2771 * is THE WHOLE TRUTH: a missing/null day is CLOSED and the weekday/weekend 2772 * groups are ignored (kept only as legacy mirrors for old readers). Added 2773 * 8 Sep 2026 for reseller showrooms â real showrooms vary by day ("Tue to 2774 * Fri" means closed Monday, which the two-group model cannot express). 2775 */ 2776 byDay?: Partial<Record<number, DayHours>>; 2777} 2778 2779/** 2780 * Platform-default booking hours (used when a tenant has no \`appointmentHours\`). 2781 * Weekday (MonâFri) 09:30â20:00, Weekend (Sat/Sun) 10:00â17:00. 2782 */ 2783export const DEFAULT_BUSINESS_HOURS: BusinessHours = { 2784 0: { openMin: 10 * 60, closeMin: 17 * 60 }, // Sun 10:00â17:00 2785 1: { openMin: 9 * 60 + 30, closeMin: 20 * 60 }, // Mon 09:30â20:00 2786 2: { openMin: 9 * 60 + 30, closeMin: 20 * 60 }, // Tue 2787 3: { openMin: 9 * 60 + 30, closeMin: 20 * 60 }, // Wed 2788 4: { openMin: 9 * 60 + 30, closeMin: 20 * 60 }, // Thu 2789 5: { openMin: 9 * 60 + 30, closeMin: 20 * 60 }, // Fri 2790 6: { openMin: 10 * 60, closeMin: 17 * 60 }, // Sat 10:00â17:00 2791}; 2792 2793/** 2794 * Expand a tenant's weekday/weekend \`AppointmentHoursConfig\` into the per-day 2795 * \`BusinessHours\` map the slot engine + calendar grid consume. Returns 2796 * \`DEFAULT_BUSINESS_HOURS\` when no config is supplied, so every caller stays 2797 * multi-tenant-safe with a single source of default truth. 2798 */ 2799export function businessHoursFromConfig(cfg?: AppointmentHoursConfig | null): BusinessHours { 2800 if (!cfg) return DEFAULT_BUSINESS_HOURS; 2801 if (cfg.byDay) { 2802 // Per-day config is the whole truth: absent/null day = closed. 2803 const out = {} as BusinessHours; 2804 for (let d = 0; d <= 6; d++) { 2805 const h = cfg.byDay[d]; 2806 out[d] = h ? { ...h } : null; 2807 } 2808 return out; 2809 } 2810 const { weekday, weekend } = cfg; 2811 return { 2812 0: weekend ? { ...weekend } : null, // Sun 2813 1: weekday ? { ...weekday } : null, // Mon 2814 2: weekday ? { ...weekday } : null, // Tue 2815 3: weekday ? { ...weekday } : null, // Wed 2816 4: weekday ? { ...weekday } : null, // Thu 2817 5: weekday ? { ...weekday } : null, // Fri 2818 6: weekend ? { ...weekend } : null, // Sat 2819 }; 2820} 2821 2822const DOW_INDEX: Record<string, number> = { 2823 Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, 2824}; 2825 2826/** Day-of-week (0=Sun..6=Sat) + minutes-from-midnight for a UTC instant, in an 2827 * arbitrary IANA timezone (DST-correct â Intl resolves the offset per-instant). 2828 * Generalizes \`melbourneFields\` so time-of-day gates can run in any zone. */ 2829export function zonedWeekdayMinutes(utcIso: string, timeZone: string): { dow: number; minutes: number } { 2830 const d = new Date(utcIso); 2831 const parts = new Intl.DateTimeFormat('en-US', { 2832 timeZone, 2833 weekday: 'short', 2834 hour: '2-digit', 2835 minute: '2-digit', 2836 hour12: false, 2837 }).formatToParts(d); 2838 let dow = 0; 2839 let hour = 0; 2840 let minute = 0; 2841 for (const p of parts) { 2842 if (p.type === 'weekday') dow = DOW_INDEX[p.value] ?? 0; 2843 else if (p.type === 'hour') hour = parseInt(p.value, 10) % 24; 2844 else if (p.type === 'minute') minute = parseInt(p.value, 10); 2845 } 2846 return { dow, minutes: hour * 60 + minute }; 2847} 2848
2849/** Melbourne-local day-of-week + minutes-from-midnight for a UTC instant. */ 2850export function melbourneFields(utcIso: string): { dow: number; minutes: number } { 2851 return zonedWeekdayMinutes(utcIso, APPOINTMENT_TZ); 2852} 2853 2854/** Result of validating a proposed start time. */ 2855export interface AppointmentTimeValidation { 2856 valid: boolean; 2857 reason?: 'past' | 'off_grid' | 'out_of_hours' | 'invalid'; 2858} 2859 2860/** 2861 * Validate a proposed UTC start time against the slot grid + business hours. 2862 * \`nowUtcIso\` is injected so callers stay deterministic/testable. 2863 * 2864 * \`timeZone\` is the IANA zone the businessHours minutes are expressed in. 2865 * Defaults to Melbourne (every pre-2026-09 caller's frame); reseller bookings 2866 * pass the location's own zone (\`appointmentHours.timezone\` ?? 2867 * \`auStateToTimezone(state)\`) so a Mandurah 10:00 is validated as Perth 10:00. 2868 * All AU zones sit on whole/half-hour offsets, so the :00/:30 grid check holds 2869 * in any of them. 2870 */ 2871export function validateAppointmentStart( 2872 startUtcIso: string, 2873 nowUtcIso: string, 2874 businessHours: BusinessHours = DEFAULT_BUSINESS_HOURS, 2875 timeZone: string = APPOINTMENT_TZ, 2876): AppointmentTimeValidation { 2877 const start = new Date(startUtcIso); 2878 if (isNaN(start.getTime())) return { valid: false, reason: 'invalid' }; 2879 if (start.getTime() <= new Date(nowUtcIso).getTime()) return { valid: false, reason: 'past' }; 2880 2881 const { dow, minutes } = zonedWeekdayMinutes(startUtcIso, timeZone); 2882 if (minutes % APPOINTMENT_SLOT_MINUTES !== 0) return { valid: false, reason: 'off_grid' }; 2883 2884 const hours = businessHours[dow]; 2885 // Closed that day, or outside open/close. A 30-min slot must finish by close â 2886 // last valid start = closeMin - slot. 2887 if (!hours || minutes < hours.openMin || minutes > hours.closeMin - APPOINTMENT_SLOT_MINUTES) { 2888 return { valid: false, reason: 'out_of_hours' }; 2889 } 2890 return { valid: true }; 2891} 2892 2893/** 2894 * Compute the appointment end as a UTC ISO string. Defaults to one slot 2895 * (\`APPOINTMENT_SLOT_MINUTES\`) so every existing phone caller is unchanged; 2896 * showroom callers pass the tenant's showroom duration. 2897 */ 2898export function computeAppointmentEndUtc( 2899 startUtcIso: string, 2900 durationMinutes: number = APPOINTMENT_SLOT_MINUTES, 2901): string { 2902 return new Date(new Date(startUtcIso).getTime() + durationMinutes * 60_000).toISOString(); 2903} 2904 2905/** 2906 * The earliest VALID booking slot strictly after \`nowUtcIso\` (+ optional 2907 * \`leadMinutes\` lead time), rolling forward to the next open day when today's 2908 * slots have all passed or the business is closed. Returns a UTC ISO string, 2909 * or null if none is found within the 2-week search horizon. 2910 * 2911 * This is what lets the booking AI propose a real time instead of a slot that 2912 * has already passed: callers inject the result so the model never has to 2913 * reason "what's still bookable today" itself. Reuses \`validateAppointmentStart\` 2914 * so the grid / business-hours / past rules cannot diverge. 2915 * 2916 * Melbourne is UTC+10/+11 (whole-hour offsets), so 30-min steps aligned to a 2917 * UTC :00/:30 boundary stay on the Melbourne :00/:30 grid. 2918 */ 2919export function computeEarliestBookableSlot( 2920 nowUtcIso: string, 2921 leadMinutes = 0, 2922 businessHours: BusinessHours = DEFAULT_BUSINESS_HOURS, 2923): string | null { 2924 const slotMs = APPOINTMENT_SLOT_MINUTES * 60_000; 2925 const now = new Date(nowUtcIso).getTime(); 2926 if (isNaN(now)) return null; 2927 // First slot boundary STRICTLY after (now + lead time). 2928 let t = Math.floor((now + leadMinutes * 60_000) / slotMs) * slotMs + slotMs; 2929 for (let i = 0; i < 14 * 48; i++, t += slotMs) { 2930 const iso = new Date(t).toISOString(); 2931 if (validateAppointmentStart(iso, nowUtcIso, businessHours).valid) return iso; 2932 } 2933 return null; 2934} 2935 2936/** 2937 * Snap a requested time to a bookable slot â deterministic so the model never 2938 * judges validity itself (it only parses NL â a candidate time; code decides). 2939 * 2940 * (1) round the request to the nearest :00/:30 boundary; (2) if that rounded 2941 * slot is valid (on-grid, in business hours, strictly future) return it; (3) 2942 * otherwise fall back to \`computeEarliestBookableSlot\` from the later of the 2943 * rounded time and now â which correctly rolls past an after-close same-day 2944 * time to the next open day (never clamps DOWN to an earlier slot the customer 2945 * didn't ask for). \`exact\` is true only when the snapped slot equals the 2946 * requested instant. Returns \`slotUtc: null\` only if nothing is bookable in the 2947 * 2-week horizon.
2948 * 2949 * UTC :00/:30 boundaries coincide with Melbourne :00/:30 (whole-hour offset). 2950 */ 2951export function snapToValidSlot( 2952 requestedUtcIso: string, 2953 nowUtcIso: string, 2954 businessHours: BusinessHours = DEFAULT_BUSINESS_HOURS, 2955): { slotUtc: string | null; exact: boolean } { 2956 const slotMs = APPOINTMENT_SLOT_MINUTES * 60_000; 2957 const req = new Date(requestedUtcIso).getTime(); 2958 if (isNaN(req)) return { slotUtc: computeEarliestBookableSlot(nowUtcIso, 0, businessHours), exact: false }; 2959 2960 const rounded = Math.round(req / slotMs) * slotMs; 2961 const roundedIso = new Date(rounded).toISOString(); 2962 if (validateAppointmentStart(roundedIso, nowUtcIso, businessHours).valid) { 2963 return { slotUtc: roundedIso, exact: rounded === req }; 2964 } 2965 2966 const floor = Math.max(rounded, new Date(nowUtcIso).getTime()); 2967 return { slotUtc: computeEarliestBookableSlot(new Date(floor).toISOString(), 0, businessHours), exact: false }; 2968} 2969 2970/** 2971 * All VALID :00/:30 booking slots (UTC ISO) strictly after \`nowUtcIso\` and 2972 * within \`horizonHours\`. Reuses \`validateAppointmentStart\` so grid/business-hours 2973 * rules never diverge. Used to build the A/B/C offer over the next 24h. 2974 */ 2975export function generateValidSlots( 2976 nowUtcIso: string, 2977 horizonHours: number, 2978 businessHours: BusinessHours = DEFAULT_BUSINESS_HOURS, 2979 /** 2980 * Earliest offered start, minutes from now (3 Oct 2026, prod 4e359d1a: the missed-call opener at 10:58 offered 2981 * "A) 11:00am"; the customer's "A" at 11:00 was already in the past and got "That time's not available"). An 2982 * OFFERED slot needs room for the reply; the default 0 keeps every other caller (validation grids) unchanged. 2983 */ 2984 minLeadMinutes = 0, 2985): string[] { 2986 const slotMs = APPOINTMENT_SLOT_MINUTES * 60_000; 2987 const now = new Date(nowUtcIso).getTime(); 2988 if (isNaN(now) || horizonHours <= 0) return []; 2989 const end = now + horizonHours * 3_600_000; 2990 const earliest = now + Math.max(0, minLeadMinutes) * 60_000; 2991 const out: string[] = []; 2992 for (let t = Math.floor(earliest / slotMs) * slotMs + slotMs; t <= end; t += slotMs) { 2993 const iso = new Date(t).toISOString(); 2994 if (validateAppointmentStart(iso, nowUtcIso, businessHours).valid) out.push(iso); 2995 } 2996 return out; 2997} 2998 2999/** 3000 * Pick up to \`count\` slots SPREAD across the window: partition \`slots\` 3001 * (chronological) into \`count\` contiguous buckets and take the first 3002 * under-capacity slot in each (overflowing into later slots if a bucket is 3003 * exhausted, never reusing one). \`isUnderCapacity\` is injected so the core 3004 * stays pure/testable. Returns the chosen slots in chronological order. 3005 */ 3006export function selectSpreadSlots( 3007 slots: string[], 3008 count: number, 3009 isUnderCapacity: (slotUtc: string) => boolean, 3010): string[] { 3011 if (count <= 0 || slots.length === 0) return []; 3012 const used = new Set<number>(); 3013 const chosen: string[] = []; 3014 const bucketSize = slots.length / count; 3015 for (let b = 0; b < count; b++) { 3016 const start = Math.floor(b * bucketSize); 3017 for (let i = start; i < slots.length; i++) { 3018 if (used.has(i)) continue; 3019 if (isUnderCapacity(slots[i])) { used.add(i); chosen.push(slots[i]); break; } 3020 } 3021 } 3022 return chosen.sort(); 3023} 3024 3025// âââ Per-slot booking capacity (tenant-config, time-banded) ââââââââââââââââââ 3026// 3027// Capacity used to be a flat \`maxPerSlot\` on each workflow step, which let the 3028// offered cap and the write cap diverge (three independent copies). The tenant 3029// config is now the single source of truth; step \`maxPerSlot\` remains the 3030// fallback for tenants without \`appointmentCapacity\` so legacy behaviour is 3031// byte-for-byte unchanged. 3032 3033/** 3034 * One capacity band: on \`days\` (JS day-of-week, 0=Sun..6=Sat), for slot starts 3035 * whose tenant-local minutes-from-midnight fall in \`[fromMin, toMin)\`, at most 3036 * \`max\` active appointments per 30-min slot. Omitted \`fromMin\`/\`toMin\` = the 3037 * whole day (0..1440). 3038 */ 3039export interface SlotCapacityBand { 3040 days: number[]; 3041 fromMin?: number; 3042 toMin?: number; 3043 max: number; 3044} 3045 3046/** 3047 * Tenant-facing per-slot capacity config (stored on 3048 * \`TenantConfig.appointmentCapacity\`). First matching band wins; no matching 3049 * band â \`default\`; \`default\` absent â the caller's fallback (usually the 3050 * workflow step's \`maxPerSlot\`). 3051 */ 3052export interface AppointmentCapacityConfig { 3053 default?: number; 3054 bands?: SlotCapacityBand[]; 3055} 3056 3057/** 3058 * Resolve the max active appointments allowed in the slot starting at 3059 * \`slotStartUtcIso\`, evaluated in \`timeZone\` (tenant-local â pass 3060 * \`appointmentHours.timezone ?? APPOINTMENT_TZ\`). Returns \`undefined\` for 3061 * "uncapped" (legacy tenants with neither config nor fallback). 3062 */ 3063export function capacityForSlotStart( 3064 slotStartUtcIso: string, 3065 timeZone: string, 3066 cfg?: AppointmentCapacityConfig | null, 3067 fallback?: number, 3068): number | undefined { 3069 if (!cfg) return fallback; 3070 if (cfg.bands?.length) { 3071 const { dow, minutes } = zonedWeekdayMinutes(slotStartUtcIso, timeZone); 3072 for (const band of cfg.bands) { 3073 if (!band.days?.includes(dow)) continue; 3074 const from = band.fromMin ?? 0; 3075 const to = band.toMin ?? 24 * 60; 3076 if (minutes >= from && minutes < to) return band.max; 3077 } 3078 } 3079 return cfg.default ?? fallback; 3080} 3081 3082// âââ Showroom (physical) appointments ââââââââââââââââââââââââââââââââââââââââ 3083// 3084// A showroom booking is the same \`appointments\` row with \`mode: '
3084showroom'\`, a 3085// longer \`endTimeUtc\`, and the address SNAPSHOTTED onto the row. Presence of 3086// \`TenantConfig.showroom\` is what gates the feature â a tenant without one can 3087// only book phone consults, and the API rejects \`mode:'showroom'\` for them. 3088 3089/** Default showroom visit length when the tenant config omits \`durationMinutes\`. */ 3090export const DEFAULT_SHOWROOM_DURATION_MINUTES = 60; 3091 3092/** 3093 * A tenant's physical showroom (stored on \`TenantConfig.showroom\`). Presence of 3094 * this config is the ONLY feature gate for showroom bookings â the ShopDash 3095 * create modal hides the Phone/Showroom toggle without it, and the API 400s. 3096 */ 3097export interface ShowroomConfig { 3098 /** Display name, e.g. "South Melbourne Showroom". */ 3099 name: string; 3100 /** Street line, e.g. "117 York Street". */ 3101 addressLine: string; 3102 /** Suburb, e.g. "South Melbourne". */ 3103 suburb: string; 3104 /** State/territory code, e.g. "VIC". */ 3105 state: string; 3106 /** Postcode, e.g. "3205". */ 3107 postcode: string; 3108 /** Optional maps deep link for the confirmation SMS / detail modal. */ 3109 mapsUrl?: string; 3110 /** Visit length in minutes (must be a multiple of APPOINTMENT_SLOT_MINUTES). 3111 * Unset â DEFAULT_SHOWROOM_DURATION_MINUTES. */ 3112 durationMinutes?: number; 3113 /** Optional contact number shown in the confirmation SMS. */ 3114 phone?: string; 3115} 3116 3117/** Default reseller visit length when the reseller row omits 3118 * \`appointmentDurationMinutes\`. Same as a showroom visit. */ 3119export const DEFAULT_RESELLER_DURATION_MINUTES = 60; 3120 3121/** The slice of a \`Reseller\` row the duration helpers need (kept structural so 3122 * appointment.ts does not import resellers.ts). */ 3123export interface ResellerDurationSource { 3124 appointmentDurationMinutes?: number; 3125} 3126 3127/** 3128 * Resolve how long an appointment occupies, from its mode + the tenant config. 3129 * Single source of truth for BOTH \`endTimeUtc\` on create AND the overlap-aware 3130 * capacity count, so a showroom block can never be written 60 min long and 3131 * counted 30 min wide. Reseller callers pass the reseller row; phone/showroom 3132 * callers are unchanged. 3133 */ 3134export function appointmentDurationMinutes( 3135 mode: AppointmentMode | undefined, 3136 showroom?: ShowroomConfig | null, 3137 reseller?: ResellerDurationSource | null, 3138): number { 3139 if (mode === 'reseller') { 3140 const r = reseller?.appointmentDurationMinutes; 3141 return typeof r === 'number' && r > 0 ? r : DEFAULT_RESELLER_DURATION_MINUTES; 3142 } 3143 if (mode !== 'showroom') return APPOINTMENT_SLOT_MINUTES; 3144 const d = showroom?.durationMinutes; 3145 return typeof d === 'number' && d > 0 ? d : DEFAULT_SHOWROOM_DURATION_MINUTES; 3146} 3147 3148/** 3149 * One-line address for SMS + UI, e.g. 3150 * "117 York Street, South Melbourne VIC 3205". 3151 * NOTE: contains no dash characters by construction â customer SMS copy must 3152 * stay dash-free (see \`humanizeSmsBody\` in @bigm/shared). 3153 */ 3154export function formatShowroomAddress(cfg: ShowroomConfig): string { 3155 return \`\${cfg.addressLine}, \${cfg.suburb} \${cfg.state} \${cfg.postcode}\`; 3156} 3157 3158// âââ Interval-overlap slot occupancy âââââââââââââââââââââââââââââââââââââââââ 3159// 3160// Capacity used to be counted by EXACT \`startTimeUtc\` match, which is only 3161// correct while every appointment is exactly one slot long. A 60-min showroom 3162// booking at 10:00 is invisible to a 10:30 exact-match check, so the AI could 3163// book a phone consult on top of an agent standing in the showroom. Both the 3164// ShopDash dataApi gate and the BigM engine slot counter consume the helpers 3165// below, so "is this slot full" has ONE definition. 3166 3167/** Half-open interval overlap: [aStart,aEnd) â© [bStart,bEnd) â â . Back-to-back 3168 * bookings (a ends exactly when b starts) do NOT overlap. */ 3169export function intervalsOverlap(aStartMs: number, aEndMs: number, bStartMs: number, bEndMs: number): boolean { 3170 return aStartMs < bEndMs && bStartMs < aEndMs; 3171} 3172 3173/** Minimal appointment shape the overlap helpers need (works on DDB rows). */ 3174export interface SlotOccupancyAppointment { 3175 startTimeUtc: string; 3176 endTimeUtc?: string; 3177 mode?: AppointmentMode; 3178 cancelled?: boolean; 3179} 3180 3181/** 3182 * Does \`appt\` occupy any part of the slot \`[slotStartUtcIso, +slotMinutes)\`? 3183 * 3184 * Uses the row's own \`endTimeUtc\` (authoritative â a showroom row was written 3185 * 60 min long). Rows written before \`endTimeUtc\` existed, or with a corrupt 3186 * end, fall back to start + \`appointmentDurationMinutes(mode, showroom)\` so a 3187 * showroom row is never silently counted as 30 min wide. Cancelled rows never 3188 * occupy anything. 3189 */ 3190export function appointmentOccupiesSlot( 3191 appt: SlotOccupancyAppointment, 3192 slotStartUtcIso: string, 3193 slotMinutes: number = APPOINTMENT_SLOT_MINUTES, 3194 showroom?: ShowroomConfig | null, 3195): boolean { 3196 if (appt.cancelled) return false;
3197 const start = new Date(appt.startTimeUtc).getTime(); 3198 if (isNaN(start)) return false; 3199 const rawEnd = appt.endTimeUtc ? new Date(appt.endTimeUtc).getTime() : NaN; 3200 const end = !isNaN(rawEnd) && rawEnd > start 3201 ? rawEnd 3202 : start + appointmentDurationMinutes(appt.mode, showroom) * 60_000; 3203 3204 const slotStart = new Date(slotStartUtcIso).getTime(); 3205 if (isNaN(slotStart)) return false; 3206 return intervalsOverlap(start, end, slotStart, slotStart + slotMinutes * 60_000); 3207} 3208 3209/** 3210 * Earliest \`startTimeUtc\` that could still overlap the slot starting at 3211 * \`slotStartUtcIso\` â i.e. how far BACK a \`startTimeUtc\` range query must reach 3212 * to see a long booking that began before the slot. Query 3213 * \`startTimeUtc BETWEEN <this> AND <slot end>\` then filter with 3214 * \`appointmentOccupiesSlot\`; the exact-match query it replaces would miss them. 3215 */ 3216export function overlapQueryFromUtc( 3217 slotStartUtcIso: string, 3218 maxAppointmentMinutes: number, 3219): string { 3220 return new Date(new Date(slotStartUtcIso).getTime() - maxAppointmentMinutes * 60_000).toISOString(); 3221} 3222 3223/** The longest appointment a tenant can book â the back-reach for overlap 3224 * queries. Max of one slot, the tenant's showroom duration, and (when the 3225 * tenant books reseller visits) the longest reseller duration in play. 3226 * Callers that don't book resellers are unchanged. */ 3227export function maxAppointmentMinutes( 3228 showroom?: ShowroomConfig | null, 3229 longestResellerMinutes?: number, 3230): number { 3231 return Math.max( 3232 APPOINTMENT_SLOT_MINUTES, 3233 appointmentDurationMinutes('showroom', showroom), 3234 typeof longestResellerMinutes === 'number' && longestResellerMinutes > 0 3235 ? longestResellerMinutes 3236 : 0, 3237 ); 3238} 3239`,Ee=`/** 3240 * Backfill Checkpoint Types 3241 * 3242 * Generic checkpoint tracking for all providers (Shopify, Wicked, Zoho, etc.) 3243 * Used to track progress of data backfill operations across tenants. 3244 * 3245 * Table: backfillCheckpoint 3246 * PK: {provider}#{tenantId} 3247 * SK: {dataType} 3248 */ 3249 3250/** 3251 * Supported backfill providers 3252 */ 3253export type BackfillProvider = 'shopify' | 'wicked' | 'zoho' | 'meta'; 3254 3255/** 3256 * Supported data types for backfill 3257 */ 3258export type BackfillDataType = 3259 | 'customers' 3260 | 'orders' 3261 | 'messages' 3262 | 'abandonedCheckouts' 3263 | 'leads' 3264 | 'contacts' 3265 | 'images' 3266 | 'assets' 3267 | 'global'; 3268 3269/** 3270 * Backfill status values 3271 */ 3272export type BackfillStatus = 'running' | 'paused' | 'completed' | 'failed'; 3273 3274/** 3275 * Backfill checkpoint record stored in DynamoDB 3276 */ 3277export interface BackfillCheckpoint { 3278 /** Partition key: {provider}#{tenantId} */ 3279 pk: string; 3280 3281 /** Sort key: {dataType} */ 3282 sk: string; 3283 3284 /** Provider identifier (shopify, wicked, zoho) */ 3285 provider: BackfillProvider; 3286 3287 /** Tenant identifier (e.g., shop domain for Shopify) */ 3288 tenantId: string; 3289 3290 /** Type of data being backfilled */ 3291 dataType: BackfillDataType; 3292 3293 /** ISO 8601 timestamp of last processed record */ 3294 lastProcessedTimestamp?: string; 3295 3296 /** ID of last processed item (for pagination resume) */ 3297 lastProcessedId?: string; 3298 3299 /** Total count of items processed */ 3300 totalProcessed: number; 3301 3302 /** Count of items fetched from source */ 3303 fetched?: number; 3304 3305 /** Count of items written to DynamoDB */ 3306 written?: number; 3307 3308 /** Count of items skipped (already exist) */ 3309 skipped?: number; 3310 3311 /** Count of items that errored */ 3312 errored?: number; 3313 3314 /** Number of Lambda invocations for this checkpoint */ 3315 invocationCount?: number; 3316 3317 /** Current status of the backfill */ 3318 status?: BackfillStatus; 3319 3320 /** ISO 8601 timestamp when checkpoint was created */ 3321 createdAt: string; 3322 3323 /** ISO 8601 timestamp when checkpoint was last updated */ 3324 updatedAt: string; 3325} 3326 3327/** 3328 * Input for creating/updating a checkpoint (excludes auto-generated fields) 3329 */ 3330export type BackfillCheckpointInput = Omit< 3331 BackfillCheckpoint, 3332 'pk' | 'sk' | 'createdAt' | 'updatedAt' 3333>; 3334 3335/** 3336 * Key components for building checkpoint keys 3337 */ 3338export interface BackfillCheckpointKey { 3339 provider: BackfillProvider; 3340 tenantId: string; 3341 dataType: BackfillDataType; 3342} 3343 3344/** 3345 * Build the partition key for a checkpoint 3346 */ 3347export function buildCheckpointPk(provider: string, tenantId: string): string { 3348 return \`\${provider}#\${tenantId}\`; 3349} 3350 3351/**
3352 * Build the sort key for a checkpoint 3353 */ 3354export function buildCheckpointSk(dataType: string): string { 3355 return dataType; 3356} 3357 3358/** 3359 * Parse a partition key back into provider and tenantId 3360 */ 3361export function parseCheckpointPk(pk: string): { provider: string; tenantId: string } { 3362 const [provider, ...tenantParts] = pk.split('#'); 3363 return { 3364 provider, 3365 tenantId: tenantParts.join('#'), // Handle tenant IDs that might contain # 3366 }; 3367} 3368`,xe=`/** 3369 * Brand Profile Types 3370 * 3371 * A per-tenant brand profile: the strongly-typed design tokens + voice + taxonomy 3372 * that make a manager/QA agent brand-aware. Stored (NOT in this table) as an 3373 * AI-friendly markdown doc with YAML frontmatter at: 3374 * s3://bigm-knowledge/tenants/<tenantId>/brand-profile.md 3375 * 3376 * The YAML frontmatter is exactly the \`BrandProfile\` shape below; the markdown 3377 * body is a short brand brief the LLM reads. Serialize/parse helpers live in the 3378 * agent-runner (the producer/consumer of this artifact) â this module is the 3379 * dependency-free type contract shared by all producers + consumers (resolver, 3380 * agent-runner injection, ShopDash, verifiers). 3381 * 3382 * Identifiers already on the \`tenants\` DDB item (baseUrl, brandSlug, logoUrl, 3383 * contactInfo) are NOT duplicated here â the profile holds only what the tenant 3384 * config does not already carry. 3385 */ 3386 3387/** A named palette plus the role assignments the QA rubric checks against. */ 3388export interface BrandPalette { 3389 /** Named brand colours, e.g. { masseuseBlue: "#1ea0b9", daintree: "#002d32" }. Hex strings. */ 3390 named: Record<string, string>; 3391 /** Role assignments used by the QA rubric / theme tokens. Hex strings. */ 3392 roles: { 3393 primaryButton: string; 3394 darkSection: string; 3395 background: string; 3396 /** Optional accent used for eyebrows / secondary CTAs. */ 3397 accent?: string; 3398 }; 3399} 3400 3401export interface BrandFonts { 3402 /** Headline font family (CSS family name), e.g. "Helvetica Now Dis
3402play". */ 3403 heading: string; 3404 /** Body font family. */ 3405 body: string; 3406} 3407 3408/** Lightweight live taxonomy snapshot (handle + title only) for grounding the agent. */ 3409export interface BrandTaxonomy { 3410 collections: Array<{ handle: string; title: string }>; 3411 products: Array<{ handle: string; title: string }>; 3412} 3413 3414export type BrandExtractionConfidence = 'manual' | 'derived' | 'low'; 3415 3416/** 3417 * The canonical brand profile. Lives as YAML frontmatter in the S3 markdown doc. 3418 */ 3419export interface BrandProfile { 3420 /** Schema version for forward-compat. */ 3421 schemaVersion: 1; 3422 /** Tenant id (shop domain) this profile belongs to. */ 3423 tenant: string; 3424 /** Human brand name, e.g. "Masseuse Health Co.". */ 3425 brandName: string; 3426 3427 palette: BrandPalette; 3428 fonts: BrandFonts; 3429 /** Brand tagline, e.g. "Live clearer, recover faster.". */ 3430 tagline?: string; 3431 /** Voice adjectives, e.g. ["modern","sophisticated","minimal","premium"]. */ 3432 voice: string[]; 3433 /** Aspirational/benchmark competitor hosts, e.g. ["revelsaunas.com.au"]. */ 3434 competitors: string[]; 3435 /** Storefront path the consult/contact CTAs link to, e.g. "/pages/book-a-consult". */ 3436 consultPath?: string; 3437 3438 /** Live taxonomy snapshot for the store this profile key represents. */ 3439 productTaxonomy: BrandTaxonomy; 3440 3441 /** S3 keys of source guideline docs (PDFs etc.) under the tenant's knowledge prefix. */ 3442 guidelineDocs: string[]; 3443 /** The store the tokens/taxonomy were sourced from (prod vs dev). */ 3444 sourceStore: string; 3445 /** How the design tokens were obtained. */ 3446 extractionConfidence: BrandExtractionConfidence; 3447 /** ISO timestamp the profile was last built. */ 3448 extractedAt: string; 3449} 3450`,De=`/** 3451 * Business Events â system-signal substrate 3452 * 3453 * Greenfield substrate alongside customer events for AI-derived signals, 3454 * alarm transitions, and lifecycle events that drive workflows (push, 3455 * triage, escalate, audit). Distinct from \`event-customer\` which is the 3456 * customer-touchpoint audit log. 3457 * 3458 * Two tables back this domain: 3459 * - \`business-events\` â the signal rows (this is the audit + triage queue) 3460 * - \`business-workflow-rules\` â the rules the engine matches against signals 3461 * 3462 * Both tables, the EventBridge pipe, the SQS queue, and the workflow engine 3463 * Lambda are independent of the customer-events pipeline. See 3464 * \`~/.claude/plans/there-is-a-concept-breezy-lightning.md\`. 3465 */ 3466 3467// ============================================================================ 3468// Enum types â canonical, referenced by all producers + consumers 3469// ============================================================================ 3470 3471export type BusinessEventSource = 3472 | 'expert-analysis' // Phase 3 producer 3473 | 'agent-alarm-evaluator' // Phase 2 producer 3474 | 'change-lifecycle-emitter' // Phase 3 producer (DDB stream consumer) 3475 | 'cloudwatch-bridge' // Phase 5 producer (SNS subscriber) 3476 | 'metric-threshold-evaluator'; // future / deferred â reserved now to avoid enum churn 3477 3478export type BusinessEventSignalKind = 3479 // expert-analysis 3480 | 'expert_analysis_completed' 3481 // agent-alarm-evaluator 3482 | 'agent_alarm_triggered' 3483 // change-lifecycle-emitter (one per status transition) 3484 | 'change_proposed' 3485 | 'change_applied' 3486 | 'change_succeeded' 3487 | 'change_failed' 3488 | 'change_closed' 3489 | 'change_reverted' 3490 // cloudwatch-bridge 3491 | 'cloudwatch_alarm_triggered' 3492 | 'cloudwatch_alarm_resolved' 3493 // future (metric-threshold-evaluator) â reserved 3494 | 'topic_threshold_breached'; 3495 3496export type BusinessEventSeverity = 'info' | 'warn' | 'critical'; 3497 3498/** 3499 * What kind of entity the signal is about. Powers entity drill-down filters. 3500 * 3501 * 'lead' deliberately not included â no v1 producer needs it. Add when a 3502 * concrete signal-about-a-lead emerges. 3503 */ 3504export type SubjectKind = 3505 | 'tenant' 3506 | 'agent' 3507 | 'campaign' 3508 | 'meta_ad' 3509 | 'template' 3510 | 'lambda' 3511 | 'queue' 3512 | 'pipe' 3513 | 'change' 3514 | 'analysis'; 3515 3516/** 3517 * Sourced from existing Cognito \`custom:role\` values. No invention. 3518 */ 3519export type TenantRole = 3520 | 'app_admin' 3521 | 'tenant_admin' 3522 | 'tenant_sales' 3523 | 'tenant_marketing' 3524 | 'tenant_operations' 3525 | 'tenant_service'; 3526 3527export type BusinessEventAckStatus = 'open' | 'acknowledged' | 'resolved'; 3528 3529// ============================================================================ 3530// Sub-interfaces for the BusinessEvent row 3531// ============================================================================ 3532 3533export interface BusinessEventMetric { 3534 name: string; 3535 operator: '>' | '<' | '>=' | '<=' | '=='; 3536 threshold: number; 3537 actualValue: number; 3538 unit: string; // 'ms' | 'count' | 'percent' | 'aud_cents' | etc. 3539 windowMs?: number; // observation window 3540} 3541 3542export interface BusinessEventRef { 3543 analysisId?: string; // expert-analysis-results PK 3544 changeId?: string;
3544 // expert-analysis-changes SK 3545 alarmHistoryPk?: string; // alarm-history table PK (\`tenantId#ruleId\`) 3546 alarmHistorySk?: string; // alarm-history table SK (\`dayKey#triggeredAt\`) 3547 cloudwatchAlarmName?: string; 3548 cloudwatchAlarmArn?: string; 3549} 3550 3551export interface BusinessEventSummary { 3552 title: string; 3553 description: string; 3554 /** Freeform escape hatch for source-specific extras. */ 3555 structured?: Record<string, unknown>; 3556} 3557 3558// ============================================================================ 3559// PushTarget + workflow step shapes 3560// ============================================================================ 3561 3562export interface PushTargetSpecificUsers { 3563 kind: 'specific-users'; 3564 /** Raw cognitoSubs. Bypasses tenant scoping â explicit cross-tenant escape hatch. */ 3565 cognitoSubs: string[]; 3566} 3567 3568export type UserSelection = 3569 | { mode: 'all' } 3570 | { mode: 'subset'; include: string[] }; 3571 3572export interface PushTargetRole { 3573 kind: 'role'; 3574 role: TenantRole; 3575 userSelection: UserSelection; 3576} 3577 3578export type PushTarget = PushTargetSpecificUsers | PushTargetRole; 3579 3580export interface PushPayload { 3581 /** Template string. Supports {{field}} substitution against the BusinessEvent row. */ 3582 title: string; 3583 /** Template string. Supports {{field}} substitution. */ 3584 body: string; 3585 /** Template string. Optional deeplink URL â typically \`https://shopzen.club/business-events/{{id}}\`. */ 3586 deepLink?: string; 3587} 3588 3589export interface PushNotificationStep { 3590 type: 'push_notification'; 3591 target: PushTarget; 3592 payload: PushPayload; 3593} 3594 3595export interface LogStep { 3596 type: 'log'; 3597 level: 'info' | 'warn' | 'error'; 3598 message: string; 3599} 3600 3601/** v1 step set. Future step types (\`escalate\`, \`webhook\`, \`route_to_ai_agent\`, \`monitor\`) added when a concrete workflow demands them. */ 3602export type BusinessWorkflowStep = PushNotificationStep | LogStep; 3603 3604// ============================================================================ 3605// Trigger filter on a workflow rule 3606// ============================================================================ 3607 3608export interface BusinessWorkflowTrigger { 3609 signalKind: BusinessEventSignalKind | BusinessEventSignalKind[]; 3610 severity?: BusinessEventSeverity[]; 3611 source?: BusinessEventSource[]; 3612 subjectKind?: SubjectKind[]; 3613 /** '*' or absent = any tenant; specific = scope to one tenant only. */ 3614 tenantId?: string | '*'; 3615} 3616 3617// ============================================================================ 3618// Main row types 3619// ============================================================================ 3620 3621/** 3622 * One row in the \`business-events\` DynamoDB table. 3623 * 3624 * PK = id (UUID). One GSI: byTenantStatus (PK=tenantId, SK=ackStatus#createdAt). 3625 * Stream NEW_AND_OLD_IMAGES. Blanket 180-day TTL via expiresAt. 3626 */ 3627export interface BusinessEvent { 3628 id: string; 3629 /** Real tenant domain or '__platform__' for shared-infra alarms. */ 3630 tenantId: string; 3631 source: BusinessEventSource; 3632 signalKind: BusinessEventSignalKind; 3633 severity: BusinessEventSeverity; 3634 3635 subjectKind: SubjectKind; 3636 subjectId: string; 3637 /** Optional human-readable label. Saves a lookup in workflow steps + UI. */ 3638 subjectLabel?: string; 3639 3640 /** Optional metric details â present when the signal is threshold-driven. */ 3641 metric?: BusinessEventMetric; 3642 3643 /** Back-link to the source-of-truth row. */ 3644 ref: BusinessEventRef; 3645 3646 summary: BusinessEventSummary; 3647 3648 /** Triage state. Defaults to 'open' on insert; mutable by ack/resolve dataApi routes. */ 3649 ackStatus: BusinessEventAckStatus; 3650 ackedBy?: string; // cognitoSub 3651 ackedAt?: string; // ISO 3652 ackNote?: string; 3653 resolvedBy?: string; // cognitoSub 3654 resolvedAt?: string; // ISO 3655 resolveNote?: string; 3656 3657 /** 3658 * Atomic ADD by the engine after each role-targeted push_notification step. 3659 * Powers the UI's "Routed to my role only" filter. 3660 */ 3661 routedToRoles?: TenantRole[]; 3662 3663 createdAt: string; // ISO 3664 /** YYYY-MM-DD partition for time-bucketed analytics. */ 3665 eventDate: string; 3666 3667 /** Epoch seconds. Set on insert as createdAt + 180 days (blanket). */ 3668 expiresAt?: number; 3669} 3670 3671/** 3672 * One row in the \`business-workflow-rules\` DynamoDB table. 3673 * 3674 * PK = ruleId (UUID). One GSI: rulesByTenant (PK=tenantId, SK=updatedAt). 3675 * 3676 * Separate from \`campaign-context\` â clean schema, no customer-flow field 3677 * bloat, independent evolution. Editor lives at \`/workflows/business\`. 3678 */ 3679export interface BusinessWorkflowRule { 3680 ruleId: string; 3681 /** Real tenant domain or '__platform__' for cross-tenant platform rules. */ 3682 tenantId: string; 3683 name: string; 3684 enabled: boolean; 3685 description?: string; 3686 3687 trigger: BusinessWorkflowTrigger; 3688 steps: BusinessWorkflowStep[]; 3689 3690 createdAt: string; 3691 updatedAt: string; 3692 createdBy: string; // cognitoSub 3693 updatedBy: string; // cognitoSub 3694} 3695 3696// ============================================================================ 3697// Input types for create / update operations 3698// ============================================================================ 3699 3700/** Producer-facing create input â system fields stamped at write time. */ 3701export type CreateBusinessEventInput = Omit< 3702 BusinessEvent, 3703 'createdAt' | 'eventDate' | 'expiresAt' | 'ackStatus' 3704> & { 3705 ackStatus?: BusinessEventAckStatus; // defaults to 'open' on insert 3706}; 3707 3708export type CreateBusinessWorkflowRuleInput = Omit< 3709 BusinessWorkflowRule, 3710 'ruleId' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' 3711>; 3712 3713export type UpdateBusinessWorkflowRuleInput = Partial< 3714 Omit<BusinessWorkflowRule, 'ruleId' | 'tenantId' | 'createdAt' | 'createdBy'> 3715>; 3716 3717// ============================================================================ 3718// Type guards 3719// ============================================================================ 3720 3721export function isPushTargetRole(target: PushTarget): target is PushTargetRole { 3722 return target.kind === 'role'; 3723} 3724 3725export function isPushTargetSpecificUsers(target: PushTarget): target is PushTargetSpecificUsers { 3726 return target.kind === 'specific-users'; 3727} 3728 3729export function isPushNotificationStep(step: BusinessWorkflowStep): step is PushNotificationStep { 3730 return step.type === 'push_notification'; 3731} 3732 3733export function isLogStep(step: BusinessWorkflowStep): step is LogStep { 3734 return step.type === 'log'; 3735} 3736 3737export function isChangeLifecycleSignal(signalKind: BusinessEventSignalKind): boolean { 3738 return signalKind.startsWith('change_'); 3739} 3740 3741export function isCloudWatchSignal(signalKind: BusinessEventSignalKind): boolean { 3742 return signalKind.startsWith('cloudwatch_alarm_'); 3743} 3744 3745// ============================================================================ 3746// Constants 3747// ============================================================================ 3748 3749/** 3750 * Sentinel tenantId for shared-infra alarms (CloudWatch alarms on Lambdas, 3751 * queues, pipes that aren't tenant-scoped). Workflows targeting these signals 3752 * use \`role: 'app_admin'\` to notify platform operators. 3753 */ 3754export const PLATFORM_TENANT_ID = '__platform__'; 3755 3756/** Atomic value all producers stamp on emission. */ 3757export const BUSINESS_EVENT_ACTOR = 'workflow_automated' as const; 3758 3759/** Default TTL for new rows (180 days from createdAt). */ 3760export const BUSINESS_EVENT_TTL_SECONDS = 180 * 24 * 60 * 60; 3761 3762export const BUSINESS_EVENT_SEVERITIES: readonly BusinessEventSeverity[] = [ 3763 'info', 3764 'warn', 3765 'critical', 3766] as const; 3767 3768export const TENANT_ROLES: readonly TenantRole[] = [ 3769 'app_admin', 3770 'tenant_admin', 3771 'tenant_sales', 3772 'tenant_marketing', 3773 'tenant_operations', 3774] as const; 3775 3776export const BUSINESS_EVENT_SOURCES: readonly BusinessEventSource[] = [ 3777 'expert-analysis', 3778 'agent-alarm-evaluator', 3779 'change-lifecycle-emitter', 3780 'cloudwatch-bridge', 3781 'metric-threshold-evaluator', 3782] as const; 3783`,Pe=`/** 3784 * Campaign Approval Type Definitions 3785 * 3786 * Based on Terraform schema: terraform/SHARED/create-dynamo-campaign-approvals 3787 * 3788 * Campaign-approvals table - Stores approval records for campaigns requiring approval 3789 * PRIMARY KEY = approvalId (hash key only) 3790 * TIMESTAMPS = createdAt, updatedAt, lastEligibilityCheck, approvedAt, rejectedAt 3791 * 3792 * GSIs: 3793 * - approvalsByCampaign: Query all approvals for a campaign (sorted by createdAt) 3794 * - approvalsByLead: Query all approvals for a lead (sorted by createdAt) 3795 * - approvalsByStatus: Query all approvals by status (sorted by createdAt) 3796 * - approvalsByTenant: Query all approvals for a tenant (sorted by createdAt) 3797 */ 3798 3799/** 3800 * Campaign approval status values 3801 */ 3802export type CampaignApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired' | 'completed' | 'ineligible'; 3803 3804/** 3805 * Eligibility reason codes for abandoned cart checkouts 3806 */ 3807export type EligibilityReasonCode = 3808 | 'ELIGIBLE' // Passed all checks 3809 | 'DELAY_NOT_MET' // Checkout too recent (within delay window) 3810 | 'ALREADY_SENT' // Campaign message already sent to this lead 3811 | 'ANY_CAMPAIGN_SENT' // Any campaign was sent to this lead recently 3812 | 'NO_LEAD_ID' // Checkout has no associated lead 3813 | 'RECOVERED' // Customer completed order after checkout 3814 | 'NO_CAMPAIGN' // No active campaign found 3815 | 'DRAFT_ORDER_SOURCE' // Checkout originated from a draft order (not a real abandoned cart) 3816 | 'NO_LINE_ITEM_MATCH' // Cart doesn't contain items matching configured line item discounts 3817 | 'HAS_OUTBOUND_SMS' // Lead has prior outbound SMS events 3818 | 'HAS_PRIOR_ORDER' // Lead has prior order.confirmed events 3819 | 'HAS_SUCCESSFUL_CONTACT' // Lead has been successfully contacted by phone 3820 | 'FIRST_LEAD_SOURCE_MISMATCH' // Lead's first source not in allowed channels 3821 | 'LAST_LEAD_SOURCE_MISMATCH' // Lead's last source not in allowed channels 3822 | 'FIRST_EVENT_TYPE_MISMATCH' // Lead's first event type not in allowed types 3823 | 'FORM_ID_MISMATCH' // Triggering event's formId doesn't match filter 3824 | 'UNKNOWN'; // Fallback 3825 3826/** 3827 * Line item in a cart 3828 */ 3829export interface ApprovalCartLineItem { 3830 title: string; 3831 variantTitle?: string; 3832 variantId?: string | number; 3833 quantity: number; 3834 price: string; 3835 compareAtPrice?: string; 3836 sku?: string; 3837} 3838 3839/** 3840 * Customer information for approval 3841 */ 3842export interface ApprovalCustomer { 3843 leadId: string; 3844 name?: string; 3845 email?: string; 3846 phone?: string; 3847} 3848 3849/** 3850 * Cart details for approval 3851 */ 3852export interface ApprovalCart { 3853 checkoutId: string; 3854 checkoutToken?: string; 3855 abandonedCheckoutUrl?: string; 3856 lineItems: ApprovalCartLineItem[]; 3857 totalPrice: string; 3858 subtotalPrice?: string; 3859 totalDiscounts?: string; 3860 currency: string; 3861} 3862 3863/** 3864 * Draft order details for approval 3865 */ 3866export interface ApprovalDraftOrder { 3867 draftOrderId?: number | string; 3868 invoiceUrl: string; 3869 /** Actual draft order line items from Shopify (populated after creation) */ 3870 lineItems?: ApprovalCartLineItem[]; 3871 /** Actual draft order total price from Shopify */ 3872 totalPrice?: string; 3873 /** Actual draft order subtotal from Shopify */ 3874 subtotalPrice?: string; 3875 /** Currency code */ 3876 currency?: string; 3877} 3878 3879/** 3880 * Discount calculation details for approval 3881 */ 3882export interface ApprovalDiscount { 3883 totalSavings: number | null; 3884 discountPercent?: number; 3885 discountCode?: string; 3886 productName: string;
3887 freeItemsAdded: string[]; 3888} 3889 3890/** 3891 * Pre-generated SMS message for approval 3892 */ 3893export interface ApprovalSmsMessage { 3894 to: string; 3895 body: string; 3896 /** 3897 * Pre-rendered MMS media URL(s) â typically a single S3 URL pointing to the 3898 * order-summary card PNG produced by the mms-card-renderer Lambda. When 3899 * present, ShopDash's Approve & Send path attaches it to the outbound SMS so 3900 * the customer receives the same MMS the engine's auto-send path would have. 3901 */ 3902 mediaUrl?: string[]; 3903} 3904 3905/** 3906 * Pre-generated email message for approval 3907 * 3908 * When \`templateId\` + \`placeholders\` are set, the modal renders preview by 3909 * fetching the template from S3 and substituting placeholders, and the send 3910 * path passes templateId + placeholders to /toolcall/send-email so the API 3911 * does the canonical render â same path as the engine's auto-send. 3912 * 3913 * \`htmlBody\` is retained for backwards compatibility with older approval rows 3914 * (rendered without full placeholder set). 3915 */ 3916export interface ApprovalEmailMessage { 3917 to: string; 3918 subject: string; 3919 textBody: string; 3920 /** Rendered HTML body - may be omitted if templateId+placeholders are set */ 3921 htmlBody?: string; 3922 /** S3 template reference - fetch and render on demand */ 3923 templateId?: string; 3924 /** Tenant ID for S3 template path (required when templateId is set) */ 3925 templateTenantId?: string; 3926 /** 3927 * Full placeholder set for the S3 template, computed at approval-write time 3928 * by the engine. Includes name, link, productName, savings, discountCode AND 3929 * the order-summary card blobs (lineItemsHtml, orderDiscountHtml,
3930 * savingsHeroHtml, paymentOptionsBlock, originalTotal, subtotal, total, 3931 * storeDomain, â¦). When this is present, callers MUST pass templateId + 3932 * placeholders to /toolcall/send-email rather than pre-rendering HTML, so 3933 * the API can do the canonical S3-template render with asset-URL resolution. 3934 */ 3935 placeholders?: Record<string, string>; 3936} 3937 3938/** 3939 * Pre-generated messages for approval 3940 */ 3941export interface ApprovalMessages { 3942 sms?: ApprovalSmsMessage; 3943 email?: ApprovalEmailMessage; 3944} 3945 3946/** 3947 * Campaign Approval record stored in DynamoDB campaign-approvals table 3948 */ 3949export interface CampaignApproval { 3950 /** Primary key - Unique approval identifier (UUID) */ 3951 approvalId: string; 3952 3953 /** Campaign identifier */ 3954 campaignId: string; 3955 3956 /** Lead identifier */ 3957 leadId: string; 3958 3959 /** Checkout event ID (from events-customer table) */ 3960 checkoutId: string; 3961 3962 /** Checkout token (Shopify checkout token) */ 3963 checkoutToken?: string; 3964 3965 /** Tenant identifier */ 3966 tenantId: string; 3967 3968 /** Approval status */ 3969 status: CampaignApprovalStatus; 3970 3971 /** Full campaign context snapshot at time of creation */ 3972 campaignDetails: Record<string, unknown>; 3973 3974 /** Lead details snapshot at time of creation */ 3975 leadDetails: { 3976 id: string; 3977 phoneNumber?: string; 3978 emailAddress?: string; 3979 name?: string; 3980 tenantName?: string; 3981 [key: string]: unknown; 3982 }; 3983 3984 /** Checkout details snapshot at time of creation */ 3985 checkoutDetails: { 3986 id: string; 3987 eventType: string; 3988 eventData?: Record<string, unknown>; 3989 createdAt: string; 3990 [key: string]: unknown; 3991 }; 3992 3993 /** Eligibility snapshot - why this lead is eligible */ 3994 eligibilitySnapshot: { 3995 delayMet: boolean; 3996 delayMinutes: number; 3997 checkoutCreatedAt: string; 3998 notRecovered: boolean; 3999 eligibleAt: string; 4000 [key: string]: unknown; 4001 }; 4002 4003 // ============================================ 4004 // NEW: Pre-generated data for approval review 4005 // ============================================ 4006 4007 /** Customer information (structured for display) */ 4008 customer?: ApprovalCustomer; 4009 4010 /** Cart details (line items, prices) */ 4011 cart?: ApprovalCart; 4012 4013 /** Draft order (if created) */ 4014 draftOrder?: ApprovalDraftOrder; 4015 4016 /** Discount calculation */ 4017 discount?: ApprovalDiscount; 4018 4019 /** Pre-generated messages (SMS and email) */ 4020 messages?: ApprovalMessages; 4021 4022 /** CloudFront tracking link */ 4023 trackingLink?: string; 4024 4025 // ============================================ 4026 // Eligibility tracking fields (for ineligible records) 4027 // ============================================ 4028 4029 /** Whether this checkout passed all eligibility checks */ 4030 eligible?: boolean; 4031 4032 /** Eligibility reason code (machine-readable) */ 4033 eligibilityReasonCode?: EligibilityReasonCode; 4034 4035 /** Human-readable eligibility reason */ 4036 eligibilityReason?: string; 4037 4038 // ============================================ 4039 4040 /** UTC timestamp when record was created (ISO 8601) */ 4041 createdAt: string; 4042 4043 /** UTC timestamp when record was last updated (ISO 8601) */ 4044 updatedAt: string; 4045 4046 /** UTC timestamp when eligibility was last checked (ISO 8601) */ 4047 lastEligibilityCheck: string; 4048 4049 /** UTC timestamp when approval was granted (ISO 8601, optional) */ 4050 approvedAt?: string; 4051 4052 /** User ID who approved (optional) */ 4053 approvedBy?: string; 4054 4055 /** UTC timestamp when approval was rejected (ISO 8601, optional) */ 4056 rejectedAt?: string; 4057 4058 /** User ID who rejected (optional) */ 4059 rejectedBy?: string; 4060 4061 /** Reason for rejection (optional) */ 4062 rejectionReason?: string; 4063} 4064 4065/** 4066 * Campaign Approval creation input (omits auto-generated fields) 4067 */ 4068export interface CreateCampaignApprovalInput { 4069 campaignId: string; 4070 leadId: string; 4071 checkoutId: string; 4072 checkoutToken?: string; 4073 tenantId: string; 4074 status?: CampaignApprovalStatus; 4075 campaignDetails: Record<string, unknown>; 4076 leadDetails: { 4077 id: string; 4078 phoneNumber?: string; 4079 emailAddress?: string; 4080 name?: string; 4081 tenantName?: string; 4082 [key: string]: unknown; 4083 }; 4084 checkoutDetails: { 4085 id: string; 4086 eventType: string; 4087 eventData?: Record<string, unknown>; 4088 createdAt: string; 4089 [key: string]: unknown; 4090 }; 4091 eligibilitySnapshot: { 4092 delayMet: boolean; 4093 delayMinutes: number; 4094 checkoutCreatedAt: string; 4095 notRecovered: boolean; 4096 eligibleAt: string; 4097 [key: string]: unknown; 4098 }; 4099} 4100 4101/** 4102 * Campaign Approval update input (only updatable fields) 4103 */
4104export interface UpdateCampaignApprovalInput { 4105 status?: CampaignApprovalStatus; 4106 updatedAt?: string; 4107 lastEligibilityCheck?: string; 4108 approvedAt?: string; 4109 approvedBy?: string; 4110 rejectedAt?: string; 4111 rejectedBy?: string; 4112 rejectionReason?: string; 4113} 4114 4115/** 4116 * Campaign Approval attributes used in GSI queries 4117 */ 4118export interface CampaignApprovalGSIAttributes { 4119 /** For approvalsByCampaign GSI */ 4120 campaignId: string; 4121 createdAt: string; 4122 4123 /** For approvalsByLead GSI */ 4124 leadId: string; 4125 4126 /** For approvalsByStatus GSI */ 4127 status: CampaignApprovalStatus; 4128 4129 /** For approvalsByTenant GSI */ 4130 tenantId: string; 4131} 4132 4133`,Re=`/** 4134 * Campaign Audience Types 4135 * 4136 * Types for backbook campaign audience filtering, preview, and batch execution. 4137 * Used by the campaign-batch-executor Lambda and dataApi audience routes. 4138 */ 4139 4140import type { LeadSourceChannel, LeadCategory, DncStatus, LockType, Lead } from './lead.js'; 4141import type { EventCustomerType, EventActor } from './event-customer.js'; 4142import type { CampaignChannel } from './campaign-context.js'; 4143 4144// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4145// AUDIENCE FILTER TYPES 4146// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4147 4148/** 4149 * Campaign audience filter - defines which leads to target. 4150 * Uses a two-phase query architecture: 4151 * Phase 1: Lead-level filters (Lead table GSI queries + FilterExpressions) 4152 * Phase 2: Event-level filters (events-customer cross-queries per lead) 4153 */ 4154export interface CampaignAudienceFilter { 4155 // ââ REQUIRED ââ 4156 tenantId: string; 4157 4158 // ââ LEAD-LEVEL FILTERS (Phase 1 - Lead table query) ââ 4159 4160 // Date filters 4161 /** Only leads created after this date (ISO 8601) */ 4162 createdAfter?: string; 4163 /** Only leads created before this date (ISO 8601) */ 4164 createdBefore?: string; 4165 /** 4166 * ROLLING lead-age floor: only leads CREATED more than N days ago. Resolved 4167 * by the batch executor at fire time to \`createdBefore = now - N days\`, so a 4168 * recurring campaign keeps picking up leads as they age past the threshold 4169 * (e.g. 2 = the 48h-uncontacted opener). If both this and the static 4170 * \`createdBefore\` are set, the resolved rolling value wins. Applied as a 4171 * FilterExpression on \`createdAt\` (the GSI SK is lastEventAt). 4172 */ 4173 createdBeforeDays?: number; 4174 /** Only leads active since this date (ISO 8601) */ 4175 lastEventAfter?: string; 4176 /** Only leads inactive since this date - for lapsed/win-back (ISO 8601) */ 4177 lastEventBefore?: string; 4178 /** 4179 * ROLLING quiet window: only leads whose last customer event is OLDER than N 4180 * days (i.e. no customer event in the last N days). Resolved by the batch 4181 * executor at fire time to \`lastEventBefore = now - N days\`, so it stays 4182 * correct on every recurring run (unlike the static \`lastEventBefore\` date). 4183 * If both this and \`lastEventBefore\` are set, the resolved rolling value wins. 4184 * Reads the \`leadsByTenantLastEvent\` GSI SK â no events-customer scan. 4185 */ 4186 lastEventBeforeDays?: number; 4187 /** 4188 * ROLLING active window: only leads whose last customer event is WITHIN the 4189 * last N days. Resolved at fire time to \`lastEventAfter = now - N days\`. 4190 */ 4191 lastEventAfterDays?: number; 4192 /** 4193 * ROLLING lead-age CEILING: only leads CREATED within the last N days. 4194 * Resolved at fire time to \`createdAfter = now - N days\`; wins over the 4195 * static \`createdAfter\`. Pair with \`createdBeforeDays\` for a sliding age 4196 * window â \`{ createdAfterDays: 120, createdBeforeDays: 2 }\` = "leads aged 4197 * between 2 and 120 days", where leads age in at day 2 and age out 4198 * permanently at day 120. 4199 */ 4200 createdAfterDays?: number; 4201 4202 // ââ PHASE-1 QUERY SHAPE ââ 4203 /** 4204 * Which Lead GSI drives the Phase-1 query â i.e. what the audience is KEY 4205 * RANGED and ORDERED by. 4206 * 4207 * 'lastEvent' (DEFAULT) â \`leadsByTenantLastEvent\` (SK lastEventAt). 4208 * Legacy behaviour; created-date bounds are 4209 * FilterExpressions. 4210 * 'createdAt' â \`leadsByCreatedAt\` (SK createdAt). Created-date 4211 * bounds become KEY CONDITIONS. 4212 * 4213 * WHY YOU USUALLY WANT 'createdAt' FOR A DRIP: \`lastEventAt\` is bumped by 4214 * our OWN outbound SMS, so a lead we just texted jumps to the head of a 4215 * descending scan and every later run has to page past it. Measured on 4216 * 2026-08-02, MMC chairs read ~2,623 lead rows per tick to find 4 sendable
4217 * ones. \`createdAt\` is immutable, so a worked lead stays where it is. 4218 * 4219 * â ï¸ Only ONE date pair can be a key range. Selecting 'createdAt' DEMOTES 4220 * \`lastEventAfter\`/\`lastEventBefore\`/\`lastEventBeforeDays\` to 4221 * FilterExpressions â they still filter identically, they just stop bounding 4222 * the read. They are never dropped. 4223 * 4224 * EXPLICIT OPT-IN, NEVER INFERRED â deriving the index from which bounds are 4225 * present would silently flip every live campaign on deploy. 4226 */ 4227 audienceIndex?: 'lastEvent' | 'createdAt'; 4228 /** 4229 * Sort direction within the chosen index. 'newest' (DEFAULT) is the 4230 * historical hardcoded \`ScanIndexForward: false\`. 4231 * 4232 * On the 'createdAt' index: 'newest' works the freshest eligible leads first 4233 * (highest intent, and the head of the scan is where unworked leads are); 4234 * 'oldest' drains the back catalogue in arrival order but must page past 4235 * everything already worked, so pair it with \`createdAfterDays\` to bound the 4236 * window. 4237 */ 4238 audienceOrder?: 'newest' | 'oldest'; 4239 4240 /** 4241 * ROLLING campaign cooldown: only leads we have NOT sent a campaign message 4242 * to in the last N days. Resolved at fire time and applied as a lead-row 4243 * FilterExpression against \`Lead.lastCampaignSentAt\`. 4244 * 4245 * A lead with no \`lastCampaignSentAt\` has never been campaigned and PASSES. 4246 * That is both correct and what allows this to be switched on before the 4247 * backfill has finished â an unstamped lead simply behaves as it does today. 4248 * 4249 * This is the platform's first working cross-campaign cooldown. It 4250 * supersedes {@link cooldownDays} / {@link excludeAnyCampaignInDays}, which 4251 * were declared years earlier and never implemented. 4252 */ 4253 notCampaignedInDays?: number; 4254 4255 /** 4256 * PER-CAMPAIGN re-contact window: allow this campaign to contact a lead it 4257 * has ALREADY sent to, once the previous send is more than N days old. 4258 * 4259 * Unset (the default) = today's behaviour: one-and-done, a campaign never 4260 * re-contacts its own past recipients. The batch executor's already-sent 4261 * gate was a bare existence check with no lookback, while the engine's 4262 * \`check_already_sent\` intended a 30-day window (\`lookbackDays || 30\`) â 4263 * the batch gate ran first, so suppression was permanent in practice. This 4264 * field gives the batch gate the window, per-campaign and opt-in. 4265 * 4266 * â ï¸ Only enable on campaigns whose COPY tolerates repetition. The u48h 4267 * openers read as a first hello ("we noticed you enquired") â re-sending 4268 * that at 30 days lands badly. The mechanism ships for all campaigns; 4269 * enabling it is a per-row content decision. 4270 * 4271 * Requires the 2026-08-02 claim-clobber fix: before it, engine claims 4272 * overwrote \`sentAt\` on completed rows and releases deleted them, so a 4273 * window computed from \`sentAt\` would re-open leads early or not at all. 4274 */ 4275 resendAfterDays?: number; 4276 4277 /** 4278 * Hard ceiling on leads YIELDED by the Phase-1 query, applied before 4279 * eligibility runs. Almost always the wrong knob â prefer 4280 * {@link maxEmitsPerRun}, which caps after eligibility and therefore cannot 4281 * starve a drip. Declared here because \`audience-query.ts\` has always read 4282 * it; it was previously absent from this interface and silently resolved to 4283 * \`undefined\` â \`Infinity\`. 4284 */ 4285 maxResults?: number; 4286 4287 // Attribution filters 4288 /** Lead source channels to include (IN filter) */ 4289 leadSourceChannels?: LeadSourceChannel[]; 4290 /** Specific Facebook campaign ID */ 4291 facebookCampaignId?: string; 4292 /** Specific Facebook ad ID */ 4293 facebookAdId?: string; 4294 /** Has Google Ads attribution (gclid) */ 4295 hasGoogleAttribution?: boolean; 4296 /** Specific UTM source */ 4297 utmSource?: string; 4298 /** Specific MaxContact calling list ID */ 4299 maxContactListId?: number; 4300 4301 // Contact filters 4302 /** Must have a phone number */ 4303 requirePhone?: boolean; 4304 /** Must have an email address */ 4305 requireEmail?: boolean; 4306 /** Must have both phone AND email */ 4307 requireBoth?: boolean; 4308 4309 // Status filters 4310 /** Exclude DNC leads - default: true, CANNOT be set to false */ 4311 excludeDnc?: boolean; 4312 /** Exclude false positives - default: true */ 4313 excludeFalsePositives?: boolean; 4314 /** Exclude leads with phone errors - default: true */ 4315 excludePhoneErrors?: boolean; 4316 /** Exclude leads with email errors - default: true */ 4317 excludeEmailErrors?: boolean; 4318 /** Explicit DNC status filter (e.g. previous_dnc only) */ 4319 dncStatus?: DncStatus; 4320 4321 // Category filters 4322 /** Include only these lead categories */ 4323 leadCategories?: LeadCategory[]; 4324 /** Exclude these lead categories */ 4325 excludeCategories?: LeadCategory[]; 4326 4327 // Ownership filters 4328 /** Include only these lock types */ 4329 lockTypes?: LockType[]; 4330 /** Exclude these lock types */ 4331 excludeLockTypes?: LockType[]; 4332 /** 4333 * Exclude leads an agent currently OWNS / is closing / has sold â the durable 4334 * ownership signals, NOT just \`lockType\` (which is cleared after a call even 4335 * while a lead is still buyer-closing). When true the batch executor excludes: 4336 * - currently buyer-closing (firstBuyerCloseUtc set & no bcExitUtc) 4337 * - sold (bcExitReason â {sold, sold_manual}) 4338 * - on-call / agent-owned (lockType â OUTREACH_SUPPRESS_LOCKTYPES, or 4339 * dispositionFlag â OUTREACH_SUPPRESS_DISPOSITIONS) 4340 * Mirrors \`isOutreachSuppressedByOwnership\` in @bigm/shared. Strongly recommended 4341 * on ALL proactive back-book outreach. (2026-06-24 buyer-close mis-send fix.) 4342 */ 4343 excludeAgentOwnedOrClosing?: boolean; 4344 /** Leads owned by a specific agent */ 4345 ownedByAgent?: string; 4346 /** Only leads with no agent ownership */ 4347 unownedOnly?: boolean; 4348 4349 // Shopify filters 4350 /** Must have a Shopify customer ID */ 4351 hasShopifyCustomerId?: boolean; 4352 4353 // Tag filters 4354 /** masterProfile.tags must contain ANY of these */ 4355 includeTags?: string[]; 4356 /** masterProfile.tags must contain NONE of these */ 4357 excludeTags?: string[]; 4358 4359 // LTV filter 4360 /** masterProfile.ltv must be >= this value */ 4361 minLtv?: number; 4362 4363 // Lead-source URL keyword filters (product-line cohort splits) 4364 /** 4365 * Include only leads whose \`leadSource.pageUrl\` OR \`leadSource.landingUrl\` 4366 * contains ANY of these substrings (case-sensitive â use lowercase keywords, 4367 * URLs are lowercase in practice). Leads with neither URL field are excluded. 4368 * Used to split a back-book cohort by product line (e.g. MHC 4369 * ['sauna','everglow','thermapod'] vs ['icebath','ice-bath','niseko','plunge']). 4370 */ 4371 leadSourceUrlIncludes?: string[]; 4372 /** 4373 * Exclude leads whose \`leadSource.pageUrl\` OR \`leadSource.landingUrl\` 4374 * contains ANY of these substrings. Leads with no URL fields pass. 4375 * Combine with \`leadSourceUrlIncludes\` on sibling rows to make cohort 4376 * splits mutually exclusive (e.g. the generic catch-all row excludes both 4377 * keyword sets). 4378 */ 4379 leadSourceUrlExcludes?: string[]; 4380 4381 // Campaign history filters 4382 //
4383 // â ï¸ THE FOUR FIELDS BELOW HAVE NEVER BEEN IMPLEMENTED. They are declared 4384 // here and read by NOTHING in BigM â not the batch executor's audience 4385 // query, not its eligibility checks, not the execution engine. ShopDash 4386 // nonetheless renders \`cooldownDays\` as an active rule chip (defaulting to 4387 // 7) and seven campaign templates set it, so operators have been configuring 4388 // a cooldown that does nothing since the type was written. 4389 // 4390 // They are left inert deliberately: wiring them up under their existing 4391 // names would silently narrow every live campaign already carrying them the 4392 // moment the code deployed. Use \`notCampaignedInDays\` instead. 4393 4394 /** @deprecated Never implemented. No replacement â per-campaign suppression 4395 * is handled by \`check_already_sent\` in the eligibility checks. */ 4396 excludeAlreadySentCampaignId?: string; 4397 /** @deprecated Never implemented. Use {@link notCampaignedInDays}. */ 4398 excludeAnyCampaignInDays?: number; 4399 /** @deprecated Never implemented, despite being surfaced in the ShopDash 4400 * audience panel. Use {@link notCampaignedInDays}. */ 4401 cooldownDays?: number; 4402 /** @deprecated Never implemented. No replacement yet. */ 4403 requireSentCampaignId?: string; 4404 4405 // ââ EVENT-LEVEL FILTERS (Phase 2 - events-customer cross-query) ââ 4406 4407 /** Event-based filters (requires cross-table query) */ 4408 eventFilters?: EventCohortFilter[]; 4409 4410 // ââ DRIP CAP (eligibility-aware emit cap) ââ 4411 /** 4412 * Maximum number of leads to actually emit to the engine per run. 4413 * Skipped leads (DNC, already-sent, cooldown, event-scan failures) do NOT 4414 * count toward this cap â only successful emits do. 4415 * 4416 * Use this to drip a large cohort over multiple cron firings: 4417 * maxEmitsPerRun: 200 + notCampaignedInDays: 30 â ~200 fresh leads/day 4418 * until drained, then the cohort re-arms as the cooldown window elapses. 4419 * (This example previously cited \`cooldownDays\`, which has never been 4420 * implemented â see the campaign-history block above.) 4421 * 4422 * Different from maxResults (which caps QUERY yields before eligibility runs): 4423 * maxResults can starve a drip when the live query keeps returning the same 4424 * already-sent leads at the top of the lastEventAt sort. maxEmitsPerRun caps 4425 * after eligibility, so each run finds NEW fresh leads even when previous 4426 * runs sent to leads earlier in the sort order. 4427 */ 4428 maxEmitsPerRun?: number; 4429} 4430 4431/** 4432 * Individual event-based filter condition. 4433 * Applied during Phase 2 by querying events-customer per lead. 4434 */ 4435export interface EventCohortFilter { 4436 /** What type of filter */ 4437 type: 4438 | 'has_event' // Lead HAS event(s) matching criteria 4439 | 'missing_event' // Lead does NOT have event(s) matching criteria 4440 | 'event_without_followup' // Event X exists without event Y after it 4441 | 'event_count' // Count of matching events meets threshold 4442 | 'time_since_event'; // Time since last matching event meets threshold 4443 4444 /** Event types to match */ 4445 eventTypes: EventCustomerType[]; 4446 4447 /** Date range for events (optional - defaults to all time) */ 4448 eventAfter?: string; // ISO 8601 4449 eventBefore?: string; // ISO 8601 4450 4451 /** For 'event_count': minimum number of matching events */ 4452 minCount?: number; 4453 4454 /** For 'time_since_event': minimum days since last matching event */ 4455 minDaysSince?: number; 4456 /** For 'time_since_event': maximum days since last matching event */ 4457 maxDaysSince?: number; 4458 4459 /** For 'event_without_followup': the follow-up event type(s) that should NOT exist */ 4460 relativeToEventTypes?: EventCustomerType[]; 4461 4462 /** 4463 * For 'has_event' / 'event_count' on call events â gate on the call's 4464 * recorded duration. Compared against \`eventData.duration\` (ms) and 4465 * \`eventData.talkTimeMs\` (fallback) on the candidate event. Lead passes only 4466 * if at least one matching event has duration ⥠minDurationMs. 4467 */ 4468 minDurationMs?: number; 4469 4470 /** 4471 * For call event filters â count a call ONLY if an agent actually had a real 4472 * conversation with the lead (classification-first, talkTime fallback): 4473 * callClassification.state â {Answered, Screened - Answered}, OR 4474 * (no callClassification â e.g. not backfilled) AND eventData.talkTime > 30s.
4475 * Mirrors \`isRealAgentConversation\` in @bigm/shared. So \`missing_event [call 4476 * types] { conversationOnly: true }\` = "never had a real agent conversation" 4477 * (a dialed-but-never-answered call does NOT exclude the lead). Aircall 4478 * events are matched via \`eventData.duration\` > 30s with no string 4479 * \`missedCallReason\` (they never carry talkTime or a classification â 4480 * the 2026-07-30 cold-lead blind spot). 4481 */ 4482 conversationOnly?: boolean; 4483 4484 /** 4485 * For 'has_event' / 'missing_event' on product events â narrow the match to 4486 * specific Shopify product IDs (as strings, even if stored as numbers in DDB). 4487 * Compared against \`eventData.product.productId\` on each candidate event. 4488 * Useful for "viewed chair X" / "never bought chair X" filters. 4489 */ 4490 productIds?: string[]; 4491 4492 /** 4493 * Narrow the match to events whose \`eventData.actor\` is in this list. 4494 * \`missing_event ['outbound_sms'] { actors: ['agent_physical'] }\` = "no HUMAN 4495 * agent has ever texted this lead" â automated workflow/AI sends 4496 * (workflow_automated / agent_ai) do not count. Events written before actor 4497 * stamping existed have no \`eventData.actor\` and never match an actors-scoped 4498 * filter. 4499 */ 4500 actors?: EventActor[]; 4501 4502 /** 4503 * Narrow the match to events attributed to one of these campaigns 4504 * (\`eventData.campaign.campaignId\`). \`missing_event ['outbound_sms'] 4505 * { campaignIds: [...] }\` = "never texted by any of THESE campaigns" â use it 4506 * to mirror an engine-side campaign exclusion into the audience, so a lead 4507 * the engine will refuse is never emitted (prevents 1-emit-per-run 4508 * starvation on the same released ineligible lead every tick). 4509 */ 4510 campaignIds?: string[]; 4511} 4512 4513// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4514// AUDIENCE PREVIEW TYPES 4515// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4516 4517/** 4518 * Audience preview result - returned by the preview endpoint. 4519 * Shows total count, sample leads, and breakdowns. 4520 */ 4521export interface AudiencePreviewResult { 4522 /** Total matching leads */ 4523 totalLeads: number; 4524 /** Sample leads for preview (first 20) */ 4525 sampleLeads: Lead[]; 4526 /** Channel availability breakdown */ 4527 channelBreakdown: { 4528 hasPhone: number; 4529 hasEmail: number; 4530 hasBoth: number; 4531 hasNeither: number; 4532 }; 4533 /** Leads by source channel */ 4534 sourceBreakdown: Partial<Record<LeadSourceChannel, number>>; 4535 /** Leads by category */ 4536 categoryBreakdown: Partial<Record<LeadCategory, number>>; 4537} 4538 4539// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4540// CAMPAIGN EXECUTION TYPES 4541// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4542 4543/** 4544 * Campaign execution status - stored on campaign-context record. 4545 */ 4546export type CampaignExecutionStatus = 4547 | 'draft' // Saved but not executed 4548 | 'scheduled' // Scheduled for future 4549 | 'running' // Currently executing 4550 | 'completed' // All leads processed 4551 | 'failed'; // Execution failed 4552 4553/** 4554 * Campaign execution progress - stored on campaign-context record. 4555 * Updated periodically during batch execution. 4556 */ 4557export interface CampaignExecutionProgress { 4558 campaignId: string; 4559 status: CampaignExecutionStatus; 4560 totalLeads: number; 4561 processed: number; 4562 sent: number; 4563 skipped: number; 4564 failed: number; 4565 startedAt?: string; 4566 completedAt?: string; 4567 errors: Array<{ leadId: string; error: string; channel: string }>; 4568} 4569 4570/** 4571 * Campaign batch execute request - sent to the batch executor Lambda. 4572 * 4573 * Either \`cohortId\` OR \`audienceFilter\` must be set. When both are present, 4574 * \`cohortId\` wins â the executor loads the cohort row from DynamoDB and 4575 * ignores the inline filter (which is treated as audit/snapshot only). 4576 */ 4577export interface CampaignBatchExecuteRequest { 4578 campaignId: string; 4579 tenantId: string; 4580 /** 4581 * Optional pointer into the \`cohorts\` table. When set, the executor 4582 * resolves the cohort at run time and uses its \`filter\` (with \`tenantId\` 4583 * injected) as the audience filter. Preferred over inline \`audienceFilter\` 4584 * because cohort edits take effect on the next firing without re-baking 4585 * the EventBridge Target Input. 4586 */ 4587 cohortId?: string; 4588 /** @deprecated â pass \`cohortId\` and store the filter in the \`cohorts\` table instead. */ 4589 audienceFilter?: CampaignAudienceFilter; 4590 channels: CampaignChannel[]; 4591 /** Full query + eligibility, returns counts, emits NO events */ 4592 dryRun?: boolean; 4593 /** Sends to first 5 leads with testOverride contacts */ 4594 testMode?: boolean; 4595 /** Required if testMode + SMS channel */ 4596 testPhoneNumber?: string; 4597 /** Required if testMode + email channel */ 4598 testEmailAddress?: string; 4599 /** ISO 8601 - future execution (Phase 2) */ 4600 scheduledAt?: string; 4601 /** 4602 * What kicked off this run â recorded on the campaign-execution-history row. 4603 * - 'cron' = EventBridge cron rule firing (recurring back-book) 4604 * - 'manual' = operator clicked "Execute" in ShopDash 4605 * - 'test' = direct Lambda invoke (CLI / aws-scripts harness) 4606 * Defaults to 'manual' for back-compat with the existing dataApi POST path. 4607 */ 4608 triggerSource?: 'cron' | 'manual' | 'test'; 4609} 4610 4611/** 4612 * Campaign execution history row â one written per batch executor run. 4613 * Append-only audit + funnel record. Stored in \`campaign-execution-history\`. 4614 * 4615 * Operator-facing UI surfaces this as a per-campaign run-history list + 4616 * candidate/emitted/skipped funnel chart over time. 4617 */ 4618export interface CampaignExecutionHistoryRow { 4619 /** PK */ 4620 campaignId: string; 4621 /** SK â ISO 8601 timestamp of run start */ 4622 runStartedAt: string; 4623 /** ISO 8601 of run end (cap-hit or completion) */ 4624 runCompletedAt?: string; 4625 /** Random UUID per run */ 4626 runId: string; 4627 /** GSI byTenantStarted hash key */ 4628 tenantId: string; 4629 /** Final outcome */ 4630 status: 4631 | 'completed' 4632 | 'completed_with_errors' 4633 | 'failed' 4634 | 'cap_hit_emits' 4635 | 'cap_hit_tenant_daily' 4636 | 'cap_hit_phase2' 4637 /** 4638 * Walked the ENTIRE audience and found nothing sendable. A normal end 4639 * state for a drip that has drained its pool â NOT a failure. Previously 4640 * this surfaced as \`cap_hit_phase2\` (a thrown error, zero emits), which 4641 * made "we ran out of leads" indistinguishable from "the filter is broken". 4642 */ 4643 | 'audience_exhausted'; 4644 /** Where the run came from */ 4645 triggerSource: 'cron' | 'manual' | 'test' | 'unknown'; 4646 /** Wall-clock ms */ 4647 durationMs: number; 4648 /** Lead-row Phase-1 match count (before event-scan filter) */ 4649 phase1Candidates: number; 4650 /** Leads after Phase-2 event-scan (before eligibility) */ 4651 totalLeads: number;
4652 /** Successful campaign.execute emits to EventBridge */ 4653 emitted: number; 4654 /** ACTUAL sends in this run's window (derived at read time from campaign-sends; 4655 * NOT stored on the row). sent ⤠emitted â the engine's final checks can drop 4656 * emitted leads before the SMS/email actually goes out. */ 4657 sent?: number; 4658 /** Lead-level skips at eligibility/cap layer */ 4659 skipped: number; 4660 /** Lead-level failures (e.g. EventBridge batch put rejected) */ 4661 failed: number; 4662 /** Breakdown of \`skipped\` by reasonCode (DNC, ALREADY_SENT, â¦) */ 4663 skipReasonCounts?: Record<string, number>; 4664 /** Drip cap hit (audienceFilter.maxEmitsPerRun) */ 4665 cappedAtMaxEmits?: boolean; 4666 /** Tenant daily cap hit (TenantConfig.outboundLimits.maxBackbookSmsPerDay) */ 4667 cappedAtTenantDailyLimit?: boolean; 4668 /** Run-level error messages */ 4669 errors?: string[]; 4670 /** Snapshot of the audience filter used at run time (audit / repro) */ 4671 audienceFilterSnapshot?: CampaignAudienceFilter; 4672 /** 4673 * Cohort id resolved at run time, if any. Stamped so historical runs can 4674 * be aggregated per cohort even after a workflow's \`cohortId\` changes. 4675 */ 4676 cohortId?: string; 4677 /** Whether this was a dry-run (no events emitted) */ 4678 dryRun?: boolean; 4679} 4680 4681// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4682// BACKBOOK CAMPAIGN TEMPLATE (Full campaign preset) 4683// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4684 4685/** 4686 * Backbook campaign template - fully populated preset for all builder steps. 4687 * Selecting a template fills every step (name, audience, channels, messages, 4688 * discount, schedule) so the user can review and tweak before saving. 4689 */ 4690export interface BackbookCampaignTemplate { 4691 /** Unique template ID */ 4692 id: string; 4693 /** Display name */ 4694 name: string; 4695 /** Short description shown in the selector */ 4696 description: string; 4697 /** Category for grouping templates */ 4698 category: 'recovery' | 'nurture' | 'retention' | 'acquisition' | 'operational'; 4699 /** Icon hint for the UI (optional) */ 4700 icon?: string; 4701 4702 // ââ Step data ââ 4703 4704 /** Campaign name (pre-filled) */ 4705 campaignName: string; 4706 /** Campaign description (pre-filled) */ 4707 campaignDescription?: string; 4708 4709 /** Audience filter (without tenantId - injected at runtime) */ 4710 audienceFilter: Omit<CampaignAudienceFilter, 'tenantId'>; 4711 4712 /** Channels to use */ 4713 channels: CampaignChannel[]; 4714 4715 /** SMS template (if SMS channel is included) */ 4716 smsTemplate?: string; 4717 /** Email subject (if email channel is included) */ 4718 emailSubject?: string; 4719 /** Email HTML body (if email channel is included) */ 4720 emailBody?: string; 4721 4722 /** Discount config */ 4723 discount?: { 4724 enabled: boolean; 4725 type: string; 4726 value?: number; 4727 }; 4728 4729 /** Schedule config */ 4730 schedule?: { 4731 sendNow: boolean; 4732 }; 4733} 4734`,Me=`/** 4735 * Campaign Context Type Definitions 4736 * 4737 * Based on Terraform schema: terraform/SHARED/create-dynamo-campaign-context 4738 * 4739 * Campaign-context table - Stores traffic and offer intent for campaigns 4740 * PRIMARY KEY = campaignId (hash key only) 4741 * 4742 * This table stores campaign configuration including: 4743 * - Landing pages and offer sources 4744 * - SMS and email templates 4745 * - Workflow definitions 4746 * - Discount, shipping, warranty, and concierge configurations 4747 */ 4748 4749import type { Workflow } from './workflow.js'; 4750import type { CampaignAudienceFilter, CampaignExecutionStatus, CampaignExecutionProgress } from './campaign-audience.js'; 4751import type { LeadSourceChannel } from './lead.js'; 4752 4753/** 4754 * Link type - determines how the campaign URL is generated 4755 * - 'landing-page': Direct to store page with tracking params only 4756 * - 'cart-permalink': Cart URL with pre-loaded items + discount code
4757 * - 'draft-order': Shopify draft order invoice URL with baked-in discount 4758 * - 'themejs': Redirect to any page with ?c={code} param; theme JS fetches campaign context and handles cart 4759 */ 4760export type LinkType = 'landing-page' | 'cart-permalink' | 'draft-order' | 'themejs'; 4761 4762/** 4763 * Video resolution mode 4764 * - 'generic': Always send a tenant-level video (not tied to any agent) 4765 * - 'agent_match': Send the calling agent's video, skip if no match (default) 4766 * - 'agent_match_with_fallback': Try agent's video first, fall back to tenant generic 4767 */ 4768export type VideoMode = 'generic' | 'agent_match' | 'agent_match_with_fallback' | 'static'; 4769 4770/** 4771 * Agent video configuration for workflow-based video delivery 4772 */ 4773export interface CampaignVideoConfig { 4774 /** Whether agent video is enabled for this workflow */ 4775 enabled: boolean; 4776 /** 4777 * Resolution mode: how to select the video at runtime 4778 * @default 'agent_match' 4779 */ 4780 mode?: VideoMode; 4781 /** Video scope determining which S3 prefix to search */ 4782 scope: 'general' | 'product' | 'collection' | 'discount' | 'category'; 4783 /** Delivery method: 'link' = CDN URL in text, 'mms' = Twilio MMS attachment */ 4784 delivery: 'link' | 'mms'; 4785 /** Category UUID (only when scope = 'category') */ 4786 categoryId?: string; 4787 /** CDN URL of the selected asset (only when mode = 'static') */ 4788 staticUrl?: string; 4789} 4790 4791/** 4792 * Campaign link configuration 4793 */ 4794export interface CampaignLink { 4795 /** Link type determining URL generation strategy */ 4796 type: LinkType; 4797 /** Landing page path (used when type = 'landing-page') */ 4798 landingPagePath?: string; 4799 /** Destination path for themejs link type (e.g., '/products/massage-chair', '/collections/sale') */ 4800 destinationPath?: string; 4801} 4802 4803/** 4804 * Input for deriving link type from campaign configuration 4805 */ 4806export interface DeriveLinkTypeInput { 4807 /** Discount category: 'none', 'discount_code', or 'draft_order' */ 4808 discountCategory?: 'none' | 'discount_code' | 'draft_order'; 4809 /** Code delivery method (only relevant when discountCategory = 'discount_code') */ 4810 codeDelivery?: 'cart_permalink' | 'theme_app' | 'manual'; 4811 /** Whether the campaign has pre-loaded cart products (for form workflows) */ 4812 hasCartProducts?: boolean; 4813} 4814 4815/** 4816 * Derive the link type based on discount configuration. 4817 * Link type is determined by discount setup, with user choice between cart_permalink and theme_app. 4818 * 4819 * Rules: 4820 * - draft_order discount â 'draft-order' (Shopify invoice URL) 4821 * - discount_code + theme_app delivery â 'themejs' (redirect with ?c={code}, theme JS handles cart) 4822 * - discount_code + cart_permalink delivery â 'cart-permalink' (pre-load cart with ?discount=CODE) 4823 * - Pre-loaded cart products (form workflows) â 'cart-permalink' 4824 * - Everything else â 'landing-page' (no discount or manual code entry) 4825 */ 4826export function deriveLinkType(config: DeriveLinkTypeInput): LinkType { 4827 // Draft order â always draft-order invoice URL 4828 if (config.discountCategory === 'draft_order') { 4829 return 'draft-order'; 4830 } 4831 4832 // Discount code via theme app â themejs (redirect to destination, theme JS fetches campaign context) 4833 if (config.discountCategory === 'discount_code' && config.codeDelivery === 'theme_app') { 4834 return 'themejs'; 4835 } 4836 4837 // Discount code via cart permalink â cart-permalink URL 4838 if (config.discountCategory === 'discount_code' && config.codeDelivery === 'cart_permalink') { 4839 return 'cart-permalink'; 4840 } 4841 4842 // Form workflows with pre-loaded products â cart-permalink (even without discount) 4843 if (config.hasCartProducts) { 4844 return 'cart-permalink'; 4845 } 4846 4847 // Everything else â landing-page 4848 // - No discount 4849 // - Discount code via manual entry 4850 return 'landing-page'; 4851} 4852 4853/** 4854 * Landing page configuration 4855 */ 4856export interface LandingPage { 4857 /** Landing page type: "collection", "product", "site" */ 4858 type?: 'collection' | 'product' | 'site'; 4859 /** Path to the landing page (e.g., "/collections/massage-chair-range") */ 4860 path: string; 4861 /** Query parameters to include in the landing URL */ 4862 query?: Record<string, string>; 4863} 4864 4865/** 4866 * SMS message template 4867 */ 4868export interface SmsMessage { 4869 /** SMS template with placeholders (e.g., "Hi {{name}} â ...") - inline content */ 4870 template?: string; 4871 4872 // S3-based template support 4873 /** 4874 * S3 template ID (folder name under tenant/sms/). 4875 * When set, Lambda fetches template from S3: {tenantId}/sms/{templateId}/template.txt 4876 * Template can contain {{placeholders}} for variable substitution. 4877 */ 4878 templateId?: string; 4879 4880 /** 4881 * Per-appointment-mode template override, keyed on the triggering event's 4882 * \`eventData.mode\` ('phone' | 'showroom' | 'reseller'). Built for the 4883 * appointment REMINDER workflows: the scheduler does not filter by mode, so 4884 * without this a reseller visit would get phone-consult copy ("we will call 4885 * you") â design doc §3.8 Defect A. A matching entry REPLACES \`templateId\` 4886 * for that send; no match (or absent mode â 'phone') falls back to 4887 * \`templateId\`, so every existing row behaves byte-for-byte the same until 4888 * an operator binds a mode template. 4889 */ 4890 templateIdByMode?: Partial<Record<'phone' | 'showroom' | 'reseller', string>>; 4891 4892 /** 4893 * Name of an eventData field holding a URL (e.g. 'resellerCardUrl') to send 4894 * as a STANDALONE follow-up SMS ~1.5s after the primary message â a message 4895 * whose trailing link stands alone gets the rich iOS contact-card preview, 4896 * which an appended link inside a longer body does not. Built for the 4897 * reseller-appointment customer leg; absent = no follow-up (unchanged 4898 * behaviour). Skipped when the field is missing on the triggering event. 4899 */ 4900 cardUrlFollowUpField?: string; 4901 4902 /** 4903 * When true, {{link}} is sent as a separate second message to enable iOS link preview. 4904 * iOS only shows link previews when URL is alone in the message. 4905 * Note: This doubles SMS cost as it sends 2 separate messages. 4906 */ 4907 splitLink?: boolean; 4908
4909 // MMS / Video support 4910 4911 /** 4912 * Public URL(s) of media to attach as inline MMS. 4913 * Twilio fetches these URLs on send. Max 5MB total, < 600KB recommended for AU carriers. 4914 * Supported: video/mp4, video/quicktime, video/webm, image/*. 4915 * Note: Converts SMS to MMS (higher cost per message). 4916 */ 4917 mediaUrl?: string[]; 4918 4919 /** 4920 * Public URL of hosted video/media to include as a link in the SMS body. 4921 * Resolved into the message via the {{video}} placeholder. No size limit. 4922 * Use this for full-quality videos (e.g., salesperson product demos). 4923 */ 4924 hostedMediaUrl?: string; 4925 4926 /** 4927 * Contact-card push ("save our number", caller-ID affinity program). When 4928 * true, send_sms stages the tenant's hosted .vcf as a separate follow-up 4929 * SMS (per-lead tracked short link) after the primary message is accepted. 4930 * Once per lead EVER (conditional \`Lead.contactCardSentAt\` stamp), so it is 4931 * safe to enable on many workflows at once. Inert unless the tenant has 4932 * \`tenants.contactCard.vcfUrl\` configured. 4933 */ 4934 sendContactCard?: boolean; 4935} 4936 4937/** 4938 * Email message template 4939 */ 4940export interface EmailMessage { 4941 /** Email subject line template */ 4942 subject: string; 4943 /** HTML email content template (inline) */ 4944 htmlContent?: string; 4945 /** Plain text email body template (inline) */ 4946 body?: string; 4947 /** Alternative template field (used in some campaigns) */ 4948 template?: string; 4949 /** List of placeholder names used in templates */ 4950 placeholders?: string[]; 4951 4952 // S3-based template support 4953 /** 4954 * S3 template ID (folder name under tenant). 4955 * When set, Lambda fetches template from S3: {tenantId}/{templateId}/template.html 4956 * Template can contain {{placeholders}} for variable substitution. 4957 * Asset references like assets/logo.png are converted to CloudFront URLs. 4958 */ 4959 templateId?: string; 4960 /** 4961 * Optional S3 bucket override. 4962 * Defaults to EMAIL_TEMPLATES_BUCKET environment variable. 4963 */ 4964 templateBucket?: string; 4965} 4966 4967/** 4968 * Site modal type 4969 * - 'offer': Display info only (no form) 4970 * - 'capture': Collect email/phone to convert visitor to lead 4971 */ 4972export type SiteModalType = 'offer' | 'capture'; 4973 4974/** 4975 * Action to take after form submission in capture modal 4976 * - 'email_code': Email the discount code to captured email 4977 * - 'sms_code': SMS the discount code to captured phone 4978 * - 'show_code': Display discount code immediately in modal 4979 * - 'thank_you': Just show confirmation message (no code) 4980 */ 4981export type SiteModalOnSubmitAction = 'email_code' | 'sms_code' | 'show_code' | 'thank_you'; 4982 4983/** 4984 * Site message template (modal/popup on Shopify storefront) 4985 */ 4986export interface SiteMessage { 4987 /** Modal title - inline content */ 4988 title?: string; 4989 /** Modal body/description - inline content */ 4990 body?: string; 4991 /** Call-to-action button text - inline content */ 4992 buttonText?: string; 4993 4994 // S3-based template support 4995 /** 4996 * S3 template ID (folder name under tenant/site/). 4997 * When set, fetches full HTML modal template from S3: {tenantId}/site/{templateId}/template.html 4998 * Template can contain {{placeholders}} for variable substitution. 4999 * Overrides title/body/buttonText when specified. 5000 */ 5001 templateId?: string; 5002 5003 // âââ Site Modal Capture Form (for unknown visitor lead generation) âââ 5004 5005 /** 5006 * Modal type: 'offer' (info only) or 'capture' (has form) 5007 * @default 'capture' 5008 */ 5009 type?: SiteModalType; 5010 5011 /** 5012 * Fields to capture in the form: 'email', 'phone', or both 5013 * Only used when type = 'capture' 5014 * @default ['email'] 5015 */ 5016 captureFields?: ('email' | 'phone')[]; 5017 5018 /** 5019 * Disclaimer/consent text shown below the form 5020 * Only used when type = 'capture' 5021 * @example "By submitting, you agree to receive marketing messages." 5022 */ 5023 disclaimerText?: string; 5024 5025 /** 5026 * What happens after successful form submission 5027 * Only used when type = 'capture' 5028 * @default 'email_code' 5029 */ 5030 onSubmitAction?: SiteModalOnSubmitAction; 5031 5032 /** 5033 * Confirmation message shown after form submit 5034 * Supports placeholders: {{email}}, {{phone}} 5035 * @example "Thanks! Check your inbox for your discount code." 5036 */ 5037 thankYouMessage?: string; 5038 5039 /** 5040 * Delay in seconds before showing the modal after trigger event 5041 * @default 2 5042 */ 5043 delaySeconds?: number; 5044} 5045 5046/** 5047 * Call Now message configuration 5048 * Minimal config â the step handler creates a call_now_recommended event 5049 */ 5050export interface CallNowMessage { 5051 /** How long the recommendation stays active (seconds). Default: 1800 (30 min) */ 5052 recommendationTtlSeconds?: number; 5053 /** Cooldown period in seconds before re-recommending for same lead. Default: 3600 (1 hr) */ 5054 cooldownSeconds?: number; 5055} 5056 5057/** 5058 * Messages configuration for SMS, email, site, and call now 5059 */ 5060export interface CampaignMessages { 5061 /** SMS message template */ 5062 sms?: SmsMessage; 5063 /** Email message template */ 5064 email?: EmailMessage; 5065 /** Site message template (modal/popup) */ 5066 site?: SiteMessage; 5067 /** Call now configuration */ 5068 callNow?: CallNowMessage; 5069 /** MMS order-summary card (rendered PNG sent as SMS mediaUrl) */ 5070 mmsCard?: MmsCardMessage; 5071} 5072 5073/** 5074 * MMS order-summary card â dynamically-rendered PNG showing product + order summary 5075 */ 5076export interface MmsCardMessage { 5077 /** Template identifier (currently only "order-summary-v1") */ 5078 templateId?: string; 5079} 5080 5081/** 5082 * Discount applicable to configuration 5083 */ 5084export interface DiscountApplicableTo { 5085 /** Type of discount applicability: "VARIANT", "PRODUCT", etc. */ 5086 type: string; 5087 /** Array of Shopify GID identifiers */ 5088 ids: string[]; 5089} 5090 5091/** 5092 * Customer eligibility for discount 5093 */ 5094export interface CustomerEligibility { 5095 /** Eligibility type: "ALL", "NEW", "REPEAT", etc. */ 5096 type: string; 5097} 5098 5099/** 5100 * Discount category - determines the mechanism used 5101 */ 5102export type DiscountCategory = 'discount_code' | 'draft_order'; 5103 5104/** 5105 * Code delivery method - how the discount code reaches the customer 5106 */ 5107export type CodeDeliveryMethod = 'cart_permalink' | 'theme_app' | 'manual'; 5108 5109/** 5110 * Draft order discount level - where discount applies 5111 */ 5112export type DraftOrderLevel = 'order' | 'line_item'; 5113 5114// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5115// LINE ITEM DISCOUNT TYPES (for checkout-abandoned workflows) 5116// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5117 5118/** 5119 * Value source for line item discounts 5120 * - 'config': Always use the configured discount value 5121 * - 'best': Use MAX(checkoutDiscount, configDiscount) - never reduces existing discounts 5122 */ 5123export type DiscountValueSource = 'config' | 'best'; 5124 5125/** 5126 * Per-line-item discount configuration 5127 * Allows specifying discounts for specific products/variants in checkout-abandoned workflows 5128 */ 5129export interface LineItemDiscount { 5130 /** Shopify variant GID: gid://shopify/ProductVariant/123 */ 5131 variantId: string; 5132 /** Product title for display */ 5133 productTitle?: string; 5134 /** Variant title for display (e.g., "Blue - Large") */ 5135 variantTitle?: string; 5136 /** Type of discount: percentage or fixed amount */ 5137 valueType: 'percentage' | 'fixed'; 5138 /** Discount value (e.g., 20 means 20% or $20 depending on valueType) */ 5139 value: number; 5140 /** 5141 * How to determine the final discount: 5142 * - 'config': Always apply configured value 5143 * - 'best': Apply MAX(checkoutDiscount, configDiscount) - never reduce existing discounts 5144 * @default 'best' 5145 */ 5146 valueSource?: DiscountValueSource; 5147} 5148 5149/** 5150 * Free item types for draft orders 5151 */ 5152export type FreeItemType = 'custom'; 5153 5154/** 5155 * Configuration for free items added to draft orders 5156 */ 5157export interface FreeItemConfig { 5158 /** Type of free item */ 5159 type: FreeItemType; 5160 /** Display title for the item */ 5161 title: string; 5162 /** Shopify variant GID (required for custom items) */ 5163 variantId?: string; 5164 /** Whether this item is enabled */ 5165 enabled: boolean; 5166 /** Only add this free item if the cart contains products matching these Shopify product GIDs. 5167 * If omitted or empty, the free item is added unconditionally (backwards compatible). */ 5168 eligibleProductIds?: string[]; 5169} 5170 5171/** 5172 * Draft order specific configuration 5173 */ 5174export interface DraftOrderConfig { 5175 /** Discount application level */ 5176 level: DraftOrderLevel; 5177 /** Free items to include in draft order */ 5178 freeItems?: FreeItemConfig[]; 5179} 5180 5181/** 5182 * Discount configuration 5183 */ 5184export interface CampaignDiscount { 5185 /** 5186 * Optional reference to a reusable discount rule in the \`discount-rules\` table. 5187 * When set, \`get-site-modal\` / \`site-modal-submit\` resolve the rule at request 5188 * time via \`getDiscountRule(tenantId, ruleId)\` from \`@bigm/shared\` and use the 5189 * resolved \`code\` / \`value\` / \`type\` authoritatively. Inline fields below remain 5190 * as fallback for legacy campaigns that don't reference a rule. 5191 */ 5192 discountRuleId?: string; 5193 /** Discount code (e.g., "BOX50") â inline fallback when discountRuleId is not set */ 5194 code: string; 5195 /** Discount description */ 5196 description?: string; 5197 /** What the discount applies to */ 5198 applicableTo?: DiscountApplicableTo; 5199 /** Customer eligibility rules */ 5200 customerEligibility?: CustomerEligibility; 5201 /** Discount type: "PERCENTAGE", "FIXED_AMOUNT", etc. */ 5202 type?: string; 5203 /** Discount title */ 5204 title?: string; 5205 /** Discount value (percentage or fixed amount) */ 5206 value?: number; 5207 5208 // âââ NEW: Discount method selection âââ 5209 5210 /** 5211 * Discount category - determines the mechanism used 5212 * - 'discount_code': Customer uses a discount code (via cart permalink, theme app, or manual entry) 5213 * - 'draft_order': Invoice with discount baked in (order-level or line-item) 5214 * @default 'draft_order' (for backwards compatibility) 5215 */ 5216 category?: DiscountCategory; 5217 5218 /** 5219 * Code delivery method (only used when category = 'discount_code') 5220 * - 'cart_permalink': Build URL with ?discount=CODE that auto-applies when clicked 5221 * - 'theme_app': Theme block detects campaign and applies code via Storefront API 5222 * - 'manual': Customer receives code and enters it manually at checkout 5223 */ 5224 codeDelivery?: CodeDeliveryMethod; 5225 5226 /**
5227 * Draft order discount level (only used when category = 'draft_order') 5228 * - 'order': Single applied_discount on entire draft order 5229 * - 'line_item': Per-item applied_discount on each line item 5230 * @default 'line_item' (for backwards compatibility with existing behavior) 5231 */ 5232 draftOrderLevel?: DraftOrderLevel; 5233 5234 // âââ Line Item Discount Configuration (checkout-abandoned workflows) âââ 5235 5236 /** 5237 * Per-line-item discount configuration. 5238 * Each entry specifies a product/variant and its discount %. 5239 * Used in checkout-abandoned workflows to offer targeted discounts. 5240 */ 5241 lineItemDiscounts?: LineItemDiscount[]; 5242 5243 /** 5244 * Require cart to contain items matching lineItemDiscounts. 5245 * When true, the workflow is only eligible if at least one item in the 5246 * abandoned cart matches a configured collection/product/variant. 5247 * This prevents sending discount offers that don't apply to the customer's cart. 5248 * @default false 5249 */ 5250 requireLineItemMatch?: boolean; 5251 5252 /** 5253 * Draft order specific configuration. 5254 * Includes discount level and free items. 5255 */ 5256 draftOrderConfig?: DraftOrderConfig; 5257} 5258 5259/** 5260 * Shipping configuration 5261 */ 5262export interface CampaignShipping { 5263 /** Whether shipping configuration is enabled */ 5264 enabled: boolean; 5265 /** Whether to automatically add shipping */ 5266 autoAdd: boolean; 5267 /** Whether free shipping is enabled (always true, not editable in UI) */ 5268 freeShipping: boolean; 5269} 5270 5271/** 5272 * Free discount configuration 5273 */ 5274export interface FreeDiscountConfig { 5275 /** Whether free discount is enabled */ 5276 enabled: boolean; 5277 /** Free discount code */ 5278 code?: string; 5279} 5280 5281/** 5282 * Warranty configuration 5283 */ 5284export interface WarrantyConfig { 5285 /** Whether warranty is enabled */ 5286 enabled: boolean; 5287 /** Whether to automatically add warranty */ 5288 autoAdd?: boolean; 5289 /** Warranty note/description */ 5290 note?: string; 5291 /** Shopify Product GID */ 5292 productId?: string; 5293 /** Warranty price */ 5294 price?: number; 5295 /** Warranty name */ 5296 name?: string; 5297 /** Shopify ProductVariant GID */ 5298 variantId?: string; 5299 /** Warranty SKU */ 5300 sku?: string; 5301} 5302 5303/** 5304 * Concierge configuration 5305 */ 5306export interface ConciergeConfig { 5307 /** Whether concierge is enabled */ 5308 enabled: boolean; 5309 /** Whether to automatically add concierge */ 5310 autoAdd?: boolean; 5311 /** Shopify ProductVariant GID */ 5312 variantId?: string; 5313} 5314 5315/** 5316 * Offer context - tracks offer source and grouping 5317 */ 5318export interface OfferContext { 5319 /** Source of the offer: "sms", "site", "agent", "voice" */ 5320 offerSource: 'sms' | 'site' | 'agent' | 'voice'; 5321 /** Optional offer group identifier */ 5322 offerGroup?: string | null; 5323} 5324 5325/** 5326 * Offer explanation - UI display configuration 5327 */ 5328export interface OfferExplanation { 5329 /** Whether offer explanation is enabled */ 5330 enabled: boolean; 5331 /** Button text */ 5332 buttonText: string; 5333 /** Offer title */ 5334 title: string; 5335 /** Offer body/description (may contain HTML) */ 5336 body: string; 5337} 5338 5339/** 5340 * Campaign channel values 5341 */ 5342export type CampaignChannel = 'sms' | 'email' | 'site' | 'call_now' | 'ai_call' | 'agent_action' | 'ai_workload' | 'push' | 'draft_order_image'; 5343 5344/** 5345 * Trigger events that support the Site channel (visitor is on storefront) 5346 * Site modal can only be shown for these events because the visitor 5347 * is actively browsing when they occur. 5348 */ 5349export const SITE_ELIGIBLE_TRIGGERS = [ 5350 'cart.added', 5351 'product.viewed', 5352 'collection.viewed', 5353 'cart.viewed', 5354 'page.viewed', 5355] as const; 5356 5357export type SiteEligibleTrigger = typeof SITE_ELIGIBLE_TRIGGERS[number]; 5358 5359/** 5360 * Check if a trigger event supports the Site channel 5361 */ 5362export function isSiteEligibleTrigger(eventType: string): eventType is SiteEligibleTrigger { 5363 return SITE_ELIGIBLE_TRIGGERS.includes(eventType as SiteEligibleTrigger); 5364} 5365 5366/** 5367 * Channel mode - how to use the selected channels 5368 * - 'first': Send to first available channel in priority order (fallback behavior) 5369 * - 'all': Send to all selected channels simultaneously 5370 */ 5371export type ChannelMode = 'first' | 'all'; 5372 5373/** 5374 * Campaign type values 5375 */ 5376export type CampaignType = 5377 | 'abandon_cart' 5378 | 'abandon_checkout' 5379 | 'promotional' 5380 | 'form_fanout' 5381 | 'welcome' 5382 // Site modal campaign types (storefront popups for unknown visitors) 5383 | 'cart_added' 5384 | 'product_viewed' 5385 | 'collection_viewed' 5386 | 'cart_viewed' 5387 | 'page_viewed' 5388 | 'other' 5389 // Backbook campaign type (batch targeting historical leads) 5390 | 'backbook'; 5391 5392/** 5393 * Campaign schedule configuration 5394 */ 5395export interface CampaignSchedule { 5396 /** Whether the campaign schedule is enabled */ 5397 enabled: boolean; 5398 /** Delay in minutes before executing the campaign (default: 0) */ 5399 delayMinutes?: number; 5400} 5401 5402// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5403// ELIGIBILITY CHECK TYPES 5404// Each check type maps 1:1 to evaluation logic in eligibility-evaluator.ts 5405// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5406 5407/** 5408 * All supported eligibility check types 5409 */ 5410export type EligibilityCheckType = 5411 | 'trigger' // Find eligible targets by event type or lead category 5412 | 'require_lead_id' // Require checkout has associated leadI
5412d 5413 | 'exclude_draft_order_source' // Skip checkouts from draft orders (staff-created) 5414 | 'check_already_sent' // Skip if campaign message already sent 5415 | 'check_any_campaign_sent' // Skip if ANY campaign was sent recently 5416 | 'check_delay' // Require minimum time since trigger event 5417 | 'check_recovery' // Skip if cart was recovered (order placed) 5418 | 'check_no_outbound_call' // Skip if lead was called recently 5419 | 'check_no_outbound_sms' // Skip if any outbound SMS was ever sent to this lead 5420 | 'check_no_prior_order' // Skip if lead has any prior order.confirmed event 5421 | 'check_no_successful_contact' // Skip if there was ever an answered call with the lead 5422 | 'check_no_recent_event' // Skip if the lead had ANY customer event within the last N days (reads Lead.lastEventAt) 5423 | 'check_no_recent_inbound_sms' // Skip if the CUSTOMER texted us within the last N days (they are mid-conversation) 5424 | 'check_no_person_since_card' // AI card follow-ups: skip if a PERSON stepped in after the AI card (staff text/call, hand-off, manual assignment) 5425 | 'check_no_agent_engagement' // Skip if an agent has ALREADY engaged the lead (showroom/connected-call result code, two-way SMS, agent/Zoho notes, answered call) 5426 | 'check_not_declined' // Skip if the lead soft-declined recently (Lead.declinedAt within lookbackDays) 5427 | 'check_first_lead_source' // Skip if lead's first source doesn't match allowed channels 5428 | 'check_last_lead_source' // Skip if lead's last source doesn't match allowed channels 5429 | 'check_first_event_type' // Skip if lead's first event type doesn't match allowed types 5430 | 'check_form_id' // Skip if triggering event's formId doesn't match filter 5431 | 'check_max_list_id' // Skip if lead's MaxContact list ID doesn't match filter 5432 | 'check_max_disposition' // Skip if triggering event's MaxContact disposition doesn't match filter 5433 | 'check_event_property' // Generic eventData path match (e.g. callClassification.state === 'Not Answered') 5434 | 'check_last_outbound_sms_campaign' // Skip if the lead's MOST-RECENT outbound SMS belongs to one of excludeCampaignIds (inverse of the booking gate â hands the conversation to the AI) 5435 | 'check_event_time_of_day' // Skip unless the triggering event's local wall-clock is inside/outside configured per-weekday windows (e.g. after-hours only) 5436 | 'check_no_active_appointment' // Skip if the lead already has a future, non-cancelled appointment 5437 | 'check_appointment_no_contact' // Only fire when the triggering call sits near a non-cancelled appointment AND no dial in that window connected 5438 | 'check_mobile_number' // Skip if the lead's phone is a KNOWN landline/freecall â an SMS/MMS to one is refused by Twilio 5439 | 'check_sales_attribution_needs_confirmation' // Only fire if event's salesAttribution.needsConfirmation === true 5440 | 'check_no_shopify_purchaser' // Skip if the lead maps to a Shopify customer with orders (auto-links by phone/email as a side effect) 5441 | 'check_inbound_email_is_human' // Skip when the triggering inbound_email is a bounce / DSN / autoresponder (62% of MMC inbound, Sep 2026) 5442 | 'check_last_outbound_sms_lane'; // Route an inbound SMS by whose conversation it is: service (last text from a service seat / service-purpose workflow, or an upcoming service call-back) vs sales (Sep 2026) 5443 5444/** 5445 * Base interface for all eligibility checks 5446 */ 5447interface EligibilityCheckBase { 5448 type: EligibilityCheckType; 5449 enabled: boolean; 5450} 5451 5452/** 5453 * Trigger check - finds eligible targets 5454 * Maps to: checkout-query.ts scan logic 5455 */ 5456export interface TriggerCheck extends EligibilityCheckBase { 5457 type: 'trigger'; 5458 /** How to find targets: by customer event or lead category */ 5459 source: 'customer_event' | 'lead_category'; 5460 /** Event type to scan for (e.g., 'checkout.abandoned') - for customer_event source */ 5461 eventType?: string; 5462 /** Days to look back for events/categories */ 5463 lookbackDays?: number; 5464 /** Lead category to match - for lead_category source */ 5465 category?: string; 5466} 5467 5468/** 5469 * Require Lead ID check - ensures checkout has associated lead 5470 * Maps to: eligibility-evaluator.ts lines 89-98 5471 */
5472export interface RequireLeadIdCheck extends EligibilityCheckBase { 5473 type: 'require_lead_id'; 5474} 5475 5476/** 5477 * Exclude Draft Order Source check - skips staff-created checkouts 5478 * Maps to: eligibility-evaluator.ts lines 100-115 5479 */ 5480export interface ExcludeDraftOrderSourceCheck extends EligibilityCheckBase { 5481 type: 'exclude_draft_order_source'; 5482} 5483 5484/** 5485 * Channel match mode for check_already_sent 5486 * - 'any': Skip if campaign was sent via ANY channel (SMS or email) 5487 * - 'same': Only block channels already used (allows sending via other channels) 5488 */ 5489export type ChannelMatchMode = 'any' | 'same'; 5490 5491/** 5492 * Check Already Sent - skips if campaign message already sent to lead 5493 * Queries the campaign-sends table to check for existing sends. 5494 */ 5495export interface CheckAlreadySentCheck extends EligibilityCheckBase { 5496 type: 'check_already_sent'; 5497 /** Days to look back for existing sends (default: 30) */ 5498 lookbackDays: number; 5499 /** 5500 * Channel match mode: 5501 * - 'any': Skip if campaign was sent via ANY channel 5502 * - 'same': Only block the specific channel(s) already used 5503 */ 5504 channelMode: ChannelMatchMode; 5505} 5506 5507/** 5508 * Check Any Campaign Sent - skips if ANY campaign was sent to lead recently 5509 * Unlike check_already_sent, this checks ALL campaigns, not just the current one. 5510 * Queries the campaign-sends table without filtering by campaignId. 5511 */ 5512export interface CheckAnyCampaignSentCheck extends EligibilityCheckBase { 5513 type: 'check_any_campaign_sent'; 5514 /** Days to look back for any campaign sends (default: 7) */ 5515 lookbackDays: number; 5516} 5517 5518/** 5519 * Check Delay - ensures minimum time has passed since trigger event 5520 * Maps to: eligibility-evaluator.ts lines 139-163 5521 * Note: Reads delayMinutes from campaign.schedule.delayMinutes 5522 */ 5523export interface CheckDelayCheck extends EligibilityCheckBase { 5524 type: 'check_delay'; 5525} 5526 5527/** 5528 * Check Recovery - skips if cart was recovered (order placed) 5529 * Maps to: eligibility-evaluator.ts lines 165-184, checkIfRecovered() 5530 */ 5531export interface CheckRecoveryCheck extends EligibilityCheckBase { 5532 type: 'check_recovery'; 5533 /** Event types that indicate recovery (default: ['order.confirmed']) */ 5534 eventTypes: string[]; 5535 /** Minimum order value as % of cart value to count as recovery (default: 0) */ 5536 minimumPercentage: number; 5537} 5538 5539/** 5540 * Check No Outbound Call - skips if lead was called recently 5541 * Only applicable to call_now and ai_call channels. 5542 */ 5543export interface CheckNoOutboundCallCheck extends EligibilityCheckBase { 5544 type: 'check_no_outbound_call'; 5545 /** Days to look back for outbound calls (default: 7) */ 5546 lookbackDays: number; 5547} 5548 5549/** 5550 * Check No Outbound SMS - skips if any outbound SMS was ever sent to this lead 5551 * Queries events-customer by leadId for outbound_sms events. 5552 */ 5553export interface CheckNoOutboundSmsCheck extends EligibilityCheckBase { 5554 type: 'check_no_outbound_sms'; 5555 /** Days to look back for outbound SMS events (0 = all time) */ 5556 lookbackDays: number; 5557} 5558 5559/** 5560 * Check No Prior Order - skips if lead has any prior order 5561 * Queries events-customer by leadId for order.confirmed events. 5562 */ 5563export interface CheckNoPriorOrderCheck extends EligibilityCheckBase { 5564 type: 'check_no_prior_order'; 5565 /** Days to look back for order events (0 = all time) */ 5566 lookbackDays: number; 5567} 5568 5569/** 5570 * Check No Successful Contact - skips if there was ever an answered call with the lead 5571 * Queries events-customer by leadId for call events, uses isAnsweredCall() from @bigm/utils. 5572 */ 5573export interface CheckNoSuccessfulContactCheck extends EligibilityCheckBase { 5574 type: 'check_no_successful_contact'; 5575 /** Days to look back for call events (0 = all time) */ 5576 lookbackDays: number; 5577} 5578 5579/** 5580 * Check No Recent Event - skip the lead if it has had ANY customer event within 5581 * the last \`minQuietDays\` days. Reads \`Lead.lastEventAt\` (the monotonic UTC 5582 * timestamp of the most-recent customer event linked to the lead) â a cheap 5583 * single-field read, no events-customer query. A lead with no \`lastEventAt\` is 5584 * treated as quiet (eligible). Use to suppress proactive outreach to leads who 5585 * are actively engaged elsewhere (e.g. mid-conversation, just messaged/called). 5586 */
5587export interface CheckNoRecentEventCheck extends EligibilityCheckBase { 5588 type: 'check_no_recent_event'; 5589 /** Minimum days of quiet (no customer event) required for eligibility. Default 1. */ 5590 minQuietDays?: number; 5591} 5592 5593/** 5594 * Check No Recent Inbound SMS â stand a proactive OPENER down when the customer 5595 * texted us within the last N days: they are mid-conversation (AI or human), and 5596 * a fresh templated opener reads as ignoring what they just said (2026-08-02 5597 * review: 111 opener sends in 30 days landed within 48h of a customer text, 5598 * incl. re-opening a customer the AI had just promised to leave for 3 weeks). 5599 * Queries the lead's newest inbound_sms via eventsByLead â deliberately NOT 5600 * Lead.lastEventAt, which any event (calls, our own sends) bumps. 5601 * â ï¸ EVENT-TRIGGERED openers only. Do NOT add to a batch/cron (backbook) row 5602 * without mirroring it in the batch-executor's audience/eligibility phase â 5603 * an engine-only skip silently burns the run's emit budget (starvation). 5604 */ 5605export interface CheckNoRecentInboundSmsCheck extends EligibilityCheckBase { 5606 type: 'check_no_recent_inbound_sms'; 5607 /** Days of customer-text quiet required for eligibility. Default 3. */ 5608 days?: number; 5609 /** Hours of quiet (2026-08-17) â beats \`days\` when set. Built for the 5610 * appt-nocontact recovery row, whose 1-day window disqualified the very 5611 * customers it was built for (AI-booked customers texted to book). */ 5612 hours?: number; 5613} 5614 5615/** 5616 * Check Not Declined â skip the lead if it carries a recent soft decline 5617 * (\`Lead.declinedAt\`, stamped by the AI booker's not-interested escalation and 5618 * cleared when the lead later books). Keeps re-pitch workflows and openers off 5619 * customers who just said no, without treating a polite decline as an opt-out. 5620 */ 5621export interface CheckNotDeclinedCheck extends EligibilityCheckBase { 5622 type: 'check_not_declined'; 5623 /** How long a decline suppresses outreach. Default 30 days. */ 5624 lookbackDays?: number; 5625} 5626 5627/** 5628 * Check No Agent Engagement â skip the lead if an AGENT has already engaged it in 5629 * ANY channel, so the automated bot never cold-opens a lead a human is/was working. 5630 * Combines the durable Lead-state signals (\`isOutreachSuppressedByOwnership\`: locks, 5631 * sold, open buyer-close, AND \`lastMaxResultCode\` â connected-conversation codes 5632 * like SHOWROOM/callbacks/not-interested) with a per-lead events-customer scan for: 5633 * - inbound_sms (customer texted in) / agent_physical outbound_sms (agent texted) 5634 * - agent_note / backfill_zoho_note (agent logged a Zoho note) 5635 * - an answered / connected call (checkLeadHasAnsweredCall) 5636 */ 5637/** 5638 * AI card follow-ups (Chris, 5 Oct 2026: "make sure there will not be any stepping on toes of physical agents"). 5639 * Skips when, AFTER the AI deal card went out (Lead.aiSale.stageEnteredAt / the stage-change event), a person stepped 5640 * in: a staff text, a call, an Auto hand-off to a person (ai_escalated), or a manual (re)assignment to an agent. 5641 * A customer reply does NOT skip (a hand-queued follow-up can follow a quiet thread). Same rules as the morning sweep 5642 * (\`nudgeEventFrom\` in @bigm/shared), checked again at send time. 5643 */ 5644export interface CheckNoPersonSinceCardCheck extends EligibilityCheckBase { 5645 type: 'check_no_person_since_card'; 5646} 5647 5648export interface CheckNoAgentEngagementCheck extends EligibilityCheckBase { 5649 type: 'check_no_agent_engagement'; 5650 /** Days to look back for SMS / note / call engagement events (0 = all time). Default 0. */ 5651 lookbackDays?: number; 5652} 5653 5654/** 5655 * Check First Lead Source - only include leads whose original (first-touch) source matches. 5656 * Compares lead.leadSource.channel against the allowed channels list. 5657 */ 5658export interface CheckFirstLeadSourceCheck extends EligibilityCheckBase { 5659 type: 'check_first_lead_source'; 5660 /** Allowed LeadSourceChannel values â lead eligible if leadSource.channel is in this list */ 5661 channels: LeadSourceChannel[]; 5662} 5663 5664/** 5665 * Check Last Lead Source - only include leads whose most recent (last-touch) source matches. 5666 * Compares lead.lastLeadSource.channel against the allowed channels list. 5667 */
5668export interface CheckLastLeadSourceCheck extends EligibilityCheckBase { 5669 type: 'check_last_lead_source'; 5670 /** Allowed LeadSourceChannel values â lead eligible if lastLeadSource.channel is in this list */ 5671 channels: LeadSourceChannel[]; 5672} 5673 5674/** 5675 * Check First Event Type - only include leads whose first event type matches. 5676 * Compares lead.leadSource.firstEventType against the allowed event types list. 5677 */ 5678export interface CheckFirstEventTypeCheck extends EligibilityCheckBase { 5679 type: 'check_first_event_type'; 5680 /** Allowed event types â lead eligible if leadSource.firstEventType is in this list */ 5681 eventTypes: string[]; 5682} 5683 5684/** 5685 * Check Form ID - filter by the triggering event's formId. 5686 * Only applicable to real-time workflows triggered by form.submitted events. 5687 * In 'include' mode, only matching form IDs pass. In 'exclude' mode, matching form IDs are blocked. 5688 */ 5689export interface CheckFormIdCheck extends EligibilityCheckBase { 5690 type: 'check_form_id'; 5691 /** 'include' = only fire for selected forms, 'exclude' = fire for all EXCEPT selected */ 5692 mode: 'include' | 'exclude'; 5693 /** Form IDs to include or exclude */ 5694 formIds: string[]; 5695} 5696 5697/**
5698 * Check Max List ID - filter by the lead's MaxContact list ID. 5699 * In 'include' mode, only leads with matching list IDs pass. 5700 * In 'exclude' mode, leads with matching list IDs are blocked. 5701 */ 5702export interface CheckMaxListIdCheck extends EligibilityCheckBase { 5703 type: 'check_max_list_id'; 5704 /** 'include' = only fire for selected lists, 'exclude' = fire for all EXCEPT selected */ 5705 mode: 'include' | 'exclude'; 5706 /** MaxContact List IDs to include or exclude */ 5707 listIds: number[]; 5708} 5709 5710/** 5711 * Check Max Disposition - filter by the triggering event's MaxContact resultCode. 5712 * In 'include' mode, only events with matching result codes pass. 5713 * In 'exclude' mode, events with matching result codes are blocked. 5714 * If resultCode is null/undefined (call still in progress), the check fails (skips workflow). 5715 */ 5716export interface CheckMaxDispositionCheck extends EligibilityCheckBase { 5717 type: 'check_max_disposition'; 5718 /** 'include' = only fire for selected dispositions, 'exclude' = fire for all EXCEPT selected */ 5719 mode: 'include' | 'exclude'; 5720 /** MaxContact result codes to include or exclude */ 5721 resultCodes: string[]; 5722} 5723 5724/** 5725 * Check Sales Attribution Needs Confirmation - only fire the workflow when the 5726 * triggering \`order.confirmed\` event has blank \`bigm.sales_agents\` + 5727 * \`bigm.payment_method\` metafields. Reads \`eventData.salesAttribution.needsConfirmation\` 5728 * which is stamped by the \`transform-shopify-order-event\` Lambda. 5729 */ 5730export interface CheckSalesAttributionNeedsConfirmationCheck extends EligibilityCheckBase { 5731 type: 'check_sales_attribution_needs_confirmation'; 5732} 5733 5734/** 5735 * Generic eventData property-path check. 5736 * Evaluates a dot-path against the triggering event and matches with the 5737 * given operator. Used by the MaxContact missed-inbound workflow to read 5738 * \`eventData.callClassification.state === 'Not Answered'\` â single source 5739 * of truth stamped by lead-webhook-call-record. 5740 */ 5741export interface CheckEventPropertyCheck extends EligibilityCheckBase { 5742 type: 'check_event_property'; 5743 /** Dot-path into the event object, e.g. 'eventData.callClassification.state'. 5744 * For \`array_contains\`, the path must resolve to an ARRAY (e.g. 'eventData.lineItems'). */ 5745 path: string; 5746 /** 5747 * - 'equals' (default/legacy): strict === between the resolved path value and \`value\`. 5748 * - 'array_contains' (2026-07-31): the array at \`path\` is eligible when ANY element's 5749 * \`field\` property matches ANY entry in \`values\` (loose == so numeric ids match 5750 * string configs). Built for "abandoned cart contains product X" checks. 5751 * - 'in' (2026-08-17): the SCALAR at \`path\` must equal (String-loose) one of 5752 * \`values\`. Built so the missed-call re-pitch rows can require 5753 * callClassification.state â {Not Answered, Answering Machine, Screened - Not 5754 * Answered} â "couldnt get through" must never follow an Answered call. 5755 * - 'not_in' (2026-09-15): the SCALAR at \`path\` must NOT equal (String-loose, 5756 * case-insensitive) any of \`values\`; a missing value is eligible. Built so the 5757 * sales inbound-email rows can stand down on mail to \`service@\` (path 5758 * \`eventData.to\`) while the service rows use \`in\` on the same path â recipient 5759 * routing as row config, no derived service/sales predicate in code. 5760 */ 5761 operator: 'equals' | 'array_contains' | 'in' | 'not_in'; 5762 value?: string | number | boolean | null; 5763 /** array_contains only: element property to compare, e.g. 'product_id'. */ 5764 field?: string; 5765 /** array_contains only: accepted values (any match â eligible). */ 5766 values?: Array<string | number>; 5767} 5768 5769/** 5770 * Skip the workflow when the lead's MOST-RECENT outbound SMS belongs to one of 5771 * \`excludeCampaignIds\`. This is the inverse of the appointment-booking gate 5772 * (\`manage_appointment.bookingCampaignIds\`): when a booking campaign sent the 5773 * last outbound SMS, the AI owns the conversation, so the agent-action workflow 5774 * should NOT also fire. Reads the same newest-first SMS history as the gate. 5775 */ 5776export interface CheckLastOutboundSmsCampaignCheck extends EligibilityCheckBase { 5777 type: 'check_last_outbound_sms_campaign'; 5778 /** 5779 * How to match the lead's most-recent outbound SMS campaignId against \`campaignIds\`: 5780 * - 'exclude' (default): ineligible (skip) when the last outbound IS in the list â 5781 * e.g. an agent-action workflow standing down while the booking AI owns the reply. 5782 * - 'include': ineligible (skip) when the last outbound is NOT in the list â 5783 * e.g. the booking AI handler gating itself to only answer its own conversations. 5784 * An empty include list makes EVERYTHING ineligible (fail-safe: never fire). 5785 */ 5786 mode?: 'include' | 'exclude'; 5787 /** Campaign IDs to match against the lead's most-recent outbound SMS. */ 5788 campaignIds?: string[]; 5789 /** @deprecated Back-compat alias for \`campaignIds\` (exclude mode). Read if \`campaignIds\` is unset. */ 5790 excludeCampaignIds?: string[]; 5791} 5792 5793/** 5794 * Gate on the TRIGGERING event's local wall-clock. Used by the after-hours 5795 * missed-call workflow to fire ONLY when the phone room is closed. The decision 5796 * is anchored to the event's own timestamp (eventStartUtc ?? eventData 5797 * .startDateTime ?? createdAt), never the processing time, so classification 5798 * retry-lag can't push a 19:59 call across the 20:00 boundary. 5799 */
5800export interface CheckEventTimeOfDayCheck extends EligibilityCheckBase { 5801 type: 'check_event_time_of_day'; 5802 /** IANA zone the windows are expressed in. Default 'Australia/Melbourne'. */ 5803 timezone?: string; 5804 /** 'outside' (default): eligible only when the event time is OUTSIDE every window. 5805 * 'inside': eligible only when inside a window. */ 5806 mode?: 'outside' | 'inside'; 5807 /** Open windows per JS weekday (0=Sun..6=Sat), minutes-from-midnight. 5808 * A missing/empty day = closed all day. */ 5809 windows: Partial<Record<number, Array<{ openMin: number; closeMin: number }>>>; 5810} 5811 5812/** 5813 * Skip when the lead already has a future, non-cancelled appointment â so a 5814 * re-offer workflow never pesters someone who is already booked. Reads the 5815 * \`appointments\` table via the appointmentsByLeadStart GSI. 5816 */ 5817export interface CheckNoActiveAppointmentCheck extends EligibilityCheckBase { 5818 type: 'check_no_active_appointment'; 5819 /** Only a FUTURE non-cancelled appt blocks (default true). */ 5820 futureOnly?: boolean; 5821} 5822 5823/** 5824 * The INVERSE of check_no_active_appointment, and what makes an outbound-call 5825 * workflow APPOINTMENT-SCOPED rather than firing on every unreached dial. 5826 * 5827 * Eligible only when BOTH hold, evaluated against the TRIGGERING EVENT'S OWN 5828 * timestamp (never Date.now(), so call-classification retry lag can't shift the 5829 * window â same rule check_event_time_of_day follows): 5830 * 1. the lead has a non-cancelled appointment whose startTimeUtc falls inside 5831 * [eventTime - beforeMinutes, eventTime + afterMinutes]; and 5832 * 2. NO dial in that same window is classified 'Answered' or 5833 * 'Screened - Answered' â so a later successful call-back in the same 5834 * burst can never produce a "sorry we missed you" text. 5835 */ 5836export interface CheckAppointmentNoContactCheck extends EligibilityCheckBase { 5837 type: 'check_appointment_no_contact'; 5838 /** How far BEFORE the call the appointment may start. Default 90. */ 5839 beforeMinutes?: number; 5840 /** 5841 * How far AFTER the call the appointment may start. Default 30. 5842 * Deliberately small: it bounds how far in the FUTURE the appointment may sit 5843 * relative to the call. A wide value (the original 240) means an agent 5844 * dialling hours early and missing texts someone who already has a booking 5845 * later that day, which reads as a mistake. \`beforeMinutes\` stays generous 5846 * because an agent running late IS still making the appointment call. 5847 */ 5848 afterMinutes?: number; 5849} 5850 5851/** 5852 * Union of all eligibility check types 5853 */ 5854/** 5855 * Check No Shopify Purchaser â skips leads that map to a Shopify customer with 5856 * order history, even when that history is NOT linked to the lead (searches 5857 * Shopify by the lead's phone/email; an exact unique match is auto-linked as a 5858 * side effect via @bigm/shared attemptAutoLinkShopifyCustomer). Guards cold-lead 5859 * openers from texting existing owners a sales pitch. Best-effort: a Shopify 5860 * API failure fails OPEN (logged) so a Shopify outage can't starve the drip. 5861 */ 5862/** 5863 * Check Inbound Email Is Human â skip when the triggering \`inbound_email\` event 5864 * is machinery: a bounce / delivery status notification (\`mailer-daemon@\`, 5865 * "Delivery Status Notification (Failure)"), an autoresponder / out-of-office, 5866 * or list/bulk mail. Measured 5â12 Sep 2026: 31 of 50 MMC inbound emails were 5867 * DSNs and every one raised a \`reply_email\` task on a real lead. Predicate lives 5868 * in @bigm/shared \`email-automation\` (also used by the service queue filter). 5869 */ 5870export interface CheckInboundEmailIsHumanCheck extends EligibilityCheckBase { 5871 type: 'check_inbound_email_is_human'; 5872} 5873 5874export interface CheckNoShopifyPurchaserCheck extends EligibilityCheckBase { 5875 type: 'check_no_shopify_purchaser'; 5876} 5877 5878/** 5879 * Route an inbound SMS by whose conversation it is (15 Sep 2026, Happiness Team). 5880 * Both teams text from the same tenant number, so the lane is read from context. 5881 * The conversation is SERVICE when any of these hold: 5882 * 1. the lead's most recent outbound_sms carries \`sentByLane: 'service'\` (a 5883 * tenant_service seat texted by hand â toolcall-send-sms stamps it), or 5884 * 2. that outbound's campaign row has \`sendingPurpose: 'service'\` (welcome order 5885 * SMS, any post-purchase workflow), or 5886 * 3. the lead has an upcoming, non-cancelled appointment with a service topic 5887 * (product_service / product_support) â covers the reminder and confirmation 5888 * texts for a service call-back. 5889 * \`lane: 'service'\` â eligible only when SERVICE; \`lane: 'sales'\` â eligible only 5890 * when NOT. A lookup failure reads as sales (today's behaviour), never as service.
5891 * The service inbound-SMS row uses 'service'; the sales row uses 'sales'. 5892 */ 5893export interface CheckLastOutboundSmsLaneCheck extends EligibilityCheckBase { 5894 type: 'check_last_outbound_sms_lane'; 5895 lane: 'service' | 'sales'; 5896} 5897 5898export type EligibilityCheck = 5899 | TriggerCheck 5900 | CheckInboundEmailIsHumanCheck 5901 | CheckLastOutboundSmsLaneCheck 5902 | RequireLeadIdCheck 5903 | ExcludeDraftOrderSourceCheck 5904 | CheckAlreadySentCheck 5905 | CheckAnyCampaignSentCheck 5906 | CheckDelayCheck 5907 | CheckRecoveryCheck 5908 | CheckNoOutboundCallCheck 5909 | CheckNoOutboundSmsCheck 5910 | CheckNoPriorOrderCheck 5911 | CheckNoSuccessfulContactCheck 5912 | CheckNoRecentEventCheck 5913 | CheckNoRecentInboundSmsCheck 5914 | CheckNoAgentEngagementCheck 5915 | CheckNoPersonSinceCardCheck 5916 | CheckNotDeclinedCheck 5917 | CheckFirstLeadSourceCheck 5918 | CheckLastLeadSourceCheck 5919 | CheckFirstEventTypeCheck 5920 | CheckFormIdCheck 5921 | CheckMaxListIdCheck 5922 | CheckMaxDispositionCheck 5923 | CheckEventPropertyCheck 5924 | CheckLastOutboundSmsCampaignCheck 5925 | CheckEventTimeOfDayCheck 5926 | CheckNoActiveAppointmentCheck 5927 | CheckAppointmentNoContactCheck 5928 | CheckSalesAttributionNeedsConfirmationCheck 5929 | CheckNoShopifyPurchaserCheck; 5930 5931/** 5932 * Per-channel eligibility configuration 5933 * Each channel has its own independent set of checks 5934 */ 5935export interface ChannelEligibilityConfig { 5936 /** Whether eligibility checking is enabled for this channel */ 5937 enabled: boolean; 5938 /** Array of checks to evaluate for this channel */ 5939 checks: EligibilityCheck[]; 5940} 5941 5942/** 5943 * Eligibility configuration for campaign execution 5944 * Per-channel: each selected channel has its own set of checks 5945 */ 5946export interface EligibilityConfig { 5947 /** Master switch - if false, all targets are eligible */ 5948 enabled: boolean; 5949 /** Per-channel eligibility checks */ 5950 channelChecks: Partial<Record<CampaignChannel, ChannelEligibilityConfig>>; 5951} 5952 5953 5954/** 5955 * Campaign trigger configuration 5956 * Stores information about the EventBridge rule that triggers campaign execution 5957 * Managed by the campaign-trigger-rules API (/campaigns/{id}/trigger) 5958 */ 5959export interface CampaignTrigger { 5960 /** Whether the trigger is currently enabled */ 5961 enabled: boolean; 5962 /** ARN of the EventBridge rule */ 5963 ruleArn?: string; 5964 /** Name of the EventBridge rule */ 5965 ruleName?: string; 5966 /** Event types that trigger this campaign (e.g., ['checkout.abandoned']). 5967 * Mutually exclusive with \`schedule\` â a workflow either fires on events OR on a cron, not both. */ 5968 eventTypes?: string[]; 5969 /** URL path patterns for site modal filtering (e.g., ['/products/*', '/collections/sale']) */ 5970 pagePatterns?: string[]; 5971 /** Cron-based trigger. When set, the workflow fires on the configured schedule 5972 * via a per-workflow EventBridge rule (created/updated by dataApi on save). 5973 * Mutually exclusive with \`eventTypes\`. */ 5974 schedule?: CampaignScheduleTrigger; 5975 /** UTC timestamp when trigger was created (ISO 8601) */ 5976 createdAt?: string; 5977 /** UTC timestamp when trigger was last updated (ISO 8601) */ 5978 updatedAt?: string; 5979} 5980 5981/** 5982 * Cron-based workflow trigger. Stored on \`CampaignTrigger.schedule\`. 5983 * 5984 * The dataApi reconciles a per-workflow EventBridge rule (\`wf-sched-{tenantId}-{suffix}\`) 5985 * whenever this is set/updated/cleared. The rule targets the campaign-execution-engine 5986 * Lambda and synthesises a \`campaign.execute\` event so the engine can resolve and 5987 * run the workflow's steps. 5988 */ 5989export interface CampaignScheduleTrigger { 5990 /** EventBridge cron expression â e.g. 'cron(0 20 * * ? *)' for daily 6am AEST. */ 5991 cronExpression: string; 5992 /** Display-only timezone hint (cron itself is UTC). e.g. 'Australia/Sydney'. */ 5993 timezoneHint?: string; 5994 /** Human-readable description shown in UI. e.g. 'Daily 6am AEST'. */ 5995 description?: string; 5996} 5997 5998/** 5999 * Campaign Context record stored in DynamoDB campaign-context table 6000 * 6001 * This is the primary type for campaign configuration stored in the campaign-context table. 6002 * It represents the complete configuration for a marketing campaign including templates, 6003 * landing pages, workflows, and offer configurations. 6004 */ 6005export interface CampaignContext { 6006 /** Primary key - Unique campaign identifier */ 6007 campaignId: string; 6008 6009 /** Campaign display name */ 6010 name: string; 6011 6012 /** Campaign description */ 6013 description?: string; 6014 6015 /** Application identifier (links to app configuration) */
6016 appId: string; 6017 6018 /** Tenant identifier (e.g., "masseuse-massage-store.myshopify.com") */ 6019 tenantId: string; 6020 6021 /** Campaign type */ 6022 campaignType?: CampaignType; 6023 6024 /** UTC timestamp when campaign starts (ISO 8601) */ 6025 startsAt?: string; 6026 6027 /** UTC timestamp when campaign ends (ISO 8601) */ 6028 endsAt?: string; 6029 6030 /** UTC timestamp when record was created (ISO 8601) */ 6031 createdAt: string; 6032 6033 /** UTC timestamp when record was last updated (ISO 8601) */ 6034 updatedAt: string; 6035 6036 /** 6037 * Channels to use for the campaign, in priority order. 6038 * First channel in array has highest priority. 6039 */ 6040 channels?: CampaignChannel[]; 6041 6042 /** 6043 * How to use the selected channels: 6044 * - 'first': Send via first available channel (fallback to next if unavailable) 6045 * - 'all': Send via all selected channels simultaneously 6046 */ 6047 channelMode?: ChannelMode; 6048 6049 /** Whether the campaign is currently active */ 6050 active: boolean; 6051 6052 /** 6053 * Send allow-list (Sep 2026, welcome-email plan). While this field is present, 6054 * EVERY send for the campaign â email and SMS, prod and dev tenant, engine or a 6055 * direct /toolcall/send-* call â is DROPPED unless the recipient is on the list. 6056 * Entries: \`user@domain\`, \`@domain\`, or a phone in any AU form. An empty array 6057 * blocks everything. Go-live = delete the field (one auditable row edit, no 6058 * deploy). Enforced by @bigm/shared \`sendAllowlistBlockReason\`. 6059 */ 6060 sendAllowlist?: string[]; 6061 6062 /** 6063 * Which shared mailbox this campaign's email goes out from (Sep 2026): 6064 * \`sales\` (default) â \`team@team.<domain>\`, \`service\` â \`service@team.<domain>\` 6065 * (post-purchase: welcome, delivery, dispatch, returns). The Happiness Team queue 6066 * is routed on the RECIPIENT address of the reply, so this is what decides which 6067 * queue a customer's reply lands in. Enforced by toolcall-send-email's forced 6068 * From rewrite; every existing row (field absent) behaves exactly as before. 6069 */ 6070 sendingPurpose?: 'sales' | 'service'; 6071 6072 /** Landing page configuration */ 6073 landing?: LandingPage; 6074 6075 /** Messages configuration (SMS and email templates) */ 6076 messages?: CampaignMessages; 6077 6078 /** Discount configuration */ 6079 discount?: CampaignDiscount; 6080 6081 /** Shipping configuration */ 6082 shipping?: CampaignShipping; 6083 6084 /** Free discount configuration */ 6085 freeDiscount?: FreeDiscountConfig; 6086 6087 /** Warranty configuration */ 6088 warranty?: WarrantyConfig; 6089 6090 /** Concierge configuration */ 6091 concierge?: ConciergeConfig; 6092 6093 /** Offer context (source and grouping) */ 6094 offerContext?: OfferContext; 6095 6096 /** Offer explanation (UI display) */ 6097 offerExplanation?: OfferExplanation; 6098 6099 /** Agent video configuration */ 6100 video?: CampaignVideoConfig; 6101 6102 /** Workflow definition for campaign execution */ 6103 workflow?: Workflow; 6104 6105 /** Schedule configuration for automated execution */ 6106 schedule?: CampaignSchedule; 6107 6108 /** Eligibility configuration for campaign execution */ 6109 eligibility?: EligibilityConfig; 6110 6111 /** Whether approval is required before sending SMS/email */ 6112 approvalRequired?: boolean; 6113 6114 /** Twilio phone number for SMS sending */ 6115 twilioPhoneNumber?: string; 6116 6117 /** 6118 * EventBridge trigger configuration 6119 * Managed by the campaign-trigger-rules API 6120 */ 6121 trigger?: CampaignTrigger; 6122 6123 /** 6124 * Link configuration (URL generation strategy) 6125 * Determines how campaign links are built: landing page, cart permalink, or draft order 6126 */ 6127 link?: CampaignLink; 6128 6129 /** 6130 * Pre-loaded cart products for form workflows 6131 * Used with cart-permalink link type to pre-populate the cart 6132 */ 6133 cartProducts?: Array<{ variantId: string; quantity: number }>; 6134 6135 /** 6136 * Substitution offer (2026-07-31): swap what the customer abandoned for a 6137 * DIFFERENT product, colour-matched from the trigger event. 6138 * 6139 * Built for the Restore+ (sold out) â Physio+ upgrade offer: the abandoned 6140 * checkout's Restore+ line supplies the colour, and \`map\` resolves it to the 6141 * Physio+ variant that becomes the draft order's only chair line. 6142 * 6143 * When set AND the trigger event yields a colour, this WINS over the 6144 * checkout's own line items (the offer is a substitution, not a recovery), 6145 * while \`load_checkout\` still supplies customer id + addresses for the draft. 6146 * When it resolves to nothing, the normal source precedence applies. 6147 */ 6148 variantMap?: { 6149 /** Dot-path to the array of abandoned line items. Default 'eventData.lineItems'. */ 6150 matchPath?: string; 6151 /** Element property holding the colour/option. Default 'variant_title'. */ 6152 matchField?: string; 6153 /** Only read the colour from lines whose product_id is in this list (the sold-out product). */ 6154 matchProductIds?: Array<string | number>; 6155 /** Colour (case-insensitive) â replacement Shopify variant id. */ 6156 map: Record<string, string | number>; 6157 /** Colour key used when the event yields no usable/mapped colour. */ 6158 defaultKey?: string; 6159 /** Quantity for the substituted line. Default 1. */ 6160 quantity?: number; 6161 }; 6162 6163 // âââ Backbook Campaign Fields (campaignType = 'backbook') âââ 6164 6165 /** 6166 * Reference to a row in the \`cohorts\` DynamoDB table. When set, the 6167 * batch-executor loads the cohort at run time and uses its \`filter\` as the 6168 * audience. Preferred over inline \`audienceFilter\` â cohort edits take 6169 * effect immediately on the next firing without re-baking EventBridge. 6170 */ 6171 cohortId?: string; 6172 6173 /** 6174 * Inline audience filter for backbook campaigns. 6175 * @deprecated â use \`cohortId\` and store the filter in the \`cohorts\` table. 6176 * Kept for one release of back-compat with pre-migration workflows. Once 6177 * the migration is complete and verified, this field will be removed and 6178 * the migration script will reject any workflow still carrying inline 6179 * audience. 6180 */ 6181 audienceFilter?: CampaignAudienceFilter; 6182 6183 /** Execution status for batch campaigns */ 6184 executionStatus?: CampaignExecutionStatus; 6185 6186 /** Last execution progress snapshot */ 6187 executionProgress?: CampaignExecutionProgress; 6188 6189 /** Scheduled execution time (ISO 8601) */ 6190 scheduledAt?: string; 6191 6192 /** 6193 * Multi-rail payment options rendered in recovery emails. 6194 * When \`paymentOptions.enabled\` is true, the \`add_payment_options_block\` 6195 * workflow step renders bank-transfer / Humm / Payright / BNPL CTAs 6196 * into the \`{{paymentOptionsBlock}}\` email placeholder. 6197 */ 6198 paymentOptions?: PaymentOptionsConfig; 6199} 6200
6201/** 6202 * Per-campaign toggles for the payment-options email block. 6203 * Static per-provider URLs (Payright apply link, BNPL badge image) live here 6204 * so non-default tenants can opt in without a code change. Bank-transfer 6205 * instructions are pulled live from Shopify's manual payment methods 6206 * by \`@bigm/shared\` â not stored here. 6207 */ 6208export interface PaymentOptionsConfig { 6209 /** Master toggle â when false the step emits an empty string. */ 6210 enabled: boolean; 6211 /** Render the Shopify manual-payment (bank transfer) row. */ 6212 showBankTransfer?: boolean; 6213 /** Render the Humm "pay over time" row. Requires tenant humm integration. */ 6214 showHumm?: boolean; 6215 /** Render the Payright row. Requires \`payright.applyUrl\`. */ 6216 showPayright?: boolean; 6217 /** Render the static BNPL / wallet badge strip. */ 6218 showBnplBadges?: boolean; 6219 /** Payright config (no platform integration â static apply link). */ 6220 payright?: { 6221 applyUrl: string; 6222 logoUrl?: string; 6223 }; 6224 /** Optional override for the BNPL badge strip HTML (else default is used). */ 6225 bnplBadgesHtml?: string; 6226} 6227 6228/** 6229 * Campaign Context creation input (omits auto-generated fields) 6230 */ 6231export interface CreateCampaignContextInput { 6232 campaignId: string; 6233 name: string; 6234 description?: string; 6235 appId: string; 6236 tenantId: string; 6237 campaignType?: CampaignType; 6238 startsAt?: string; 6239 endsAt?: string; 6240 channels?: CampaignChannel[]; 6241 channelMode?: ChannelMode; 6242 active: boolean; 6243 landing?: LandingPage; 6244 messages?: CampaignMessages; 6245 discount?: CampaignDiscount; 6246 shipping?: CampaignShipping; 6247 freeDiscount?: FreeDiscountConfig; 6248 warranty?: WarrantyConfig; 6249 concierge?: ConciergeConfig; 6250 offerContext?: OfferContext; 6251 offerExplanation?: OfferExplanation; 6252 workflow?: Workflow; 6253 schedule?: CampaignSchedule; 6254 eligibility?: EligibilityConfig; 6255 approvalRequired?: boolean; 6256 twilioPhoneNumber?: string; 6257 trigger?: CampaignTrigger; 6258 link?: CampaignLink; 6259 video?: CampaignVideoConfig; 6260 cartProducts?: Array<{ variantId: string; quantity: number }>; 6261 /** Pointer into \`cohorts\` table â preferred over inline \`audienceFilter\`. */ 6262 cohortId?: string; 6263 /** @deprecated â use \`cohortId\`. */ 6264 audienceFilter?: CampaignAudienceFilter; 6265 executionStatus?: CampaignExecutionStatus; 6266 executionProgress?: CampaignExecutionProgress; 6267 scheduledAt?: string; 6268 paymentOptions?: PaymentOptionsConfig; 6269} 6270 6271/** 6272 * Campaign Context update input (only updatable fields) 6273 */ 6274export interface UpdateCampaignContextInput { 6275 name?: string; 6276 description?: string; 6277 startsAt?: string; 6278 endsAt?: string; 6279 channels?: CampaignChannel[]; 6280 channelMode?: ChannelMode; 6281 active?: boolean; 6282 landing?: LandingPage; 6283 messages?: CampaignMessages; 6284 discount?: CampaignDiscount; 6285 shipping?: CampaignShipping; 6286 freeDiscount?: FreeDiscountConfig; 6287 warranty?: WarrantyConfig; 6288 concierge?: ConciergeConfig; 6289 offerContext?: OfferContext; 6290 offerExplanation?: OfferExplanation; 6291 workflow?: Workflow; 6292 schedule?: CampaignSchedule; 6293 eligibility?: EligibilityConfig; 6294 approvalRequired?: boolean; 6295 twilioPhoneNumber?: string; 6296 trigger?: CampaignTrigger; 6297 link?: CampaignLink; 6298 video?: CampaignVideoConfig; 6299 cartProducts?: Array<{ variantId: string; quantity: number }>; 6300 /** Pointer into \`cohorts\` table â preferred over inline \`audienceFilter\`. */ 6301 cohortId?: string; 6302 /** @deprecated â use \`cohortId\`. */ 6303 audienceFilter?: CampaignAudienceFilter; 6304 executionStatus?: CampaignExecutionStatus; 6305 executionProgress?: CampaignExecutionProgress; 6306 scheduledAt?: string; 6307 updatedAt?: string; 6308 paymentOptions?: PaymentOptionsConfig; 6309} 6310 6311/** 6312 * Mandatory fields for CampaignContext 6313 * These fields are required when creating/editing a campaign 6314 * Used for UI validation and highlighting 6315 */ 6316export const CAMPAIGN_MANDATORY_FIELDS = [ 6317 'campaignId', 6318 'name', 6319 'appId', 6320 'tenantId', 6321 'active', 6322] as const; 6323 6324/** 6325 * Nested mandatory fields with dot notation paths 6326 * Maps form field paths to their mandatory status 6327 */ 6328export const CAMPAIGN_MANDATORY_FIELD_PATHS: Record<string, boolean> = { 6329 'campaignId': true, 6330 'info.name': true, 6331 'info.appId': true, 6332 'info.tenantId': true, 6333 'info.active': true, 6334 'info.startsAt': true, 6335 'info.endsAt': true, 6336 'info.channels': true, 6337 'info.channelMode': true, 6338 'messages.sms.template': true, 6339 'messages.email.subject': true, 6340 'discount.code': true, 6341}; 6342 6343/** 6344 * Check if a field path is mandatory 6345 */ 6346export function isCampaignFieldMandatory(fieldPath: string): boolean { 6347 return CAMPAIGN_MANDATORY_FIELD_PATHS[fieldPath] === true; 6348} 6349 6350`,Oe=`/** 6351 * Campaign Send Type Definitions 6352 * 6353 * Based on Terraform schema: terraform/SHARED/create-dynamo-campaign-sends 6354 * 6355 * Campaign-sends table - Tracks which leads received which campaigns 6356 * PRIMARY KEY = leadId (hash key), campaignId (range key) 6357 * TIMESTAMPS = createdAt, updatedAt, sentAt, expiredAt 6358 * 6359 * GSIs:
6360 * - campaignSendsByCampaign: Query all leads in a campaign (sorted by sentAt) 6361 * - campaignSendsByLead: Query all campaigns sent to a lead (sorted by sentAt) 6362 * - campaignSendsByTenant: Query all campaign sends for a tenant (sorted by sentAt) 6363 */ 6364 6365/** 6366 * Campaign Send record stored in DynamoDB Campaign-sends table 6367 */ 6368export interface CampaignSend { 6369 /** Hash key - Lead identifier */ 6370 leadId: string; 6371 6372 /** Range key - Campaign identifier */ 6373 campaignId: string; 6374 6375 /** 6376 * Foreign key to EventCustomer.id (for joining back to original event). 6377 * 6378 * OPTIONAL because two row shapes share this table (2026-08-02 correction â 6379 * the type previously declared it required, which real rows contradicted): 6380 * - COMPLETED SEND rows, written by create-campaign-send: carry \`eventId\` 6381 * + \`active\`, never \`claimedBy\`. 6382 * - IN-FLIGHT CLAIM rows, written by campaign-execution-engine before the 6383 * workflow runs: carry \`claimedBy\`/\`claimedAt\`, no \`eventId\`/\`active\`. 6384 * \`attribute_exists(eventId)\` is the discriminator for "a send actually 6385 * happened", and is what releaseCampaignClaim keys its delete guard on. 6386 */ 6387 eventId?: string; 6388 6389 /** Whether this is the active campaign for the lead. Absent on claim rows. */ 6390 active?: boolean; 6391 6392 /** UTC timestamp when the campaign was sent (ISO 8601) */ 6393 sentAt: string; 6394 6395 /** Execution id of the engine invocation currently claiming this 6396 * (leadId, campaignId) slot. Present ONLY on in-flight claim rows; stripped 6397 * on release once a send has landed. */ 6398 claimedBy?: string; 6399 6400 /** When the claim was taken (ISO 8601). Written alongside \`claimedBy\` since 6401 * 2026-08-02; legacy claim rows may carry \`claimedBy\` without this. */ 6402 claimedAt?: string; 6403 6404 /** UTC timestamp when this campaign was superseded by a new one (ISO 8601, optional) */ 6405 expiredAt?: string; 6406 6407 /** Channel used. 'agent_action' appears on CLAIM rows written by the 6408 * engine for agent-action workflows (the campaign's channels[0]) â no 6409 * message is sent on that channel, so treat such rows as claims, not sends. 6410 * (Previously the union omitted it while prod rows carried it.) */ 6411 channel: 'sms' | 'email' | 'site' | 'agent_action'; 6412 6413 /** Tenant identifier (for multi-tenancy) */ 6414 tenantName: string; 6415 6416 /** UTC timestamp when record was created (ISO 8601) */ 6417 createdAt: string; 6418 6419 /** UTC timestamp when record was last updated (ISO 8601) */ 6420 updatedAt: string; 6421} 6422 6423/** 6424 * Campaign Send creation input (omits auto-generated fields) 6425 */ 6426export interface CreateCampaignSendInput { 6427 leadId: string; 6428 campaignId: string; 6429 eventId: string; 6430 active?: boolean; 6431 sentAt?: string; 6432 expiredAt?: string; 6433 channel: 'sms' | 'email' | 'site'; 6434 tenantName: string; 6435} 6436 6437/** 6438 * Campaign Send update input (only updatable fields) 6439 */ 6440export interface UpdateCampaignSendInput { 6441 active?: boolean; 6442 expiredAt?: string; 6443 updatedAt?: string; 6444} 6445 6446/** 6447 * Campaign Send attributes used in GSI queries 6448 */ 6449export interface CampaignSendGSIAttributes { 6450 /** For campaignSendsByCampaign GSI */ 6451 campaignId: string; 6452 sentAt: string; 6453 6454 /** For campaignSendsByLead GSI */ 6455 leadId: string; 6456 6457 /** For campaignSendsByTenant GSI */ 6458 tenantName: string; 6459} 6460 6461`,Ne=`/** 6462 * Cart Attributes - Type-Safe Shopify Cart Attribute Keys 6463 * 6464 * DESIGN: Uses branded types to make it IMPOSSIBLE to use wrong string literals. 6465 * You MUST use the exported constants - raw strings won't compile. 6466 * 6467 * This prevents the exact bug we had: campaign-redirect using "leadID" but 6468 * cart-permalink using "lead_id" - both would have been strings, so TypeScript 6469 * couldn't catch the mismatch. 6470 * 6471 * With branded types, you literally can't write: 6472 * url.searchParams.set('attributes[lead_id]', value) // â Won't compile 6473 * 6474 * You MUST write: 6475 * url.searchParams.set(CART_ATTR_PARAMS.leadId, value) // â Uses constant 6476 */ 6477 6478// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6479// BRANDED TYPE - Makes string literals incompatible 6480// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6481 6482/** 6483 * Branded string type that can ONLY be created through our constants. 6484 * Regular strings are NOT assignable to this type. 6485 */ 6486declare const CartAttrKeyBrand: unique symbol; 6487export type CartAttrKey = string & { readonly [CartAttrKeyBrand]: never }; 6488 6489/** 6490 * Branded string type for the URL parameter format: attributes[key] 6491 */ 6492declare const CartAttrParamBrand: unique symbol; 6493export type CartAttrParam = string & { readonly [CartAttrParamBrand]: never }; 6494 6495// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6496// CANONICAL ATTRIBUTE KEYS - The ONLY way to get valid keys 6497// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6498 6499/** 6500 * Campaign ID attribute key. 6501 * Format: campaignID (uppercase ID to match Shopify convention) 6502 */ 6503export const CART_ATTR_CAMPAIGN_ID = 'campaignID' as CartAttrKey; 6504 6505/** 6506 * Lead ID attribute key (CANONICAL). 6507 * Format: leadID (uppercase ID for consistency with campaignID) 6508 * 6509 * HISTORY: Previously there was a mismatch where some code used "leadId" (camelCase) 6510 * and other code used "leadID" (uppercase). This caused attribution to silently fail. 6511 */ 6512export const CART_ATTR_LEAD_ID = 'leadID' as CartAttrKey; 6513 6514/** 6515 * Legacy lead ID key for READERS only. 6516 * Some old URLs may have "leadId" (camelCase). Readers should check both. 6517 * @deprecated Writers must use CART_ATTR_LEAD_ID 6518 */ 6519export const CART_ATTR_LEAD_ID_LEGACY = 'leadId' as CartAttrKey; 6520 6521/** 6522 * Click ID attribute key. 6523 * Format: clickId (camelCase) 6524 */ 6525export const CART_ATTR_CLICK_ID = 'clickId' as CartAttrKey; 6526 6527/** 6528 * Phone attribute key. 6529 */ 6530export const CART_ATTR_PHONE = 'phone' as CartAttrKey; 6531 6532/** 6533 * Email attribute key. 6534 */ 6535export const CART_ATTR_EMAIL = 'email' as CartAttrKey; 6536 6537/** 6538 * Draft order attribute key. 6539 */ 6540export const CART_ATTR_DRAFT_ORDER = 'draftOrder' as CartAttrKey; 6541 6542// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6543// URL PARAMETER FORMAT - Pre-built "attributes[key]" strings 6544// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6545 6546/**
6547 * Build the URL parameter format for a cart attribute. 6548 * Returns: "attributes[keyName]" 6549 */ 6550function buildParam(key: CartAttrKey): CartAttrParam { 6551 return \`attributes[\${key}]\` as CartAttrParam; 6552} 6553 6554/** 6555 * Pre-computed URL parameter keys. 6556 * Use these with URLSearchParams.set() or when building query strings. 6557 * 6558 * @example 6559 * url.searchParams.set(CART_ATTR_PARAMS.campaignId, campaignId); 6560 * url.searchParams.set(CART_ATTR_PARAMS.leadId, leadId); 6561 */ 6562export const CART_ATTR_PARAMS = { 6563 campaignId: buildParam(CART_ATTR_CAMPAIGN_ID), 6564 leadId: buildParam(CART_ATTR_LEAD_ID), 6565 clickId: buildParam(CART_ATTR_CLICK_ID), 6566 phone: buildParam(CART_ATTR_PHONE), 6567 email: buildParam(CART_ATTR_EMAIL), 6568 draftOrder: buildParam(CART_ATTR_DRAFT_ORDER), 6569} as const; 6570 6571// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6572// TYPE-SAFE HELPER FUNCTIONS 6573// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6574 6575/** 6576 * Interface for URL-like objects (works with both browser URL and Node.js URL) 6577 */ 6578interface URLLike { 6579 searchParams: { 6580 set(name: string, value: string): void; 6581 }; 6582} 6583 6584/** 6585 * Set a cart attribute on a URL. 6586 * This function REQUIRES a CartAttrParam - you cannot pass a raw string. 6587 * 6588 * @example 6589 * // â This works - uses the constant 6590 * setCartAttrOnUrl(url, CART_ATTR_PARAMS.campaignId, 'my-campaign'); 6591 * 6592 * // â This WON'T COMPILE - raw string is not CartAttrParam 6593 * setCartAttrOnUrl(url, 'attributes[campaign_id]', 'my-campaign'); 6594 */ 6595export function setCartAttrOnUrl(url: URLLike, param: CartAttrParam, value: string): void { 6596 url.searchParams.set(param, value); 6597} 6598 6599/** 6600 * Set multiple cart attributes on a URL. 6601 * 6602 * @example 6603 * setCartAttrsOnUrl(url, { 6604 * [CART_ATTR_PARAMS.campaignId]: campaignId, 6605 * [CART_ATTR_PARAMS.leadId]: leadId, 6606 * }); 6607 */ 6608export function setCartAttrsOnUrl( 6609 url: URLLike, 6610 attrs: Partial<Record<CartAttrParam, string | undefined>> 6611): void { 6612 for (const [param, value] of Object.entries(attrs)) { 6613 if (value !== undefined && value !== null && value.trim() !== '') { 6614 url.searchParams.set(param, value); 6615 } 6616 } 6617} 6618 6619/** 6620 * Build a query params object for cart attributes. 6621 * Returns an object suitable for spreading into URLSearchParams or query string builders. 6622 * 6623 * @example 6624 * const params = buildCartAttrQueryParams({ 6625 * campaignId: 'my-campaign', 6626 * leadId: 'lead-123', 6627 * }); 6628 * // Result: { 'attributes[campaignID]': 'my-campaign', 'attributes[leadID]': 'lead-123' } 6629 */ 6630export function buildCartAttrQueryParams(attrs: { 6631 campaignId?: string; 6632 leadId?: string; 6633 clickId?: string; 6634 phone?: string; 6635 email?: string; 6636 draftOrder?: string; 6637}): Record<string, string> { 6638 const params: Record<string, string> = {}; 6639 6640 if (attrs.campaignId) params[CART_ATTR_PARAMS.campaignId] = attrs.campaignId; 6641 if (attrs.leadId) params[CART_ATTR_PARAMS.leadId] = attrs.leadId; 6642 if (attrs.clickId) params[CART_ATTR_PARAMS.clickId] = attrs.clickId; 6643 if (attrs.phone) params[CART_ATTR_PARAMS.phone] = attrs.phone; 6644 if (attrs.email) params[CART_ATTR_PARAMS.email] = attrs.email.toLowerCase().trim(); 6645 if (attrs.draftOrder) params[CART_ATTR_PARAMS.draftOrder] = attrs.draftOrder; 6646 6647 return params; 6648} 6649 6650// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6651// CART ATTRIBUTES OBJECT TYPE 6652// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 6653 6654/** 6655 * Type for cart attributes object (as received from Shopify). 6656 * Keys are the raw attribute names (not the attributes[] format). 6657 */ 6658export interface CartAttributes { 6659 campaignID?: string; 6660 leadID?: string; 6661 leadId?: string; // Legacy - readers should check both 6662 clickId?: string; 6663 phone?: string; 6664 email?: string; 6665 draftOrder?: string; 6666 [key: string]: string | undefined; 6667} 6668 6669/** 6670 * Extract leadId from cart attributes, checking canonical then legacy. 6671 * Use this helper to safely get leadId regardless of which format was used. 6672 */ 6673export function getLeadIdFromCartAttrs(attrs: CartAttributes | null | undefined): string | null { 6674 if (!attrs) return null; 6675 return attrs[CART_ATTR_LEAD_ID] || attrs[CART_ATTR_LEAD_ID_LEGACY] || null; 6676} 6677 6678/** 6679 * Extract campaignId from cart attributes. 6680 */ 6681export function getCampaignIdFromCartAttrs(attrs: CartAttributes | null | undefined): string | null { 6682 if (!attrs) return null; 6683 return attrs[CART_ATTR_CAMPAIGN_ID] || null; 6684} 6685`,Le=`/** 6686 * Cart Permalink URL Generation 6687 * 6688 * Shared utility for generating Shopify cart permalink URLs. 6689 * Used by both the campaign execution engine and the ShopDash w
6689orkflow test modal 6690 * to ensure consistent URL generation. 6691 * 6692 * URL Format: https://{store}.myshopify.com/cart/{variant}:{qty}?discount={code} 6693 * 6694 * IMPORTANT: This module uses cart attribute constants from cart-attributes.ts 6695 * to ensure consistency with the web pixel and other consumers. 6696 */ 6697 6698import { CART_ATTR_PARAMS } from './cart-attributes.js'; 6699 6700/** 6701 * Cart product item for permalink generation 6702 */ 6703export interface CartPermalinkProduct { 6704 /** Variant ID - can be GID format or numeric */ 6705 variantId: string | number 6706 /** Quantity of this variant */ 6707 quantity: number 6708} 6709 6710/** 6711 * Options for generating cart permalink 6712 */ 6713export interface CartPermalinkOptions { 6714 /** Shop domain (e.g., "my-store.myshopify.com" or "my-store") */ 6715 shopDomain: string 6716 /** Cart products to include */ 6717 products: CartPermalinkProduct[] 6718 /** Optional discount code to apply */ 6719 discountCode?: string 6720 /** Optional landing page path for non-cart links */ 6721 landingPagePath?: string 6722 /** Optional campaign ID for attribution */ 6723 campaignId?: string 6724 /** Optional lead ID for attribution */ 6725 leadId?: string 6726} 6727 6728/** 6729 * Result of cart permalink generation 6730 */ 6731export interface CartPermalinkResult { 6732 /** Full cart permalink URL */ 6733 permalink: string 6734 /** Link type that was generated */ 6735 linkType: 'cart-permalink' | 'landing-page' | 'homepage' 6736 /** Number of products that were skipped due to invalid variant IDs */ 6737 skippedProducts: number 6738} 6739 6740/** 6741 * Extract numeric variant ID from GID format or number 6742 * 6743 * Handles: 6744 * - GID format: "gid://shopify/ProductVariant/43932809068785" 6745 * - Numeric string: "43932809068785" 6746 * - Number: 43932809068785 6747 * 6748 * @param variantId - Variant ID in any supported format 6749 * @returns Numeric variant ID or null if invalid 6750 */ 6751export function extractVariantId(variantId: string | number | undefined): number | null { 6752 if (!variantId) return null 6753 6754 if (typeof variantId === 'number') { 6755 return variantId 6756 } 6757 6758 // Handle GID format: "gid://shopify/ProductVariant/43932809068785" 6759 const gidMatch = String(variantId).match(/ProductVariant\\/(\\d+)$/) 6760 if (gidMatch) { 6761 return parseInt(gidMatch[1], 10) 6762 } 6763 6764 // Try parsing as number 6765 const num = parseInt(String(variantId), 10) 6766 return isNaN(num) ? null : num 6767} 6768 6769/** 6770 * Normalize shop domain to full myshopify.com format 6771 * 6772 * @param shopDomain - Shop domain in various formats 6773 * @returns Full myshopify.com domain (e.g., "my-store.myshopify.com") 6774 */ 6775export function normalizeShopDomain(shopDomain: string): string { 6776 // Remove any protocol prefix 6777 let domain = shopDomain.replace(/^https?:\\/\\//, '') 6778 6779 // If already has .myshopify.com, return as-is 6780 if (domain.includes('.myshopify.com')) { 6781 return domain 6782 } 6783 6784 // If has custom domain suffix, return as-is 6785 if (domain.includes('.')) { 6786 return domain 6787 } 6788 6789 // Add .myshopify.com suffix 6790 return \`\${domain}.myshopify.com\` 6791} 6792 6793/** 6794 * Encode a query parameter value 6795 * Simple URL encoding for query parameters 6796 */ 6797function encodeParam(value: string): string { 6798 return encodeURIComponent(value) 6799} 6800 6801/** 6802 * Build query string from params object 6803 */ 6804function buildQueryString(params: Record<string, string>): string { 6805 const parts: string[] = [] 6806 for (const [key, value] of Object.entries(params)) { 6807 if (value) { 6808 parts.push(\`\${encodeParam(key)}=\${encodeParam(value)}\`) 6809 } 6810 } 6811 return parts.length > 0 ? \`?\${parts.join('&')}\` : '' 6812} 6813 6814/** 6815 * Generate a Shopify cart permalink URL 6816 * 6817 * This is the canonical function for generating cart URLs that should be used 6818 * by both the campaign execution engine and the ShopDash frontend to ensure 6819 * consistent URL generation. 6820 * 6821 * @param options - Cart permalink options 6822 * @returns Cart permalink result with URL and link type 6823 * 6824 * @example 6825 * // Cart with products and discount 6826 * generateCartPermalinkUrl({ 6827 * shopDomain: 'my-store.myshopify.com', 6828 * products: [ 6829 * { variantId: 'gid://shopify/ProductVariant/123', quantity: 1 }, 6830 * { variantId: 456, quantity: 2 } 6831 * ], 6832 * discountCode: 'SAVE10' 6833 * }) 6834 * // => { permalink: 'https://my-store.myshopify.com/cart/123:1,456:2?discount=SAVE10', linkType: 'cart-permalink', skippedProducts: 0 } 6835 * 6836 * @example 6837 * // No products + discount code: uses Shopify's /discount/ route to auto-apply 6838 * generateCartPermalinkUrl({ 6839 * shopDomain: 'my-store.myshopify.com', 6840 * products: [], 6841 * discountCode: 'WELCOME', 6842 * landingPagePath: '/collections/sale' 6843 * }) 6844 * // => { permalink: 'https://my-store.myshopify.com/discount/WELCOME?redirect=%2Fcollections%2Fsale', linkType: 'landing-page', skippedProducts: 0 } 6845 */ 6846export function generateCartPermalinkUrl(options: CartPermalinkOptions): CartPermalinkResult { 6847 const { shopDomain, products, discountCode, landingPagePath, campaignId, leadId } = options 6848 6849 const normalizedDomain = normalizeShopDomain(shopDomain) 6850 let skippedProducts = 0 6851 6852 // Build query params (shared across all link types) 6853 // IMPORTANT: Use CART_ATTR_PARAMS constants to ensure consistency with web pixel 6854 const queryParams: Record<string, string> = {} 6855 if (discountCode) { 6856 queryParams['discount'] = discountCode 6857 } 6858 if (campaignId) { 6859 queryParams[CART_ATTR_PARAMS.campaignId] = campaignId 6860 } 6861 if (leadId) { 6862 queryParams[CART_ATTR_PARAMS.leadId] = leadId 6863 } 6864 6865 // If we have products, build a cart permalink 6866 if (products && products.length > 0) { 6867 const cartItems: string[] = [] 6868 6869 for (const product of products) { 6870 const variantIdNum = extractVariantId(product.variantId) 6871 6872 if (variantIdNum === null) { 6873 skippedProducts++ 6874 continue 6875 } 6876 6877 const quantity = typeof product.quantity === 'number' && product.quantity > 0 6878 ? product.quantity 6879 : 1 6880 6881 cartItems.push(\`\${variantIdNum}:\${quantity}\`) 6882 } 6883 6884 if (cartItems.length > 0) { 6885 // Build cart path: variant:qty,variant:qty 6886 const cartPath = cartItems.join(',') 6887 // Add storefront=true to stay on cart page instead of redirecting to checkout 6888 queryParams['storefront'] = 'true' 6889 const queryString = buildQueryString(queryParams) 6890 const permalink = \`https://\${normalizedDomain}/cart/\${cartPath}\${queryString}\` 6891 6892 return { 6893 permalink, 6894 linkType: 'cart-permalink', 6895 skippedProducts, 6896 } 6897 } 6898 } 6899 6900 // No products: if we have a discount code, use Shopify's /discount/CODE route 6901 // This applies the discount to the customer's session and redirects to the landing page. 6902 // The ?discount= query param only works on /cart pages, not on landing pages or homepage. 6903 if (discountCode) { 6904 const redirectPath = landingPagePath 6905 ? (landingPagePath.startsWith('/') ? landingPagePath : \`/\${landingPagePath}\`) 6906 : '/' 6907 6908 // Build redirect URL with attribution params (no discount param - it's in the path) 6909 const attrParams: Record<string, string> = {} 6910 if (campaignId) { 6911 attrParams[CART_ATTR_PARAMS.campaignId] = campaignId 6912 } 6913 if (leadId) { 6914 attrParams[CART_ATTR_PARAMS.leadId] = leadId 6915 } 6916 6917 // Shopify's /discount/CODE route supports ?redirect= to land the customer on a specific page 6918 const redirectTarget = Object.keys(attrParams).length > 0 6919 ? \`\${redirectPath}\${buildQueryString(attrParams)}\` 6920 : redirectPath 6921 6922 const permalink = \`https://\${normalizedDomain}/discount/\${encodeParam(discountCode)}?redirect=\${encodeParam(redirectTarget)}\` 6923 6924 return { 6925 permalink, 6926 linkType: landingPagePath ? 'landing-page' : 'homepage', 6927 skippedProducts, 6928 } 6929 } 6930 6931 // If we have a landing page path (but no products and no discount) 6932 if (landingPagePath) { 6933 const path = landingPagePath.startsWith('/') ? landingPagePath : \`/\${landingPagePath}\` 6934 const queryString = buildQueryString(queryParams) 6935 const permalink = \`https://\${normalizedDomain}\${path}\${queryString}\` 6936 6937 return { 6938 permalink, 6939 linkType: 'landing-page', 6940 skippedProducts, 6941 } 6942 } 6943 6944 // Fallback to homepage 6945 const queryString = buildQueryString(queryParams) 6946 const permalink = \`https://\${normalizedDomain}/\${queryString}\` 6947 6948 return { 6949 permalink, 6950 linkType: 'homepage', 6951 skippedProducts, 6952 } 6953} 6954`,Ue=`/** 6955 * Operator Change Log Entry Types 6956 * 6957 * Operator-authored entries that timestamp meaningful changes to the platform 6958 * (deploys, ad-spend reallocations, workflow tweaks, incidents). Backed by the 6959 * \`change-log\` DynamoDB table (Terraform: SHARED/create-dynamo-change-log). 6960 * 6961 * Rendered on /dashboard/change-log alongside live revenue + ad-spend columns. 6962 * Created via the ShopDash UI ("+ Add entry" on /dashboard/change-log â 6963 * \`POST /data/change-log\`). 6964 * 6965 * Named \`OperatorChangeLogEntry\` to disambiguate from the unrelated 6966 * \`ChangeLogEntry\` in expertAnalysis.ts (which describes per-recommendation 6967 * proposal/close history inside an expert-analysis row). 6968 * 6969 * ## Tenant scoping 6970 * Each entry has a scope that determines which operators see it on their 6971 * timeline: 6972 * - **platform-wide** â entry stored once under PK \`__platform__\`. Every 6973 * operator sees it regardless of which tenants they're scoped to. 6974 * - **single-tenant** â entry stored once under PK = that tenantName. 6975 * - **multi-tenant subset** â entry fanned out: one row per affected 6976 * tenant (PK = each tenant), every copy carrying the full denormalised 6977 * \`tenantNames\` list so the UI can render "applies to A, B, C". 6978 * 6979 * The UI dedupes by \`entryId\` (stable ULID across all fanned-out copies). 6980 */ 6981 6982/** Sentinel partition key for entries that apply to every tenant. Mirrors 6983 * the same convention used in the \`business-events\` table. */ 6984export const CHANGE_LOG_PLATFORM_TENANT = '__platform__'; 6985 6986export interface OperatorChangeLogEntry { 6987 /** 6988 * Partition key â the tenant this row instance belongs to. For platform-wide
6989 * entries this is \`__platform__\`. For single-tenant entries this is the 6990 * tenantName. For multi-tenant subset entries this is one of the affected 6991 * tenants (and the entry is duplicated under each). 6992 */ 6993 tenantName: string; 6994 6995 /** 6996 * Sort key â ULID minted by the producer. Stable across every fanned-out 6997 * copy of the same entry, so the UI can dedupe. 6998 */ 6999 entryId: string; 7000 7001 /** 7002 * Denormalised list of every tenant this entry applies to. \`undefined\` or 7003 * empty means platform-wide (visible to every operator). When set with one 7004 * or more entries, the entry is scoped to exactly those tenants and the row 7005 * is fanned out under each. 7006 * 7007 * Always carries the full subset on every copy so a single row read tells 7008 * the UI what to render for the scope badge. 7009 */ 7010 tenantNames?: string[]; 7011 7012 /** 7013 * YYYY-MM-DD the entry attaches to. Operator-chosen; may be backdated. 7014 */ 7015 date: string; 7016 7017 /** Short headline, ~60 chars. */ 7018 title: string; 7019 7020 /** ~25 word business-impact sentence shown beneath the title. */ 7021 summary: string; 7022 7023 /** 7024 * Composite GSI sort key on \`byTenantDate\`: \`\${date}#\${entryId}\`. Producers 7025 * MUST denormalise at write time; mutators MUST keep in sync with \`date\`. 7026 */ 7027 dateEntryId: string; 7028 7029 /** Optional tags (e.g. ['internal'], ['ads']). */ 7030 tags?: string[]; 7031 7032 /** ISO 8601 write timestamp. */ 7033 createdAt: string; 7034 7035 /** Cognito sub of the author. Optional for back-imported rows. */ 7036 createdBy?: string; 7037} 7038 7039/** Payload accepted by \`POST /data/change-log\`. */ 7040export interface CreateOperatorChangeLogEntryInput { 7041 /** 7042 * Tenants this entry applies to. Omit (or pass an empty array) to mark the 7043 * entry platform-wide. Pass one tenant for single-tenant scope, multiple 7044 * tenants for a fan-out subset. 7045 */ 7046 tenantNames?: string[]; 7047 date: string; 7048 title: string; 7049 summary: string; 7050 tags?: string[]; 7051} 7052`,Be=`/** 7053 * Chat Types - Site Chat (Digital Clienteling) 7054 * 7055 * Types for the storefront chat system. Sessions and messages are stored 7056 * in the \`chat-messages\` DynamoDB table using a single-table design. 7057 * 7058 * PK: {shopDomain}#{sessionId} 7059 * SK: "META" for sessions, "MSG#{timestamp}#{messageId}" for messages 7060 */ 7061 7062// ============================================================================ 7063// Session 7064// ============================================================================ 7065 7066export type ChatSessionStatus = 'waiting' | 'active' | 'closed'; 7067 7068export interface ChatSessionMetadata { 7069 pageUrl?: string; 7070 productHandle?: string; 7071 productTitle?: string; 7072 productPrice?: string; 7073 cartTotal?: string; 7074} 7075 7076export interface ChatSession { 7077 /** Composite: {shopDomain}#{sessionId} */ 7078 pk: string; 7079 /** Always "META" for session records */ 7080 sk: 'META'; 7081 7082 shopDomain: string; 7083 sessionId: string; 7084 visitorId: string; 7085 leadId?: string; 7086 7087 status: ChatSessionStatus; 7088 7089 /** Agent email (null if unassigned) */ 7090 assignedAgent?: string; 7091 assignedAgentName?: string; 7092 assignedAgentAvatarUrl?: string; 7093 assignedAgentTitle?: string; 7094 assignedAgentLocation?: string; 7095 7096 createdAt: string; 7097 updatedAt: string; 7098 lastMessageAt: string; 7099 lastMessagePreview?: string; 7100 7101 /** Visitor info collected during chat */ 7102 visitorName?: string; 7103 visitorEmail?: string; 7104 visitorPhone?: string; 7105 7106 /** Product/page context at session start */ 7107 metadata?: ChatSessionMetadata; 7108 7109 /** TTL - 30 days after last activity */ 7110 expiresAt: number; 7111} 7112 7113// ============================================================================ 7114// Messages 7115// ============================================================================ 7116 7117export type ChatMessageSender = 'visitor' | 'agent' | 'ai' | 'system'; 7118export type ChatMessageType = 'text' | 'quick_reply' | 'image' | 'video' | 'system'; 7119 7120export interface ChatQuickReplyOption { 7121 label: string; 7122 action: string; 7123 payload?: Record<string, unknown>; 7124} 7125 7126export interface ChatMessage { 7127 /** Composite: {shopDomain}#{sessionId} */ 7128 pk: string; 7129 /** Format: MSG#{ISO timestamp}#{messageId} */ 7130 sk: string; 7131 7132 messageId: string; 7133 sender: ChatMessageSender; 7134 senderName?: string; 7135 senderAvatarUrl?: string; 7136 7137 body: string; 7138 messageType: ChatMessageType; 7139 7140 /** Quick reply buttons offered to the customer */ 7141 quickReplyOptions?: ChatQuickReplyOption[]; 7142 /** Which quick reply action the customer selected */ 7143 quickReplyAction?: string; 7144 7145 /** S3 URLs for images/videos */ 7146 mediaUrls?: string[]; 7147 mediaContentTypes?: string[]; 7148 7149 createdAt: string; 7150 /** TTL - 30 days */ 7151 expiresAt: number; 7152} 7153 7154// ============================================================================ 7155// Agent Quick Replies 7156// ============================================================================ 7157 7158export interface AgentQuickReply { 7159 id: string; 7160 /** Short display text for the button */ 7161 label: string; 7162 /** Full message body (supports {variables}) */ 7163 body: string; 7164 /** Grouping: "contact", "offer", "product", "custom" */ 7165 category?: string; 7166 /** Available template variables */ 7167 variables?: string[]; 7168 /** Embedded video/image URL for MMS attachment */ 7169 mediaUrl?: string; 7170 /** Display label for the media */ 7171 mediaLabel?: string; 7172 /** Channel this reply belongs to (sms, chat, etc.) â omitted = all channels */ 7173 channel?: string; 7174} 7175 7176// ============================================================================ 7177// Tenant Chat Config (stored on TenantConfig.chatConfig) 7178// ============================================================================ 7179 7180export interface ChatBusinessHours { 7181 start: string; 7182 end: string; 7183 timezone: string; 7184} 7185 7186export interface SiteChatConfig { 7187 enabled: boolean; 7188 /** First message template with {productTitle}, {agentName}, {location} */ 7189 autoGreeting?: string; 7190 /** URL patterns where chat widget appears: ["/products/*", "/collections/*"] */ 7191 availablePages?: string[]; 7192 /** When chat is available */ 7193 businessHours?: ChatBusinessHours; 7194 /** Text shown outside hours or when all agents busy */ 7195 offlineMessage?: string; 7196 /** Minutes of inactivity before session auto-closes (default 10) */ 7197 inactivityTimeoutMinutes?: number; 7198} 7199`,Fe=`/** 7200 * Cohort Type Definitions 7201 * 7202 * Reusable audience definitions stored per-tenant that workflows reference 7203 * by ID instead of carrying an inline \`audienceFilter\`. Table: \`cohorts\` 7204 * (PK: tenantId, SK: cohortId). 7205 * 7206 * A cohort is a tenant-scoped, named, persisted \`CampaignAudienceFilter\` 7207 * (minus the runtime-injected \`tenantId\`). One cohort can be referenced by 7208 * many workflows via \`CampaignContext.cohortId\`. The batch-executor resolves 7209 * the cohort at run time, so edits to a cohort take effect on the very next 7210 * cron firing for every referring workflow â no EventBridge re-bake required. 7211 * 7212 * Mirrors the \`discount-rules\` reference precedent (\`discount-rules.ts\`). 7213 */ 7214 7215import type { CampaignAudienceFilter } from './campaign-audience.js'; 7216 7217/** 7218 * Loose grouping for cohort browsing in the UI. Not load-bearing â operators 7219 * can reassign at will. 7220 */ 7221export type CohortCategory = 7222 | 'recovery' // win-back / lapsed customers 7223 | 'nurture' // engaged but not yet purchased 7224 | 'retention' // repeat / upsell 7225 | 'vip' // high-LTV 7226 | 'acquisition' // top-of-funnel cohort 7227 | 'operational'; // internal / housekeeping (e.g. data hygiene drips) 7228 7229export const VALID_COHORT_CATEGORIES: readonly CohortCategory[] = [ 7230 'recovery', 'nurture', 'retention', 'vip', 'acquisition', 'operational', 7231] as const; 7232 7233/** 7234 * Cohort row stored in the \`cohorts\` DynamoDB table. 7235 * 7236 * The \`filter\` field is a \`CampaignAudienceFilter\` with \`tenantId\` stripped 7237 * (it's the table's PK; injecting it at resolution time keeps the row 7238 * portable should we ever want to clone a cohort across tenants). 7239 */ 7240export interface Cohort { 7241 /** PK â tenant identifier (e.g. "store.myshopify.com") */ 7242 tenantId: string; 7243
7244 /** SK â unique cohort id, format \`cohort_<base36-ts>_<rand6>\` */ 7245 cohortId: string; 7246 7247 /** Operator-facing name */ 7248 name: string; 7249 7250 /** Optional longer description */ 7251 description?: string; 7252 7253 /** Loose UI grouping (see VALID_COHORT_CATEGORIES) */ 7254 category?: CohortCategory; 7255 7256 /** 7257 * The audience filter. \`tenantId\` is intentionally stripped â the 7258 * resolver in \`campaign-batch-executor\` injects it from the cohort row's 7259 * PK at runtime. 7260 */ 7261 filter: Omit<CampaignAudienceFilter, 'tenantId'>; 7262 7263 /** 7264 * Stable hash of \`filter\` â used by the migration script to dedupe 7265 * cohorts that were extracted from identical inline \`audienceFilter\`s. 7266 * Recomputed on every PUT. 7267 */ 7268 filterHash: string; 7269 7270 /** Whether this cohort is available for new workflow assignments */ 7271 enabled: boolean; 7272 7273 /** ISO 8601 â UTC timestamp when the cohort was created */ 7274 createdAt: string; 7275 7276 /** ISO 8601 â UTC timestamp of last edit */ 7277 updatedAt: string; 7278 7279 /** Cognito sub or email of creator */ 7280 createdBy?: string; 7281 7282 /** Cognito sub or email of last editor */ 7283 updatedBy?: string; 7284 7285 /** 7286 * Last time the preview endpoint ran against this cohort. 7287 * Updated as a side-effect of \`GET /data/campaign-audience/preview?cohortId=â¦\`. 7288 */ 7289 lastPreviewedAt?: string; 7290 7291 /** Phase-1 lead-row count from the last preview run (excludes Phase-2 event-scan) */ 7292 lastPreviewSize?: number; 7293} 7294 7295/** 7296 * Payload accepted by \`POST /data/cohorts\`. 7297 * Server fills in \`cohortId\`, \`filterHash\`, \`createdAt\`, \`updatedAt\`. 7298 */ 7299export interface CreateCohortInput { 7300 name: string; 7301 description?: string; 7302 category?: CohortCategory; 7303 filter: Omit<CampaignAudienceFilter, 'tenantId'>; 7304 enabled?: boolean; 7305} 7306 7307/** 7308 * Payload accepted by \`PUT /data/cohorts/{cohortId}\`. 7309 * Any field omitted is left unchanged. \`filterHash\` is recomputed server-side 7310 * if \`filter\` is present. 7311 */ 7312export interface UpdateCohortInput { 7313 name?: string; 7314 description?: string; 7315 category?: CohortCategory; 7316 filter?: Omit<CampaignAudienceFilter, 'tenantId'>; 7317 enabled?: boolean; 7318} 7319 7320/** 7321 * Generate a unique cohort id. Mirrors \`generateRuleId()\` in \`discount-rules.ts\`.
7322 * Format: \`cohort_<base36-timestamp>_<6-char-random>\` 7323 */ 7324export function generateCohortId(): string { 7325 const timestamp = Date.now().toString(36); 7326 const random = Math.random().toString(36).substring(2, 8); 7327 return \`cohort_\${timestamp}_\${random}\`; 7328} 7329 7330/** 7331 * Stable JSON serialiser â sorts object keys so two semantically-equal 7332 * objects produce identical strings. Used by \`computeFilterHash\`. 7333 */ 7334function stableStringify(value: unknown): string { 7335 if (value === null || value === undefined) return JSON.stringify(value); 7336 if (typeof value !== 'object') return JSON.stringify(value); 7337 if (Array.isArray(value)) { 7338 return \`[\${value.map(stableStringify).join(',')}]\`; 7339 } 7340 const obj = value as Record<string, unknown>; 7341 const keys = Object.keys(obj).sort(); 7342 const parts = keys.map(k => \`\${JSON.stringify(k)}:\${stableStringify(obj[k])}\`); 7343 return \`{\${parts.join(',')}}\`; 7344} 7345 7346/** 7347 * djb2 hash â fast, zero-dependency, sufficient for cohort dedup 7348 * (collision-rate negligible for the ~hundreds of cohorts per tenant scale 7349 * we expect). Returns an unsigned 32-bit hex string. 7350 * 7351 * Why not SHA-256: the types package is consumed by frontend code via the 7352 * dual-repo sync. Pulling in \`node:crypto\` would bloat the Vite bundle. 7353 * djb2 is identical on every JS runtime. 7354 */ 7355export function computeFilterHash( 7356 filter: Omit<CampaignAudienceFilter, 'tenantId'> 7357): string { 7358 const stable = stableStringify(filter); 7359 let hash = 5381; 7360 for (let i = 0; i < stable.length; i++) { 7361 // hash * 33 ^ char 7362 hash = ((hash << 5) + hash) ^ stable.charCodeAt(i); 7363 } 7364 // force unsigned 32-bit + zero-pad hex 7365 return (hash >>> 0).toString(16).padStart(8, '0'); 7366} 7367 7368/** 7369 * Validate a cohort row. Used server-side on create + update; not load-bearing 7370 * (the executor will reject misconfigured cohorts loudly at run time) but 7371 * surfaces obvious mistakes early. 7372 */ 7373export function validateCohort( 7374 cohort: Partial<Cohort> 7375): { valid: boolean; errors: string[] } { 7376 const errors: string[] = []; 7377 if (!cohort.tenantId) errors.push('tenantId is required'); 7378 if (!cohort.cohortId) errors.push('cohortId is required'); 7379 if (!cohort.name || cohort.name.trim().length === 0) errors.push('name is required'); 7380 if (!cohort.filter) errors.push('filter is required'); 7381 if (cohort.category && !VALID_COHORT_CATEGORIES.includes(cohort.category)) { 7382 errors.push(\`category must be one of: \${VALID_COHORT_CATEGORIES.join(', ')}\`); 7383 } 7384 return { valid: errors.length === 0, errors }; 7385} 7386`,He=`/** 7387 * delivery-booking.ts â ONE set of rules for "can this order's delivery be booked 7388 * right now", shared by the Book delivery button and the AI. 7389 * 7390 * Chris, 23 Sep 2026: "I want to be able to book a delivery like when you can in the 7391 * UI with the delivery button below the Shopify sales order. There are rules as to 7392 * when that button can be enabled and disabled. I want to reuse those rules. And I 7393 * also want the rules that the AI and the button use to be shared so when they're 7394 * changed, they are changed for both." 7395 * 7396 * â WHAT IS ALREADY SHARED, and what this file adds. I got this wrong once on 23 Sep 7397 * 2026 and the correction matters more than the original claim: I first read shopdash's 7398 * MAIN checkout, which sits hundreds of commits behind \`origin/dev\`, and concluded that 7399 * \`assessBookingEligibility\` had zero callers. It has callers. On \`origin/dev\` the 7400 * FREIGHT half is properly shared already â dataApi imports it from \`@bigm/shared\` and 7401 * runs it twice, once for \`GET /data/sap/booking-eligibility\` to render the button 7402 * state and again inside \`booking-create\` against live stock, so the UI gate cannot be 7403 * bypassed. Do not re-derive stock, ATP, interstate or TAS/NT rules here; they are 7404 * done, and they are done well. 7405 * 7406 * What is NOT shared is the other half: whether the button should be offered at all. 7407 * That still lives in a private \`canBookDelivery()\` inside 7408 * \`shopdash/src/components/messaging/OrdersPane.vue\` â is the order confirmed, is it 7409 * paid, was a booking already keyed, is a delivery already in flight â and a Vue 7410 * function is unreachable from the engine, so the AI cannot honour the same rules. 7411 * This file lifts exactly that half, and adds the thing neither half had: something a 7412 * customer can actually be told when the answer is no. 7413 * 7414 * The lesson is worth keeping: check the branch a tree is on before concluding that 7415 * something is missing from it. 7416 * 7417 * Two audiences, one decision. An operator wants the facility code and the ATP count; 7418 * a customer must never hear either. So every outcome carries BOTH an \`operatorDetail\` 7419 * (the button's tooltip, unchanged from the freight assessor) and a \`customerLine\` 7420 * drawn from what the team itself writes (see \`DELIVERY_HOLDING_LINES\`). 7421 * 7422 * â AND THE RULE THAT MATTERS MOST: a delivery that cannot be booked NEVER stops the 7423 * sale. Chris, 23 Sep 2026: "there'll be other things to continue with the sale 7424 * without the promise of a delivery on a particular day." \`blocksSale\` is therefore 7425 * \`false\` on every outcome in this file; the only thing a block withholds is the DATE. 7426 * 7427 * Lives in \`@bigm/types\` on purpose: it is pure, it has no AWS SDK in it, and Vue can 7428 * import it directly. A bare value import from \`@bigm/shared\` would be aliased to the 7429 * redact-cards leaf by shopdash's vite config and break the production build 7430 * (memory \`reference-shopdash-bigm-shared-browser-alias\`); \`resellerBookingEligibility\` 7431 * is the existing precedent for putting a shared gate here. 7432 */ 7433import type { BookingEligibility, BookingEligibilityReason } from './freight.js'; 7434 7435/** Why the button itself is off, before freight is even consulted. */ 7436export type DeliveryGateReason = 7437 | 'no_grant' // the operator is not in the booking pilot 7438 | 'not_winnings_tenant' // this tenant has no Winnings freight at all 7439 | 'order_not_confirmed' // the order event is not a confirmed/amended order 7440 | 'not_paid' // financialStatus is neither paid nor partially_paid 7441 | 'already_recorded' // a booking was keyed for this order in this session 7442 | 'delivery_in_flight'; // a delivery already exists and is not failed/cancelled 7443 7444export type DeliveryBlockReason = DeliveryGateReason | BookingEligibilityReason; 7445 7446/** 7447 * How a block should be SPOKEN ABOUT, as opposed to why it happened. A customer does
7448 * not care whether the stock is short or interstate: they care whether a date can be 7449 * named today. Collapsing the reasons here is what makes one holding line correct for 7450 * many causes, which is what Chris asked for ("a generic response"). 7451 */ 7452export type DeliveryHoldKind = 7453 | 'datable' // a date may be offered 7454 | 'awaiting_payment' 7455 | 'awaiting_stock' 7456 | 'team_will_arrange'; 7457 7458export interface DeliveryDateOutcome { 7459 /** May we put specific delivery dates in front of this customer? */ 7460 canPromiseDate: boolean; 7461 /** Never true. A delivery block withholds the date, never the sale. */ 7462 blocksSale: false; 7463 reason: DeliveryBlockReason | null; 7464 hold: DeliveryHoldKind; 7465 /** The operator's sentence: facility, ATP, what to do. Safe for the button only. */ 7466 operatorDetail: string; 7467 /** The customer's sentence, from the team's own wording. Safe to send. */ 7468 customerLine: string; 7469} 7470 7471export interface DeliveryButtonGateInput { 7472 /** The operator is in the booking pilot (or an app admin). */ 7473 hasBookingGrant: boolean; 7474 /** This tenant runs Winnings freight. */ 7475 isWinningsTenant: boolean; 7476 /** The order event's state, as the orders pane computes it. */ 7477 orderState?: string; 7478 /** Shopify financial status, lower case or not. */ 7479 financialStatus?: string; 7480 /** A booking was keyed for this order already in this session. */ 7481 alreadyRecorded?: boolean; 7482 /** Stage of the latest delivery event for this order, if any. */ 7483 deliveryStage?: string | null; 7484} 7485 7486/** Delivery stages an agent may key a FRESH booking over. Mirrors the orders pane. */ 7487export const REBOOKABLE_DELIVERY_STAGES: readonly string[] = ['failed', 'cancelled']; 7488 7489/** Financial statuses that count as paid enough to arrange a delivery. */ 7490export const PAID_FINANCIAL_STATUSES: readonly string[] = ['paid', 'partially_paid']; 7491 7492export const isOrderPaidForDelivery = (financialStatus?: string): boolean => 7493 PAID_FINANCIAL_STATUSES.includes(String(financialStatus ?? '').toLowerCase()); 7494 7495/** 7496 * The button's own rules, extracted verbatim from \`canBookDelivery()\` in 7497 * \`OrdersPane.vue\` so the two cannot drift. Returns \`null\` when nothing blocks and 7498 * the freight assessment should decide. 7499 */ 7500export function assessDeliveryButtonGate(input: DeliveryButtonGateInput): DeliveryGateReason | null { 7501 if (!input.hasBookingGrant) return 'no_grant'; 7502 if (!input.isWinningsTenant) return 'not_winnings_tenant'; 7503 if (input.orderState !== 'confirmed') return 'order_not_confirmed'; 7504 if (!isOrderPaidForDelivery(input.financialStatus)) return 'not_paid'; 7505 if (input.alreadyRecorded) return 'already_recorded'; 7506 if (input.deliveryStage && !REBOOKABLE_DELIVERY_STAGES.includes(input.deliveryStage)) return 'delivery_in_flight'; 7507 return null; 7508} 7509 7510const HOLD_OF: Record<DeliveryBlockReason, DeliveryHoldKind> = { 7511 // Gate reasons 7512 no_grant: 'team_will_arrange', 7513 not_winnings_tenant: 'team_will_arrange', 7514 order_not_confirmed: 'team_will_arrange', 7515 not_paid: 'awaiting_payment', 7516 already_recorded: 'team_will_arrange', 7517 delivery_in_flight: 'team_will_arrange', 7518 // Freight reasons 7519 no_stock: 'awaiting_stock', 7520 low_stock: 'awaiting_stock', 7521 interstate: 'team_will_arrange', 7522 transfer: 'team_will_arrange', 7523 blocked: 'team_will_arrange', 7524}; 7525 7526/** The operator sentence for a gate reason. Freight reasons bring their own \`detail\`. */ 7527const OPERATOR_DETAIL: Record<DeliveryGateReason, string> = { 7528 no_grant: 'Delivery booking is limited to the pilot group.', 7529 not_winnings_tenant: 'This tenant does not use Winnings freight.', 7530 order_not_confirmed: 'Needs a confirmed order (order.confirmed or order.amended).', 7531 not_paid: 'Not paid yet â delivery is arranged once payment clears.', 7532 already_recorded: 'A booking was already keyed for this order.', 7533 delivery_in_flight: 'A delivery already exists for this order.', 7534}; 7535 7536/** 7537 * What the customer may be told, per hold kind. 7538 * 7539 * â THESE ARE NOT MY SENTENCES. Each one is the team's own, taken from their real 7540 * outbound SMS and email by 7541 * \`BigM/aws-scripts-process/seed-offer-config-2026-09/mine-delivery-holding-language.mjs\` 7542 * over 180 days of both tenants: 6,694 outbound messages mention delivery, of which 7543 * 1,238 sentences DEFER a date and 1,955 PROMISE one. Only the deferring half is 7544 * eligible here, and then only the 591 that are customer-facing (the script's own 7545 * clusters are thick with staff-to-supplier mail and signature blocks, which are 7546 * filtered out). The mined JSON is committed beside the script, so any line here can 7547 * be traced back to the messages it came from. 7548 * 7549 * Provenance of each line, by distinct leads it was sent to: 7550 * - team_will_arrange 26 leads, SMS + email, both tenants. The highest-evidence 7551 * line in the corpus and the one Chris quoted from memory: 7552 * "our Happiness Team will be in contact with you shortly to 7553 * organise delivery of your brand new Masseuse Massage chair." 7554 * Kept verbatim minus the brand's product name. 7555 * - awaiting_stock MHC SMS, 2026: "Once it does, we will c
7555ontact you to book you 7556 * in for a delivery date that suits." Same shape as the 7557 * EverGlow pre-order email used on 5 leads. 7558 * - awaiting_payment MMC SMS: "Let me know once that is paid and I will organise 7559 * delivery", and MHC SMS: "Yes as soon as it's paid we will 7560 * dispatch." Kept in the agent's own first person, because the 7561 * AI speaks as a named agent. 7562 * 7563 * Replacing a line means re-running the miner and taking another real one, not 7564 * writing a better sentence. 7565 * 7566 * â MEASURED, NOT WRITTEN. Chris, 23 Sep 2026: "there should be many examples of how 7567 * existing physical sales agents handle it." There are. Over 180 days, 225 outbound SMS 7568 * sent by a PERSON (\`eventData.actor === 'agent_physical'\`, so no workflow is feeding the 7569 * AI its own output) mention delivery and promise contact, and they cluster hard onto one 7570 * shape â "they will be in contact with you very soon to arrange a day and time to deliver 7571 * and install", sent by name on 30+ threads. The templates below are that phrasing, with 7572 * the MMC-specific "White Glove installer" and "install" generalised so MHC's kerbside 7573 * deliveries read correctly too. Miner: \`aws-scripts-process/seed-offer-config-2026-09\`. 7574 * 7575 * GSM-7 only: no em dash, en dash, smart quotes or emoji, or the SMS doubles in cost 7576 * (memory \`reference-sms-gsm7-segment-cost\`). 7577 */ 7578const HOLDING_LINE_TEMPLATES: Record<Exclude<DeliveryHoldKind, 'datable'>, string> = { 7579 // "Thanks Terry. Payment received. Our delivery team will be in contact tomorrow to 7580 // organise the delivery for you. Steve" 7581 awaiting_payment: 'Once that is paid {{team}} will be in contact to organise the delivery for you.', 7582 awaiting_stock: 'We will contact you to book you in for a delivery date that suits.', 7583 // The team's most-used line by a distance, sent by name on 30+ real threads: "Your chair 7584 // has arrived with your White Glove installer. They will be in contact with you very soon 7585 // to arrange a day and time to deliver and install your awesome new chair." 7586 team_will_arrange: '{{team}} will be in contact with you very soon to arrange a day and time for your delivery.', 7587}; 7588 7589/** 7590 * The tenant's own name for the people who ring the customer. MMC and MHC both sign 7591 * off as the Happiness Team; a tenant that has not said gets the neutral default, so 7592 * no tenant ever inherits another's wording. 7593 */ 7594export const DEFAULT_DELIVERY_TEAM_NAME = 'our team'; 7595 7596/** The customer-safe sentence for a hold, with the tenant's team name in it. */ 7597export function deliveryHoldingLine(hold: DeliveryHoldKind, teamName?: string): string { 7598 if (hold === 'datable') return ''; 7599 const team = (teamName || '').trim() || DEFAULT_DELIVERY_TEAM_NAME; 7600 const line = HOLDING_LINE_TEMPLATES[hold].replace('{{team}}', team); 7601 return line.charAt(0).toUpperCase() + line.slice(1); 7602} 7603 7604/** Frozen snapshot of the neutral wording, for tests and for the prompt's examples. */ 7605export const DELIVERY_HOLDING_LINES: Record<Exclude<DeliveryHoldKind, 'datable'>, string> = { 7606 awaiting_payment: deliveryHoldingLine('awaiting_payment'), 7607 awaiting_stock: deliveryHoldingLine('awaiting_stock'), 7608 team_will_arrange: deliveryHoldingLine('team_will_arrange'), 7609}; 7610 7611export interface DeliveryDateOutcomeInput extends DeliveryButtonGateInput { 7612 /** The freight assessment, when a sheet could be built. Absent = not assessed yet. */ 7613 freight?: Pick<BookingEligibility, 'eligible' | 'reason' | 'detail'> | null; 7614 /** The tenant's name for its delivery people, e.g. "our Happiness Team". */ 7615 teamName?: string; 7616} 7617 7618/** 7619 * The one call both the button and the AI make. The button reads \`canPromiseDate\` and 7620 * \`operatorDetail\`; the AI reads \`canPromiseDate\` and, when false, says \`customerLine\` 7621 * and keeps selling. 7622 */ 7623export function assessDeliveryDate(input: DeliveryDateOutcomeInput): DeliveryDateOutcome { 7624 const gate = assessDeliveryButtonGate(input); 7625 const blocked = (reason: DeliveryBlockReason, operatorDetail: string): DeliveryDateOutcome => { 7626 const hold = HOLD_OF[reason]; 7627 return { 7628 canPromiseDate: false, 7629 blocksSale: false, 7630 reason, 7631 hold, 7632 operatorDetail, 7633 customerLine: deliveryHoldingLine(hold, input.teamName), 7634 }; 7635 }; 7636 if (gate) return blocked(gate, OPERATOR_DETAIL[gate]); 7637 if (input.freight && !input.freight.eligible) { 7638 const reason = input.freight.reason ?? 'blocked'; 7639 return blocked(reason, input.freight.detail || OPERATOR_DETAIL.not_winnings_tenant); 7640 } 7641 return { 7642 canPromiseDate: true, 7643 blocksSale: false, 7644 reason: null, 7645 hold: 'datable', 7646 operatorDetail: input.freight?.detail || 'Ready to book.', 7647 customerLine: '', 7648 }; 7649} 7650`,Ve=`/** 7651 * Delivery "good to go" confirmation. 7652 * 7653 * An APP-INTERNAL readiness sign-off on a Winnings 3PL delivery â a human gate 7654 * before the (not-yet-automated) Winnings booking step. It is NOT a Winnings 7655 * event and is never pushed to Winnings/SAP; delivery.* events remain read-only. 7656 * 7657 * Table: \`delivery-confirmations\` (PK: tenantId, SK: deliveryNumber). 7658 * Terraform: terraform/SHARED/create-dynamo-delivery-confirmations. 7659 * 7660 * \`confirmedBy\` is stamped server-side from the verified Cognito ID token in the 7661 * dataApi handler â never trusted from the browser. \`confirmedByName\` is a 7662 * display-only convenience captured from the client at write time. 7663 */ 7664 7665/** One confirmation row keyed by (tenantId, deliveryNumber). */ 7666export interface DeliveryConfirmation { 7667 /** Tenant (shop domain). PK. */ 7668 tenantId: string; 7669 7670 /** SAP delivery number this sign-off belongs to. SK. */ 7671 deliveryNumber: string; 7672 7673 /** True = a human confirmed this delivery is good to go. */ 7674 confirmedGoodToGo: boolean; 7675 7676 /** Verified email of the last person who set the flag (server-stamped). */ 7677 confirmedBy: string; 7678 7679 /** Display name of that person at write time (client-supplied, display only). */ 7680 confirmedByName?: string; 7681 7682 /** ISO 8601 â when the flag was last set (server-stamped). */ 7683 confirmedAt: string; 7684 7685 /** ISO 8601 â UTC last-edit timestamp (server-stamped). */ 7686 updatedAt: string; 7687} 7688 7689/** Body accepted by PUT /data/delivery-confirmations/:deliveryNumber. */ 7690export interface SetDeliveryConfirmationInput { 7691 confirmedGoodToGo: boolean; 7692 /** Display name for the marker (the verified email is derived server-side). */ 7693 confirmedByName?: string; 7694} 7695`,Ge=`/** 7696 * Discount Rules Type Definitions 7697 * 7698 * Reusable discount rules stored per-tenant that can be referenced across multiple campaigns. 7699 * Table: discount-rules (PK: tenantId, SK: ruleId) 7700 * 7701 * These rules centralize discount configuration so it can be: 7702 * - Defined once and reused across campaigns 7703 * - Managed via dedicated UI (Discount Rules tab) 7704 * - Applied uniformly regardless of triggering event type 7705 */ 7706 7707import type { 7708 DiscountCategory, 7709 CodeDeliveryMethod, 7710 DraftOrderLevel, 7711 LineItemDiscount, 7712 DiscountApplicableTo, 7713 FreeItemConfig, 7714} from './campaign-context.js'; 7715 7716/** 7717 * Discount Rule - reusable tenant-wide discount configuration 7718 * 7719 * Can be referenced by campaigns via discountRuleId instead of 7720 * embedding discount config directly in CampaignContext. 7721 */ 7722export interface DiscountRule { 7723 /** Partition key - tenant identifier (e.g., "store.myshopify.com") */ 7724 tenantId: string; 7725 7726 /** Sort key - unique rule identifier (e.g., "rule_abc123") */ 7727 ruleId: string; 7728 7729 /** Display name for the rule (e.g., "Black Friday 50% Off") */ 7730 name: string; 7731 7732 /** Optional description of when/how to use this rule */ 7733 description?: string; 7734 7735 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7736 // DISCOUNT CONFIGURATION 7737 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7738 7739 /** 7740 * Discount category - determines the mechanism used 7741 * - 'discount_code': Customer uses a discount code 7742 * - 'draft_order': Invoice with discount baked in 7743 */ 7744 category: DiscountCategory; 7745 7746 /** 7747 * Shopify discount code (when category = 'discount_code') 7748 * This code must exist in Shopify's discount system 7749 */ 7750 code?: string; 7751 7752 /** Discount type: "PERCENTAGE" or "FIXED_AMOUNT" */ 7753 type: 'PERCENTAGE' | 'FIXED_AMOUNT'; 7754 7755 /** Base discount value (e.g., 50 for 50% or $50) */ 7756 value: number; 7757 7758 /** Optional discount title/description for display */ 7759 title?: string; 7760 7761 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7762 // ELIGIBILITY & TARGETING 7763 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7764 7765 /** 7766 * What products/variants the discount applies to
7767 * - type: 'ALL' | 'VARIANT' | 'PRODUCT' | 'COLLECTION' 7768 * - ids: Array of Shopify GIDs 7769 */ 7770 applicableTo?: DiscountApplicableTo; 7771 7772 /** 7773 * Per-line-item discount configuration 7774 * Allows different discount percentages for specific products/variants 7775 * Used in checkout-abandoned workflows for targeted offers 7776 */ 7777 lineItemDiscounts?: LineItemDiscount[]; 7778 7779 /** 7780 * Require cart to contain items matching lineItemDiscounts 7781 * When true, the rule only applies if cart contains at least one matching item 7782 * @default false 7783 */ 7784 requireLineItemMatch?: boolean; 7785 7786 /** 7787 * Minimum cart value (in dollars) required for this discount to apply. 7788 * If the cart total is below this threshold, the discount will not be offered. 7789 * @example 2000 means the cart must be at least $2,000 7790 */ 7791 minimumCartValue?: number; 7792 7793 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7794 // CODE DELIVERY (for discount_code category) 7795 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7796 7797 /** 7798 * Code delivery method (only used when category = 'discount_code') 7799 * - 'cart_permalink': URL with ?discount=CODE that auto-applies 7800 * - 'theme_app': Theme block applies code via Storefront API 7801 * - 'manual': Customer enters code manually at checkout 7802 */ 7803 codeDelivery?: CodeDeliveryMethod; 7804 7805 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7806 // DRAFT ORDER CONFIG (for draft_order category) 7807 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7808 7809 /**
7810 * Draft order discount level (only used when category = 'draft_order') 7811 * - 'order': Single applied_discount on entire draft order 7812 * - 'line_item': Per-item applied_discount on each line item 7813 * @default 'line_item' 7814 */ 7815 draftOrderLevel?: DraftOrderLevel; 7816 7817 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7818 // FREE ITEMS 7819 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7820 7821 /** 7822 * Free items to include when this discount rule is applied 7823 * Items are added to draft order at 100% discount 7824 */ 7825 freeItems?: FreeItemConfig[]; 7826 7827 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7828 // MANAGEMENT 7829 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7830 7831 /** Whether this rule is currently active and available for use */ 7832 enabled: boolean; 7833 7834 /** UTC timestamp when rule was created (ISO 8601) */ 7835 createdAt: string; 7836 7837 /** UTC timestamp when rule was last updated (ISO 8601) */ 7838 updatedAt: string; 7839 7840 /** User who created the rule (Cognito sub or email) */ 7841 createdBy?: string; 7842 7843 /** User who last updated the rule */ 7844 updatedBy?: string; 7845} 7846 7847/** 7848 * Input for creating a new discount rule 7849 */ 7850export interface CreateDiscountRuleInput { 7851 tenantId: string; 7852 name: string; 7853 description?: string; 7854 category: DiscountCategory; 7855 code?: string; 7856 type: 'PERCENTAGE' | 'FIXED_AMOUNT'; 7857 value: number; 7858 title?: string; 7859 applicableTo?: DiscountApplicableTo; 7860 lineItemDiscounts?: LineItemDiscount[]; 7861 requireLineItemMatch?: boolean; 7862 minimumCartValue?: number; 7863 codeDelivery?: CodeDeliveryMethod; 7864 draftOrderLevel?: DraftOrderLevel; 7865 freeItems?: FreeItemConfig[]; 7866 enabled?: boolean; 7867} 7868 7869/** 7870 * Input for updating an existing discount rule 7871 */ 7872export interface UpdateDiscountRuleInput { 7873 name?: string; 7874 description?: string; 7875 category?: DiscountCategory; 7876 code?: string; 7877 type?: 'PERCENTAGE' | 'FIXED_AMOUNT'; 7878 value?: number; 7879 title?: string; 7880 applicableTo?: DiscountApplicableTo; 7881 lineItemDiscounts?: LineItemDiscount[]; 7882 requireLineItemMatch?: boolean; 7883 minimumCartValue?: number; 7884 codeDelivery?: CodeDeliveryMethod; 7885 draftOrderLevel?: DraftOrderLevel; 7886 freeItems?: FreeItemConfig[]; 7887 enabled?: boolean; 7888} 7889 7890/** 7891 * Reference to a discount rule from campaign configuration 7892 * 7893 * Campaigns can reference a discount rule by ID instead of 7894 * embedding the full discount configuration inline. 7895 */ 7896export interface DiscountRuleReference { 7897 /** Reference to discount-rules table (tenantId + ruleId) */ 7898 discountRuleId: string; 7899 7900 /** 7901 * Campaign-specific overrides 7902 * These values override the rule's values for this specific campaign 7903 */ 7904 overrides?: { 7905 /** Override the discount value */ 7906 value?: number; 7907 /** Override the discount code */ 7908 code?: string; 7909 /** Override applicability */ 7910 applicableTo?: DiscountApplicableTo; 7911 /** Additional line item discounts for this campaign */ 7912 lineItemDiscounts?: LineItemDiscount[]; 7913 /** Additional free items for this campaign */ 7914 freeItems?: FreeItemConfig[]; 7915 }; 7916} 7917 7918/** 7919 * Query parameters for listing discount rules 7920 */ 7921export interface ListDiscountRulesParams { 7922 tenantId: string; 7923 /** Filter by enabled status */ 7924 enabled?: boolean; 7925 /** Maximum number of results */ 7926 limit?: number; 7927 /** Pagination token from previous response */ 7928 nextToken?: string; 7929} 7930 7931/** 7932 * Response from listing discount rules 7933 */ 7934export interface ListDiscountRulesResponse { 7935 rules: DiscountRule[]; 7936 /** Token for fetching next page of results */ 7937 nextToken?: string; 7938} 7939 7940/** 7941 * Generate a unique rule ID 7942 * Format: rule_{timestamp}_{random} 7943 */ 7944export function generateRuleId(): string { 7945 const timestamp = Date.now().toString(36); 7946 const random = Math.random().toString(36).substring(2, 8); 7947 return \`rule_\${timestamp}_\${random}\`; 7948} 7949 7950/** 7951 * Validate a discount rule for required fields 7952 */ 7953export function validateDiscountRule( 7954 rule: Partial<DiscountRule> 7955): { valid: boolean; errors: string[] } { 7956 const errors: string[] = []; 7957 7958 if (!rule.tenantId) errors.push('tenantId is required'); 7959 if (!rule.ruleId) errors.push('ruleId is required'); 7960 if (!rule.name) errors.push('name is required'); 7961 if (!rule.category) errors.push('category is required'); 7962 if (!rule.type) errors.push('type is required'); 7963 if (rule.value === undefined || rule.value === null) errors.push('value is required'); 7964 7965 // Category-specific validation 7966 if (rule.category === 'discount_code' && !rule.code) { 7967 errors.push('code is required when category is discount_code'); 7968 } 7969 7970 // Value validation
7971 if (rule.type === 'PERCENTAGE' && rule.value !== undefined) { 7972 if (rule.value < 0 || rule.value > 100) { 7973 errors.push('percentage value must be between 0 and 100'); 7974 } 7975 } 7976 7977 if (rule.type === 'FIXED_AMOUNT' && rule.value !== undefined) { 7978 if (rule.value < 0) { 7979 errors.push('fixed amount value must be non-negative'); 7980 } 7981 } 7982 7983 return { 7984 valid: errors.length === 0, 7985 errors, 7986 }; 7987} 7988`,We=`/** 7989 * Draft Order Builder Module 7990 * 7991 * Shared utilities for building Shopify draft orders with discounts and free items. 7992 * Used by both the campaign execution engine (Lambda) and ShopDash UI for consistent 7993 * draft order construction and preview. 7994 * 7995 * This module provides pure functions with no side effects - API calls and data fetching 7996 * are handled externally and results passed in. 7997 * 7998 * Works alongside: 7999 * - cart-permalink.ts: Link generation 8000 * - draft-order-preview.ts: Display/extraction for UI 8001 */ 8002 8003import type { LineItemDiscount } from './campaign-context.js'; 8004import { extractVariantId } from './cart-permalink.js'; 8005 8006// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8007// INTERFACES 8008// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8009 8010/** 8011 * Shopify applied discount object for draft orders 8012 */ 8013export interface AppliedDiscount { 8014 description?: string; 8015 value_type: 'percentage' | 'fixed_amount'; 8016 value: string; 8017 title?: string; 8018 amount: string; 8019} 8020 8021/** 8022 * Draft order line item for Shopify API 8023 * Named with Shopify prefix to avoid collision with DraftOrderLineItem in draft-order-preview.ts 8024 * (which is for display/UI purposes) 8025 */ 8026export interface ShopifyDraftOrderLineItem { 8027 variant_id: number; 8028 quantity: number; 8029 /** Custom price per unit - overrides Shopify's catalog price */ 8030 price?: string; 8031 applied_discount?: AppliedDiscount; 8032} 8033 8034/** 8035 * Result of determining what action to take for a free item 8036 */ 8037export interface FreeItemAction { 8038 action: 'add' | 'override' | 'skip'; 8039 reason: string; 8040} 8041 8042/** 8043 * Result of calculating effective discount. 8044 * 8045 * Discriminated union â \`kind\` tells the caller whether \`value\` is a percentage 8046 * (0â100) or a fixed dollar amount. The downstream draft-order builder uses 8047 * this to populate Shopify's \`applied_discount.value_type\` correctly. 8048 * 8049 * - \`kind: 'percentage'\` â value is a number 0â100, becomes Shopify 8050 * \`value_type: 'percentage'\`. Used for percent line-item discounts and the 8051 * campaign-level base discount. 8052 * - \`kind: 'fixed_amount'\` â value is a dollar amount, becomes Shopify 8053 * \`value_type: 'fixed_amount'\`. Used when \`LineItemDiscount.valueType: 'fixed'\`. 8054 */ 8055export type EffectiveDiscountResult = 8056 | { kind: 'percentage'; value: number; source: 'checkout' | 'campaign' | 'line_item' } 8057 | { kind: 'fixed_amount'; value: number; source: 'checkout' | 'campaign' | 'line_item' }; 8058 8059/** 8060 * Generic cart line item interface (handles both camelCase and snake_case) 8061 */ 8062export interface CartLineItem { 8063 variant_id?: string | number; 8064 variantId?: string | number; 8065 quantity?: number; 8066 price?: string | number; 8067 line_price?: string | number; 8068 final_line_price?: string | number; 8069 final_price?: string | number; 8070 [key: string]: unknown; 8071} 8072 8073// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8074// ELIGIBILITY CHECKING 8075// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8076 8077/** 8078 * Check if campaign uses a Shopify discount code (vs draft order) 8079 * 8080 * For Shopify discount codes, eligibility is handled by Shopify at checkout 8081 * based on the discount's configuration. For draft orders, we manage eligibility 8082 * ourselves via applicableTo.ids. 8083 * 8084 * @param discountCategory - The campaign's discount category 8085 * @returns true if using Shopify discount codes 8086 */ 8087export function isShopifyDiscountCode( 8088 discountCategory?: 'discount_code' | 'draft_order' 8089): boolean { 8090 return discountCategory === 'discount_code'; 8091} 8092 8093/** 8094 * Check if a variant is eligible for the campaign discount 8095 * 8096 * For Shopify discount codes (category: 'discount_code'): 8097 * - Returns true for all variants - Shopify handles eligibility at checkout 8098 * 8099 * For draft orders (category: 'draft_order'): 8100 * - If applicableVariantIds is empty/undefined, all items are eligible 8101 * - Otherwise, variant must be in the list 8102 * 8103 * @param variantId - The variant ID to check 8104 * @param discountCategory - The campaign's discount category 8105 * @param applicableVariantIds - List of eligible variant IDs (for draft orders) 8106 * @returns true if the variant is eligible for discount 8107 */ 8108export function isVariantEligible( 8109 variantId: string | number | undefined, 8110 discountCategory?: 'discount_code' | 'draft_order', 8111 applicableVariantIds?: (string | number)[] 8112): boolean { 8113 if (!variantId) return false;
8114 8115 const variantIdNum = extractVariantId(variantId); 8116 if (variantIdNum === null) return false; 8117 8118 // For Shopify discount codes, all items are potentially eligible 8119 // Shopify handles actual eligibility at checkout 8120 if (isShopifyDiscountCode(discountCategory)) { 8121 return true; 8122 } 8123 8124 // For draft orders, check applicableTo.ids if specified 8125 // If no list specified, allow all items (default behavior) 8126 if (!applicableVariantIds || applicableVariantIds.length === 0) { 8127 return true; 8128 } 8129 8130 // Check if variant matches any in the eligible list 8131 for (const eligibleId of applicableVariantIds) { 8132 const eligibleIdNum = extractVariantId(eligibleId); 8133 if (eligibleIdNum !== null && eligibleIdNum === variantIdNum) { 8134 return true; 8135 } 8136 } 8137 8138 return false; 8139} 8140 8141/** 8142 * Check if a variant is a configured free item (warranty, shipping, concierge) 8143 * 8144 * Free items are not discounted - they're already free. This helps avoid 8145 * applying campaign discounts to items that are already $0. 8146 * 8147 * @param variantId - The variant ID to check 8148 * @param freeItemVariantIds - List of free item variant IDs from campaign config 8149 * @returns true if the variant is a free item 8150 */ 8151export function isFreeItemVariant( 8152 variantId: string | number | undefined, 8153 freeItemVariantIds: (string | number | undefined)[] 8154): boolean { 8155 if (!variantId) return false; 8156 8157 const variantIdNum = extractVariantId(variantId); 8158 if (variantIdNum === null) return false; 8159 8160 for (const freeItemId of freeItemVariantIds) { 8161 const freeItemIdNum = extractVariantId(freeItemId); 8162 if (freeItemIdNum !== null && freeItemIdNum === variantIdNum) { 8163 return true; 8164 } 8165 } 8166 8167 return false; 8168} 8169 8170// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8171// DISCOUNT CALCULATION 8172// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8173 8174/** 8175 * Find line item discount config for a specific variant 8176 * 8177 * Line item discounts allow per-product discount configuration, 8178 * overriding the base campaign discount. 8179 * 8180 * @param variantId - The variant ID to find config for 8181 * @param lineItemDiscounts - Array of line item discount configs 8182 * @returns The matching config or undefined 8183 */ 8184export function findLineItemDiscountConfig( 8185 variantId: string | number | undefined, 8186 lineItemDiscounts: LineItemDiscount[] | undefined 8187): LineItemDiscount | undefined { 8188 if (!variantId || !lineItemDiscounts?.length) return undefined; 8189 8190 const variantIdNum = extractVariantId(variantId); 8191 if (variantIdNum === null) return undefined; 8192 8193 return lineItemDiscounts.find(config => { 8194 const configVariantIdNum = extractVariantId(config.variantId); 8195 return configVariantIdNum !== null && configVariantIdNum === variantIdNum; 8196 }); 8197} 8198 8199/** 8200 * Calculate the discount percentage already applied at checkout 8201 * 8202 * This determines how much discount the customer already has from 8203 * codes they applied during checkout. 8204 * 8205 * @param originalLinePrice - Price before discounts 8206 * @param currentLinePrice - Price after existing discounts 8207 * @returns Discount percentage (0-100) 8208 */ 8209export function calculateCheckoutDiscountPercent( 8210 originalLinePrice: number, 8211 currentLinePrice: number 8212): number { 8213 if (originalLinePrice <= 0) return 0; 8214 if (currentLinePrice >= originalLinePrice) return 0; 8215 8216 return ((originalLinePrice - currentLinePrice) / originalLinePrice) * 100; 8217} 8218 8219/** 8220 * Calculate the effective discount to apply 8221 * 8222 * Implements 'best' vs 'config' mode logic: 8223 * - 'best' (default): Use MAX(checkoutDiscount, configDiscount) - never reduce existing discounts 8224 * - 'config': Always use configured value, even if lower than checkout 8225 * 8226 * This ensures we never make the customer worse off unless explicitly configured. 8227 * 8228 * @param checkoutDiscountPercent - Existing checkout discount % 8229 * @param configuredDiscountPercent - Base campaign discount % 8230 * @param lineItemConfig - Optional per-variant config 8231 * @returns Effective discount % and source 8232 */
8233export function calculateEffectiveDiscount( 8234 checkoutDiscountPercent: number, 8235 configuredDiscountPercent: number, 8236 lineItemConfig?: LineItemDiscount 8237): EffectiveDiscountResult { 8238 if (lineItemConfig) { 8239 const configValue = lineItemConfig.value; 8240 const valueSource = lineItemConfig.valueSource || 'best'; 8241 8242 // Fixed-amount line-item discounts: the checkoutDiscountPercent (a 8243 // percentage) and configValue (dollars) are different units, so 'best' 8244 // comparison doesn't apply. Always return the configured dollar amount. 8245 if (lineItemConfig.valueType === 'fixed') { 8246 return { kind: 'fixed_amount', value: configValue, source: 'line_item' }; 8247 } 8248 8249 // Percentage line-item discounts. 8250 if (valueSource === 'config') { 8251 return { kind: 'percentage', value: configValue, source: 'line_item' }; 8252 } 8253 // 'best' mode: use MAX(checkout, config) â never reduce existing discount 8254 if (checkoutDiscountPercent >= configValue) { 8255 return { kind: 'percentage', value: checkoutDiscountPercent, source: 'checkout' }; 8256 } 8257 return { kind: 'percentage', value: configValue, source: 'line_item' }; 8258 } 8259 8260 // No line item config â use base campaign discount with 'best' logic. 8261 // Base campaign discount is always a percent (rule.type === 'PERCENTAGE' is 8262 // the only branch that calls this with > 0). 8263 if (checkoutDiscountPercent >= configuredDiscountPercent) { 8264 return { kind: 'percentage', value: checkoutDiscountPercent, source: 'checkout' }; 8265 } 8266 return { kind: 'percentage', value: configuredDiscountPercent, source: 'campaign' }; 8267} 8268 8269// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8270// FREE ITEMS LOGIC 8271// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8272 8273/** 8274 * Find a cart item by variant ID 8275 * 8276 * Handles both camelCase (lineItems) and snake_case (line_items) formats. 8277 * 8278 * @param cartItems - Array of cart items 8279 * @param targetVariantId - Variant ID to find 8280 * @returns The matching cart item or null 8281 */ 8282export function findCartItemByVariant<T extends CartLineItem>( 8283 cartItems: T[], 8284 targetVariantId: string | number 8285): T | null { 8286 const targetIdNum = extractVariantId(targetVariantId); 8287 if (targetIdNum === null) return null; 8288 8289 for (const item of cartItems) { 8290 const itemVariantId = extractVariantId( 8291 item.variant_id ?? item.variantId 8292 ); 8293 if (itemVariantId === targetIdNum) { 8294 return item; 8295 } 8296 } 8297 8298 return null; 8299} 8300 8301/** 8302 * Get the price from a cart item 8303 * 8304 * Handles various price field names used by different Shopify data formats. 8305 * 8306 * @param cartItem - The cart item 8307 * @returns Price as number (in cents/smallest currency unit) 8308 */ 8309export function getCartItemPrice(cartItem: CartLineItem): number { 8310 const priceFields = ['final_line_price', 'line_price', 'price', 'final_price']; 8311 8312 for (const field of priceFields) { 8313 const value = cartItem[field]; 8314 if (value !== undefined && value !== null) { 8315 const price = typeof value === 'string' ? parseFloat(value) : Number(value); 8316 if (!isNaN(price)) { 8317 return price; 8318 } 8319 } 8320 } 8321 8322 return 0; 8323} 8324 8325/** 8326 * Determine what action to take for a free item 8327 * 8328 * Free items are processed in priority order: 8329 * - Not in cart: Add at $0 8330 * - In cart with price > 0: Override to $0 (make it free) 8331 * - In cart at $0: Skip (already free) 8332 * 8333 * @param freeItemVariantId - Variant ID of the free item 8334 * @param cartItems - Current cart items 8335 * @returns Action to take and reason 8336 */ 8337export function determineFreeItemAction( 8338 freeItemVariantId: string | number, 8339 cartItems: CartLineItem[] 8340): FreeItemAction { 8341 const cartItem = findCartItemByVariant(cartItems, freeItemVariantId); 8342 8343 if (!cartItem) { 8344 return { 8345 action: 'add', 8346 reason: 'Not in cart', 8347 }; 8348 } 8349 8350 const currentPrice = getCartItemPrice(cartItem); 8351 8352 if (currentPrice > 0) { 8353 return { 8354 action: 'override', 8355 reason: \`Was in cart at $\${(currentPrice / 100).toFixed(2)}, now free\`, 8356 }; 8357 } 8358 8359 return { 8360 action: 'skip', 8361 reason: 'Already in cart at $0', 8362 }; 8363} 8364 8365// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8366// DRAFT ORDER LINE ITEM BUILDING 8367// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8368 8369/**
8370 * Build an applied_discount object for Shopify draft order API. 8371 * 8372 * Accepts either an \`EffectiveDiscountResult\` (preferred â carries kind) or a 8373 * bare percent number (legacy callers). When given a bare number, treats it 8374 * as a percentage discount, matching the previous behaviour. 8375 * 8376 * @param discount - EffectiveDiscountResult OR a bare percent number 8377 * @param description - Optional description for the discount 8378 * @returns AppliedDiscount object for Shopify API 8379 */ 8380export function buildAppliedDiscount( 8381 discount: EffectiveDiscountResult | number, 8382 description?: string 8383): AppliedDiscount { 8384 const resolved: EffectiveDiscountResult = 8385 typeof discount === 'number' 8386 ? { kind: 'percentage', value: discount, source: 'campaign' } 8387 : discount; 8388 8389 if (resolved.kind === 'fixed_amount') { 8390 return { 8391 description, 8392 value_type: 'fixed_amount', 8393 value: resolved.value.toFixed(2), 8394 title: \`$\${resolved.value.toFixed(2)} off\`, 8395 amount: resolved.value.toFixed(2), 8396 }; 8397 } 8398 8399 return { 8400 description, 8401 value_type: 'percentage', 8402 value: resolved.value.toString(), 8403 title: \`\${resolved.value}% off\`, 8404 amount: resolved.value.toString(), 8405 }; 8406} 8407 8408/** 8409 * Build a draft order line item with optional discount. 8410 * 8411 * The \`discount\` parameter accepts either: 8412 * - an \`EffectiveDiscountResult\` (preferred â carries the percentage-vs-fixed 8413 * distinction), or 8414 * - a bare percent number (legacy callers; treated as a percentage discount). 8415 * 8416 * @param variantId - Numeric variant ID 8417 * @param quantity - Quantity 8418 * @param discount - Optional EffectiveDiscountResult or bare percent 8419 * @param campaignId - Optional campaign ID for attribution 8420 * @param price - Optional explicit per-unit price override 8421 * @returns DraftOrderLineItem for Shopify API 8422 */ 8423export function buildDraftOrderLineItem( 8424 variantId: number, 8425 quantity: number, 8426 discount?: EffectiveDiscountResult | number, 8427 campaignId?: string, 8428 price?: number 8429): ShopifyDraftOrderLineItem { 8430 const lineItem: ShopifyDraftOrderLineItem = { 8431 variant_id: variantId, 8432 quantity, 8433 }; 8434 8435 // Set explicit price if provided (e.g., to match abandoned checkout pricing) 8436 if (price !== undefined && price > 0) { 8437 lineItem.price = price.toFixed(2); 8438 } 8439 8440 const hasDiscount = 8441 typeof discount === 'number' 8442 ? discount > 0 8443 : discount !== undefined && discount.value > 0; 8444 8445 if (hasDiscount && discount !== undefined) {
8446 lineItem.applied_discount = buildAppliedDiscount( 8447 discount, 8448 campaignId ? \`Campaign: \${campaignId}\` : undefined 8449 ); 8450 } 8451 8452 return lineItem; 8453} 8454 8455/** 8456 * Build a free item line (100% discount) 8457 * 8458 * @param variantId - Numeric variant ID 8459 * @param title - Item title for description 8460 * @returns DraftOrderLineItem with 100% discount 8461 */ 8462export function buildFreeItemLineItem( 8463 variantId: number, 8464 title: string 8465): ShopifyDraftOrderLineItem { 8466 return { 8467 variant_id: variantId, 8468 quantity: 1, 8469 applied_discount: { 8470 description: \`Free \${title}\`, 8471 value_type: 'percentage', 8472 value: '100', 8473 title: 'Free', 8474 amount: '100', 8475 }, 8476 }; 8477} 8478`,qe=`/** 8479 * Draft Order Preview Module 8480 * 8481 * Shared utility for extracting and normalizing draft order/cart preview data 8482 * from CampaignApproval records. Handles both new format (cart.lineItems) and 8483 * legacy format (eventData.lineItems). 8484 * 8485 * Used by: 8486 * - Lambda: When creating approval records 8487 * - UI: When displaying cart/draft order modals 8488 */ 8489 8490import type { CampaignApproval } from './campaign-approval.js'; 8491 8492/** 8493 * Normalized line item for display 8494 */ 8495export interface DraftOrderLineItem { 8496 title: string; 8497 variantTitle?: string; 8498 sku?: string; 8499 quantity: number; 8500 price: string; 8501 compareAtPrice?: string; 8502 lineTotal: number; 8503 isFree?: boolean; 8504} 8505 8506/** 8507 * Pricing information for display 8508 */ 8509export interface DraftOrderPricing { 8510 subtotal: string; 8511 total: string; 8512 currency: string; 8513 discountAmount?: string; 8514 discountCode?: string; 8515 totalSavings?: number | null; 8516 shippingAmount?: string | null; 8517 taxAmount?: string; 8518} 8519 8520/** 8521 * Customer information for display 8522 */ 8523export interface DraftOrderCustomer { 8524 name?: string; 8525 email?: string; 8526 phone?: string; 8527} 8528 8529/** 8530 * Draft order information 8531 */ 8532export interface DraftOrderInfo { 8533 draftOrderId?: string | number; 8534 invoiceUrl?: string; 8535} 8536 8537/** 8538 * Complete normalized draft order preview data 8539 */ 8540export interface DraftOrderPreview { 8541 lineItems: DraftOrderLineItem[]; 8542 pricing: DraftOrderPricing; 8543 customer?: DraftOrderCustomer; 8544 draftOrder?: DraftOrderInfo; 8545 freeItemsAdded: string[]; 8546 /** Where the data was extracted from */ 8547 dataSource: 'cart' | 'eventData' | 'none'; 8548} 8549 8550/** 8551 * Options for extracting draft order preview 8552 */ 8553export interface ExtractDraftOrderPreviewOptions { 8554 defaultCurrency?: string; 8555} 8556 8557/** 8558 * Extract draft order preview data from a CampaignApproval record. 8559 * Handles both new format (cart.lineItems) and legacy format (eventData.lineItems). 8560 * 8561 * @param approval - The CampaignApproval record 8562 * @param options - Optional configuration 8563 * @returns Normalized DraftOrderPreview or null if no cart data 8564 */ 8565export function extractDraftOrderPreview( 8566 approval: CampaignApproval, 8567 options?: ExtractDraftOrderPreviewOptions 8568): DraftOrderPreview | null { 8569 const defaultCurrency = options?.defaultCurrency || 'AUD'; 8570 8571 // Get eventData for fallback 8572 const eventData = approval.checkoutDetails?.eventData as Record<string, unknown> | undefined; 8573 const pricing = eventData?.pricing as Record<string, unknown> | undefined; 8574 const rawItems = (eventData?.lineItems || eventData?.line_items) as Array<Record<string, unknown>> | undefined; 8575 8576 // Try cart.lineItems first (new format, properly populated) 8577 if (approval.cart?.lineItems?.length) { 8578 return extractFromCartFormat(approval, defaultCurrency); 8579 } 8580 8581 // Fall back to eventData (legacy format) 8582 if (rawItems && rawItems.length > 0) { 8583 return extractFromEventDataFormat(approval, eventData!, pricing, rawItems, defaultCurrency); 8584 } 8585 8586 return null; 8587} 8588 8589/** 8590 * Extract from new cart format (approval.cart) 8591 */ 8592function extractFromCartFormat( 8593 approval: CampaignApproval, 8594 defaultCurrency: string 8595): DraftOrderPreview { 8596 const cart = approval.cart!; 8597 8598 return { 8599 lineItems: cart.lineItems.map(item => ({ 8600 title: item.title, 8601 variantTitle: item.variantTitle, 8602 sku: item.sku, 8603 quantity: item.quantity, 8604 price: item.price, 8605 compareAtPrice: item.compareAtPrice, 8606 lineTotal: parseFloat(item.price) * item.quantity, 8607 })), 8608 pricing: { 8609 subtotal: cart.subtotalPrice || cart.totalPrice, 8610 total: cart.totalPrice, 8611 currency: cart.currency || defaultCurrency, 8612 discountAmount: cart.totalDiscounts, 8613 discountCode: approval.discount?.discountCode, 8614 totalSavings: approval.discount?.totalSavings, 8615 }, 8616 customer: extractCustomer(approval), 8617 draftOrder: extractDraftOrder(approval), 8618 freeItemsAdded: approval.discount?.freeItemsAdded || [], 8619 dataSource: 'cart', 8620 }; 8621} 8622 8623/** 8624 * Extract from legacy eventData format (approval.checkoutDetails.eventData) 8625 */ 8626function extractFromEventDataFormat( 8627 approval: CampaignApproval, 8628 eventData: Record<string, unknown>, 8629 pricing: Record<string, unknown> | undefined, 8630 rawItems: Array<Record<string, unknown>>, 8631 defaultCurrency: string 8632): DraftOrderPreview { 8633 // Extract pricing - check nested pricing object first (Shopify storefront format) 8634 const subtotal = pricing?.subtotalPrice 8635 ? String(pricing.subtotalPrice) 8636 : String(eventData.subtotal_price || eventData.subtotalPrice || '0'); 8637 8638 const total = pricing?.totalPrice 8639 ? String(pricing.totalPrice) 8640 : String(eventData.total_price || eventData.totalPrice || '0'); 8641 8642 const discountAmount = pricing?.totalDiscounts 8643 ? String(pricing.totalDiscounts) 8644 : eventData.total_discounts || eventData.totalDiscounts 8645 ? String(eventData.total_discounts || eventData.totalDiscounts) 8646 : undefined; 8647 8648 // Extract discount code from eventData or approval 8649 const discountCodes = eventData.discountCodes as string[] | undefined; 8650 const discountCode = discountCodes?.[0] || approval.discount?.discountCode; 8651 8652 // Extract customer name from shipping/billing address 8653 const shippingAddress = eventData.shippingAddress as Record<string, unknown> | undefined; 8654 const billingAddress = eventData.billingAddress as Record<string, unknown> | undefined; 8655 const customerName = (shippingAddress?.name as string | undefined) 8656 || (billingAddress?.name as string | undefined) 8657 || approval.leadDetails?.name; 8658 8659 return { 8660 lineItems: rawItems.map(item => ({ 8661 title: String(item.title || item.name || ''), 8662 variantTitle: (item.variant_title || item.variantTitle) as string | undefined, 8663 sku: item.sku as string | undefined, 8664 quantity: Number(item.quantity) || 1, 8665 price: String(item.price || item.line_price || '0'), 8666 compareAtPrice: (item.compare_at_price || item.compareAtPrice) as string | undefined, 8667 lineTotal: parseFloat(String(item.price || item.line_price || '0')) * (Number(item.quantity) || 1), 8668 })), 8669 pricing: { 8670 subtotal, 8671 total, 8672 currency: String(eventData.currency || eventData.presentmentCurrency || defaultCurrency), 8673 discountAmount, 8674 discountCode, 8675 totalSavings: approval.discount?.totalSavings, 8676 }, 8677 customer: { 8678 name: customerName, 8679 email: approval.leadDetails?.emailAddress, 8680 phone: approval.leadDetails?.phoneNumber, 8681 }, 8682 draftOrder: extractDraftOrder(approval), 8683 freeItemsAdded: approval.discount?.freeItemsAdded || [], 8684 dataSource: 'eventData', 8685 }; 8686} 8687 8688/** 8689 * Extract customer info from approval 8690 */ 8691function extractCustomer(approval: CampaignApproval): DraftOrderCustomer | undefined { 8692 const eventData = approval.checkoutDetails?.eventData as Record<string, unknown> | undefined; 8693 const shippingAddress = eventData?.shippingAddress as Record<string, unknown> | undefined; 8694 const billingAddress = eventData?.billingAddress as Record<string, unknown> | undefined; 8695 8696 const name = (shippingAddress?.name as string | undefined) 8697 || (billingAddress?.name as string | undefined) 8698 || approval.leadDetails?.name; 8699 8700 return { 8701 name, 8702 email: approval.leadDetails?.emailAddress, 8703 phone: approval.leadDetails?.phoneNumber, 8704 }; 8705} 8706 8707/** 8708 * Extract draft order info from approval 8709 */ 8710function extractDraftOrder(approval: CampaignApproval): DraftOrderInfo | undefined { 8711 if (!approval.draftOrder?.invoiceUrl) return undefined; 8712 8713 return { 8714 draftOrderId: approval.draftOrder.draftOrderId, 8715 invoiceUrl: approval.draftOrder.invoiceUrl, 8716 }; 8717} 8718 8719/** 8720 * Check if an approval has displayable cart data 8721 */ 8722export function hasCartData(approval: CampaignApproval): boolean { 8723 // Check new format 8724 if (approval.cart?.lineItems?.length) return true; 8725 8726 // Check legacy format 8727 const eventData = approval.checkoutDetails?.eventData as Record<string, unknown> | undefined; 8728 const items = eventData?.lineItems || eventData?.line_items; 8729 return Array.isArray(items) && items.length > 0; 8730} 8731 8732/** 8733 * Check if an approval has draft order info 8734 */ 8735export function hasDraftOrderData(approval: CampaignApproval): boolean { 8736 // Check new format 8737 if (approval.draftOrder?.invoiceUrl) return true; 8738 if (approval.trackingLink) return true; 8739 if (approval.cart?.abandonedCheckoutUrl) return true; 8740 8741 // Check legacy format 8742 const eventData = approval.checkoutDetails?.eventData as Record<string, unknown> | undefined; 8743 return !!(eventData?.abandonedCheckoutUrl || eventData?.abandoned_checkout_url); 8744} 8745 8746/** 8747 * Get cart total formatted as currency string 8748 */ 8749export function getCartTotalFormatted( 8750 approval: CampaignApproval, 8751 options?: { defaultCurrency?: string; minimumFractionDigits?: number; maximumFractionDigits?: number } 8752): string { 8753 const preview = extractDraftOrderPreview(approval, options); 8754 if (!preview) return '-'; 8755 8756 const numTotal = parseFloat(preview.pricing.total); 8757 if (isNaN(numTotal) || numTotal === 0) return '-'; 8758
8759 return numTotal.toLocaleString('en-AU', { 8760 style: 'currency', 8761 currency: preview.pricing.currency || options?.defaultCurrency || 'AUD', 8762 minimumFractionDigits: options?.minimumFractionDigits ?? 0, 8763 maximumFractionDigits: options?.maximumFractionDigits ?? 0, 8764 }); 8765} 8766 8767/** 8768 * Get draft order total formatted as currency string. 8769 * Uses the actual Shopify draft order pricing when available, 8770 * falls back to cart total if draft order pricing not stored. 8771 */ 8772export function getDraftOrderTotalFormatted( 8773 approval: CampaignApproval, 8774 options?: { defaultCurrency?: string; minimumFractionDigits?: number; maximumFractionDigits?: number } 8775): string { 8776 const defaultCurrency = options?.defaultCurrency || 'AUD'; 8777 8778 // Use actual draft order pricing if available 8779 if (approval.draftOrder?.totalPrice) { 8780 const numTotal = parseFloat(approval.draftOrder.totalPrice); 8781 if (!isNaN(numTotal) && numTotal > 0) { 8782 return numTotal.toLocaleString('en-AU', { 8783 style: 'currency', 8784 currency: approval.draftOrder.currency || defaultCurrency, 8785 minimumFractionDigits: options?.minimumFractionDigits ?? 0, 8786 maximumFractionDigits: options?.maximumFractionDigits ?? 0, 8787 }); 8788 } 8789 } 8790 8791 // Fallback to cart total 8792 return getCartTotalFormatted(approval, options); 8793} 8794 8795/** 8796 * Extract draft order preview using actual Shopify draft order data when available. 8797 * Falls back to cart/eventData extraction for older records without draft order pricing. 8798 */ 8799export function extractDraftOrderPreviewFromDraftOrder( 8800 approval: CampaignApproval, 8801 options?: ExtractDraftOrderPreviewOptions 8802): DraftOrderPreview | null { 8803 const defaultCurrency = options?.defaultCurrency || 'AUD'; 8804 const draftOrder = approval.draftOrder; 8805 8806 // If we have actual draft order line items, use those 8807 if (draftOrder?.lineItems?.length && draftOrder.totalPrice) { 8808 return { 8809 lineItems: draftOrder.lineItems.map(item => ({ 8810 title: item.title, 8811 variantTitle: item.variantTitle, 8812 sku: item.sku, 8813 quantity: item.quantity, 8814 price: item.price, 8815 compareAtPrice: item.compareAtPrice, 8816 lineTotal: parseFloat(item.price) * item.quantity, 8817 })), 8818 pricing: { 8819 subtotal: draftOrder.subtotalPrice || draftOrder.totalPrice, 8820 total: draftOrder.totalPrice, 8821 currency: draftOrder.currency || defaultCurrency, 8822 discountAmount: approval.discount?.totalSavings != null 8823 ? String(approval.discount.totalSavings) 8824 : undefined, 8825 discountCode: approval.discount?.discountCode, 8826 totalSavings: approval.discount?.totalSavings, 8827 }, 8828 customer: extractCustomer(approval), 8829 draftOrder: extractDraftOrder(approval), 8830 freeItemsAdded: approval.discount?.freeItemsAdded || [], 8831 dataSource: 'cart', 8832 }; 8833 } 8834 8835 // Fallback to standard extraction (cart/eventData) 8836 return extractDraftOrderPreview(approval, options); 8837} 8838`,Ke=`/** 8839 * Everyday Assist (EDA) consumer-app domain types. 8840 * 8841 * EDA is the relative-facing dashboard at everydayassist.ai (root + www, dev branch on dev.everydayassist.ai). It has its 8842 * own Cognito pool, its own prepaid-balance billing system, and its own preferences 8843 * store. These types are shared between: 8844 * 8845 * - BigM/lambda/lambda-deployed-eda-charge-call/ (call-time balance debiter) 8846 * - eda/amplify/functions/edaSignup, edaBilling, edaAutoTopUp, edaPreferences, edaDataApi 8847 * - eda/src/ (Vue frontend) 8848 * 8849 * Tables (created in BigM/terraform/SHARED/create-dynamo-eda-*): 8850 * eda-account-billing â one row per relative account (Stripe customer + 8851 * balance + auto-top-up rules) 8852 * eda-billing-events â immutable ledger of every credit / debit 8853 * eda-care-plans â per-patient (per-Lead) preferences 8854 * eda-pricing â single config row, $/min rate 8855 * leadToRelative â reverse lookup: Lead â Cognito accountId(s) 8856 * 8857 * Billing model: prepaid balance. 8858 * 1) Card captured at signup via Stripe SetupIntent. 8859 * 2) Initial top-up (e.g. $20) charged immediately, credits balanceCents. 8860 * 3) Calls debit balanceCents atomically â Stripe is NOT in the call hot path. 8861 * 4) When balanceCents drops below autoTopUp.thresholdCents, the scheduled 8862 * \`edaAutoTopUp\` Lambda runs an off-session PaymentIntent for 8863 * autoTopUp.topUpAmountCents and credits balanceCents on success. 8864 * 8865 * Pricing snapshotting: each account locks in the rate at signup 8866 * (\`eda-account-billing.ratePerMinuteCents\`). Changing the global \`eda-pricing\` 8867 * row only affects NEW signups â existing accounts keep their rate. 8868 */ 8869 8870// ----------------------------------------------------------------------------- 8871// eda-account-billing â Stripe customer + card-on-file + locked-in rate 8872// ----------------------------------------------------------------------------- 8873 8874export type EdaAccountBillingStatus = 'active' | 'payment_failed' | 'cancelled'; 8875 8876/** 8877 * Stored as the literal strings 'true' / 'false' rather than a boolean because 8878 * the field is the partition key on the \`accountsByAutoTopUpDue\` GSI used by 8879 * \`edaAutoTopUp\` â DynamoDB partition keys must be S/N/B. 8880 */ 8881export type EdaAutoTopUpEnabledFlag = 'true' | 'false'; 8882 8883export interface EdaAutoTopUpConfig {
8884 /** Trigger an auto-topup when balanceCents drops below this. Cents. */ 8885 thresholdCents: number; 8886 /** Amount to charge per auto-topup. Cents. */ 8887 topUpAmountCents: number; 8888} 8889 8890export interface EdaAccountBilling { 8891 /** Cognito sub of the relative â primary key. */ 8892 accountId: string; 8893 /** Stripe customer ID (cus_xxx). */ 8894 stripeCustomerId: string; 8895 /** Default off-session reusable payment method (pm_xxx). */ 8896 defaultPaymentMethodId: string; 8897 /** Per-minute rate locked in at signup. Cents. */ 8898 ratePerMinuteCents: number; 8899 /** ISO-3 currency code. */ 8900 currency: string; 8901 /** Card display fields. Captured from Stripe SetupIntent confirmation. */ 8902 cardLast4: string; 8903 cardBrand: string; 8904 status: EdaAccountBillingStatus; 8905 /** 8906 * Current prepaid balance in cents. Calls atomically debit this. The 8907 * \`edaAutoTopUp\` scheduled Lambda credits it when below threshold. Negative 8908 * values are not possible â \`eda-charge-call\` uses a ConditionExpression to 8909 * refuse debits that would underflow. 8910 */ 8911 balanceCents: number; 8912 /** 8913 * GSI partition for \`accountsByAutoTopUpDue\`. Exposed as a separate field 8914 * (instead of nested under \`autoTopUp\`) so DynamoDB can index it. 8915 */ 8916 autoTopUpEnabled: EdaAutoTopUpEnabledFlag; 8917 /** Auto top-up rules (always present; only consulted when \`autoTopUpEnabled === 'true'\`). */ 8918 autoTopUp: EdaAutoTopUpConfig; 8919 /** ISO timestamp of the most recent auto-topup attempt â for cooldown. */ 8920 lastTopUpAttemptAt?: string; 8921 /** Outcome of the most recent auto-topup. */ 8922 lastTopUpStatus?: 'succeeded' | 'failed'; 8923 /** Stripe failure code if the most recent attempt failed. */ 8924 lastTopUpError?: string; 8925 /** Optional cumulative free minutes already consumed (for trial gating, if added later). */ 8926 freeMinutesUsed?: number; 8927 /** 8928 * Free calls remaining for new accounts. Initialized to 8929 * \`EDA_FREE_TRIAL_CALL_COUNT\` (default 1) at signup. \`eda-charge-call\` 8930 * decrements this before debiting \`balanceCents\`; when > 0 the call is 8931 * NOT charged + a \`trial_call\` ledger row replaces the \`call_charge\` row. 8932 * Reaches 0 â normal prepaid-balance billing resumes. 8933 */ 8934 trialCallsRemaining?: number; 8935 /** Cumulative cents charged across the lifetime of this account (running total). */ 8936 totalChargedCents?: number; 8937 /** ISO timestamps. */ 8938 createdAt: string; 8939 updatedAt: string; 8940} 8941 8942// ----------------------------------------------------------------------------- 8943// eda-billing-events â immutable charge ledger (one row per Stripe charge) 8944// ----------------------------------------------------------------------------- 8945 8946export type EdaBillingEventType = 8947 | 'call_charge' // balance debit after a completed call 8948 | 'trial_call' // free trial call â no debit, decrements trialCallsRemaining 8949 | 'charge_skipped_insufficient_balance' // call happened but balance was too low 8950 | 'charge_skipped_account_frozen' // call skipped â account status is not 'active' (P0-009) 8951 | 'signup' 8952 | 'manual_topup' // user-initiated top-up (initial or manual) 8953 | 'auto_topup' // scheduled-Lambda top-up succeeded 8954 | 'auto_topup_failed' // scheduled-Lambda top-up failed 8955 | 'auto_topup_requires_action' // 3DS challenge â payment not complete (P0-003 webhook) 8956 | 'webhook_payment_intent_succeeded' // succeeded PI w/o auto_topup metadata â log only 8957 | 'charge_disputed' // Stripe dispute opened (P0-003 webhook) 8958 | 'refund' 8959 | 'card_updated' 8960 | 'payment_failed'; 8961 8962/** 8963 * Default number of free trial calls a new EDA account starts with. 8964 * \`eda-charge-call\` consumes these before charging the prepaid balance. 8965 * Override per-tenant via \`EDA_FREE_TRIAL_CALL_COUNT\` env on the lambdas 8966 * that read it (edaSignup writes it; eda-charge-call decrements). 8967 */ 8968export const EDA_FREE_TRIAL_CALL_COUNT_DEFAULT = 1; 8969 8970export interface EdaBillingEvent { 8971 /** PK. Cognito sub. */ 8972 accountId: string; 8973 /** SK. ISO timestamp of the event. */ 8974 timestamp: string; 8975 type: EdaBillingEventType; 8976 /** Customer charge in cents. Negative for refunds. */ 8977 amountCents: number; 8978 /** Balance after this row (for ledger reconciliation). Optional on legacy rows. */ 8979 balanceAfterCents?: number; 8980 /** 8981 * Source \`events-customer\` event id used for idempotency. Populated for 8982 * \`call_charge\` and \`payment_failed\` rows. GSI key on \`eventsByEventId\`. 8983 */ 8984 eventId?: string; 8985 /** Patient lead the call was for. */ 8986 leadId?: string; 8987 /** Voice call duration in seconds (raw, before ceil-to-minute). */ 8988 durationSeconds?: number; 8989 /** Stripe PaymentIntent ID â pi_xxx. */ 8990 stripePaymentIntentId?: string; 8991 /** Stripe error code on failed charges (e.g. \`card_declined\`). */ 8992 stripeFailureCode?: string; 8993 /** Human-readable description shown in the relative's dashboard receipts list. */ 8994 description: string; 8995} 8996 8997// -----------------------------------------------------------------------------
8998// eda-care-plans â per-patient (per-Lead) preferences + schedule 8999// ----------------------------------------------------------------------------- 9000 9001export type EdaPatientRelationship = 'parent' | 'spouse' | 'grandparent' | 'sibling' | 'other'; 9002 9003/** Days are lower-case 3-letter abbreviations. */ 9004export type EdaScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'; 9005 9006export interface EdaCallSchedule { 9007 /** Days the agent should call. */ 9008 days: EdaScheduleDay[]; 9009 /** 24-hour HH:MM strings, patient's local timezone. */ 9010 times: string[]; 9011} 9012 9013export interface EdaCarePlanNotifications { 9014 /** Email a summary to the relative after every call. */ 9015 emailAfterCall: boolean; 9016 /** SMS the relative if the call analysis flags distress. */ 9017 smsOnDistress: boolean; 9018 /** 9019 * Phones to text on distress. Defaults to the signed-in relative if empty. 9020 * E.164 format. 9021 */ 9022 distressContacts: string[]; 9023} 9024 9025export interface EdaCarePlan { 9026 /** PK. Lead UUID this plan belongs to. */ 9027 leadId: string; 9028 /** First name as the agent should call them ("Mum", "Dorothy"). */ 9029 patientFirstName: string; 9030 relationship: EdaPatientRelationship; 9031 /** IANA timezone, e.g. "Australia/Sydney". */ 9032 timezone: string; 9033 /** Null = no scheduled calls (paused). */ 9034 schedule: EdaCallSchedule | null; 9035 /** Which configured agent runs the call. Defaults to "hazel" (outbound check-in). */ 9036 agentId: string; 9037 /** Freeform notes appended to the agent's prompt at call time. */ 9038 personaNotes: string; 9039 /** Topics the agent should bring up. */ 9040 topics: string[]; 9041 /** Topics to avoid. */ 9042 avoidTopics: string[]; 9043 notifications: EdaCarePlanNotifications; 9044 // âââ Missed-call escalation (NEW 2026-05-04) âââââââââââââââââââââââââââââ 9045 // Counter incremented by \`eda-call-scheduler\` when a \`lastCalledAt\` slot 9046 // expires without \`eda-call-postprocess\` firing for it (i.e. patient didn't 9047 // pick up). Reset to 0 by \`eda-call-postprocess\` on every successful call. 9048 // When this counter transitions to >= MISSED_CALL_ESCALATION_THRESHOLD (3), 9049 // an escalation SMS fires to every number in \`notifications.distressContacts\`. 9050 /** Number of consecutive scheduled calls that went unanswered. Reset to 0 on first answered call. */ 9051 consecutiveMissedCalls?: number; 9052 /** ISO timestamp of the most recent answered call (set by post-process). */ 9053 lastAnsweredAt?: string; 9054 /** 9055 * ISO of the \`lastCalledAt\` slot we last accounted for in 9056 * \`consecutiveMissedCalls\`. Prevents double-counting if the scheduler 9057 * re-scans the same row before the next call attempt. 9058 */ 9059 lastMissAccountedAt?: string; 9060 /** ISO timestamp of the most recent escalation SMS. Used for dedup â one escalation per missed-streak. */ 9061 lastEscalationAt?: string; 9062 createdAt: string; 9063 updatedAt: string; 9064} 9065 9066/** 9067 * Number of consecutive missed scheduled calls that triggers an escalation 9068 * SMS to \`notifications.distressContacts\`. The counter resets on the first 9069 * answered call. 9070 */ 9071export const MISSED_CALL_ESCALATION_THRESHOLD = 3; 9072 9073// ----------------------------------------------------------------------------- 9074// eda-pricing â single global row, snapshotted onto each account at signup 9075// ----------------------------------------------------------------------------- 9076 9077export interface EdaPricing { 9078 /** PK. Always "eda-call-min" for the per-minute voice rate. */ 9079 pricingId: string; 9080 /** Cents per ceil-rounded minute of voice call (inbound or outbound). */ 9081 ratePerMinuteCents: number; 9082 currency: string; 9083 /** ISO timestamp the rate became effective. */ 9084 effectiveFrom: string; 9085} 9086 9087// ----------------------------------------------------------------------------- 9088// leadToRelative â reverse-lookup so eda-charge-call can resolve Lead â account 9089// ----------------------------------------------------------------------------- 9090 9091export interface LeadToRelative { 9092 /** PK = leadId. */ 9093 leadId: string; 9094 /** SK = Cognito sub of the relative. Composite supports multi-relative-per-patient. */ 9095 accountId: string; 9096 /** ISO timestamp. */ 9097 createdAt: string; 9098} 9099 9100// ----------------------------------------------------------------------------- 9101// Inputs for create/update (used by handlers)
9102// ----------------------------------------------------------------------------- 9103 9104export type CreateEdaCarePlanInput = Omit<EdaCarePlan, 'createdAt' | 'updatedAt'>; 9105export type UpdateEdaCarePlanInput = Partial<Omit<EdaCarePlan, 'leadId' | 'createdAt'>> & { 9106 leadId: string; 9107}; 9108 9109export interface CreateEdaAccountBillingInput { 9110 accountId: string; 9111 stripeCustomerId: string; 9112 defaultPaymentMethodId: string; 9113 ratePerMinuteCents: number; 9114 currency: string; 9115 cardLast4: string; 9116 cardBrand: string; 9117} 9118 9119export interface CreateEdaBillingEventInput { 9120 accountId: string; 9121 type: EdaBillingEventType; 9122 amountCents: number; 9123 description: string; 9124 balanceAfterCents?: number; 9125 eventId?: string; 9126 leadId?: string; 9127 durationSeconds?: number; 9128 stripePaymentIntentId?: string; 9129 stripeFailureCode?: string; 9130} 9131 9132/** Body shape for POST /billing/auto-topup-config. */ 9133export interface UpdateEdaAutoTopUpConfigInput { 9134 enabled: boolean; 9135 thresholdCents: number; 9136 topUpAmountCents: number; 9137} 9138 9139/** Body shape for POST /billing/initial-topup and /billing/manual-topup. */ 9140export interface EdaTopUpInput { 9141 amountCents: number; 9142} 9143 9144// ----------------------------------------------------------------------------- 9145// Defaults 9146// ----------------------------------------------------------------------------- 9147 9148/** Rate used when seeding \`eda-pricing\`. Override in Terraform if needed. */ 9149export const EDA_DEFAULT_RATE_PER_MINUTE_CENTS = 50; 9150 9151export const EDA_DEFAULT_CURRENCY = 'aud'; 9152 9153export const EDA_PRICING_ID = 'eda-call-min'; 9154 9155export const EDA_DEFAULT_AGENT_ID = 'hazel'; 9156 9157/** Tenant name owning the EDA product. Used by the charge-call pipe filter. */ 9158export const EDA_TENANT_NAME = 'everydayassist.ai'; 9159 9160// ----------------------------------------------------------------------------- 9161// Auto top-up â defaults + bounds 9162// ----------------------------------------------------------------------------- 9163 9164/** Default initial credit charged at signup (cents). */ 9165export const EDA_DEFAULT_INITIAL_TOPUP_CENTS = 2000; // $20 9166 9167/** Default trigger threshold (cents). */ 9168export const EDA_DEFAULT_AUTO_TOPUP_THRESHOLD_CENTS = 500; // $5 9169 9170/** Default per-trigger top-up amount (cents). */ 9171export const EDA_DEFAULT_AUTO_TOPUP_AMOUNT_CENTS = 2000; // $20 9172 9173/** Minimum allowed values (validated server-side and at the form layer). */ 9174export const EDA_MIN_TOPUP_AMOUNT_CENTS = 1000; // $10 9175export const EDA_MAX_TOPUP_AMOUNT_CENTS = 20000; // $200 9176export const EDA_MIN_AUTO_TOPUP_THRESHOLD_CENTS = 200; // $2 9177export const EDA_MIN_AUTO_TOPUP_AMOUNT_CENTS = 1000; // $10 9178 9179/** Cooldown between consecutive auto-topup attempts on the same account. */ 9180export const EDA_AUTO_TOPUP_COOLDOWN_MINUTES = 30; 9181 9182/** GSI on eda-account-billing used by edaAutoTopUp. */ 9183export const EDA_ACCOUNTS_BY_AUTO_TOPUP_DUE_INDEX = 'accountsByAutoTopUpDue'; 9184`,Ye=`/** 9185 * Event Customer Type Definitions 9186 * 9187 * Based on Terraform schema: terraform/SHARED/create-dynamo-event-customer 9188 * 9189 * Event table - Customer interaction events (calls, SMS, emails, etc.) 9190 * PRIMARY KEY = id (UUID) 9191 * TIMESTAMPS = createdAt, updatedAt, eventStartUtc, eventEndUtc 9192 * 9193 * GSIs: 9194 * - eventsByLead: All events for a lead (sorted by creation time) 9195 * - eventsByTypeDay: All events of a specific type for a specific date (for daily aggregations) 9196 * - eventsByTenantDay: All events for a tenant for a specific date (for daily aggregations) 9197 * - eventsByTypeTenantDay: All events of a specific type for a specific tenant and date (composite GSI) 9198 * - eventsByAppIdTenantDay: All events for a specific app and tenant, sorted by date (composite GSI) 9199 * - eventsByLeadStatusCreatedAt: Unassigned events by creation time (for identity resolution) 9200 * - eventsByLeadStatusEventDate: Unassigned events by event date (for reporting) 9201 * - eventsByVisitor: Events by visitor ID for anonymous tracking (links to lead on identification) 9202 */ 9203 9204import type { AppointmentMode, AppointmentReminderKind } from './appointment.js'; 9205 9206/** 9207 * Campaign Attribution Schema 9208 * 9209 * Normalized campaign attribution object used across all campaign-related events. 9210 * Campaign attribution is session-scoped and applies only to events occurring after campaign.click. 9211 * 9212 * Attribution Rules: 9213 * - Campaign applies ONLY to events occurring after campaign.click 9214 * - Attribution is session-scoped (60-120 minute TTL) 9215 * - Do NOT retroactively attach campaign to prior events
9216 * - Do NOT attach campaign to inbound calls or organic sessions 9217 */ 9218export interface CampaignAttribution { 9219 /** Campaign identifier (e.g., "sms-au-remedial-deluxe-test") */ 9220 campaignId: string; 9221 9222 /** @deprecated Use channels array instead. Legacy channel through which campaign was delivered. */ 9223 channel?: string; 9224 9225 /** Channels through which campaign can be delivered, in priority order (e.g., ["sms", "email"]) */ 9226 channels?: string[]; 9227 9228 /** Channel mode: 'first' (send to first available) or 'all' (send to all selected) */ 9229 channelMode?: 'first' | 'all'; 9230 9231 /** @deprecated No longer used. Was: Source of the campaign (e.g., "twilio", "sendgrid") */ 9232 source?: string; 9233 9234 /** Medium of the campaign (e.g., "outbound", "inbound") */ 9235 medium: string; 9236 9237 /** Variant of the campaign (e.g., "v1", "v2") */ 9238 variant: string; 9239 9240 /** 9241 * Touch ID - Set to clickId for campaign.click and subsequent events in the session. 9242 * Anchors attribution for session-scoped campaign context. 9243 */ 9244 touchId?: string; 9245} 9246 9247/** 9248 * Valid event types for EventCustomer records 9249 */ 9250export type EventCustomerType = 9251 | 'backfill_shopify_customer' 9252 | 'backfill_shopify_order' 9253 | 'backfill_wicked_contact' 9254 | 'customer.created' 9255 | 'customer.updated' 9256 | 'customer.deleted' 9257 | 'message.received' 9258 | 'messenger.inbound' 9259 | 'messenger.outbound' 9260 | 'chat.message.inbound' 9261 | 'chat.message.outbound' 9262 | 'checkout.abandoned' 9263 | 'checkout.started' 9264 | 'checkout.contact_info_submitted' 9265 | 'checkout.address_info_submitted' 9266 | 'checkout.shipping_info_submitted' 9267 | 'checkout.payment_info_submitted' 9268 | 'form.submitted' 9269 | 'metaform.leads' 9270 | 'inbound_email' 9271 | 'outbound_email' 9272 | 'max_inbound_call' 9273 | 'max_outbound_call' 9274 | 'max_inbound_call_started' 9275 | 'max_outbound_call_started' 9276 | 'inbound.call' 9277 | 'order.confirmed' 9278 | 'order.amended' 9279 | 'order.cancelled' 9280 | 'order.refunded' 9281 | 'inbound_sms' 9282 | 'outbound_sms' 9283 | 'outbound_call' 9284 | 'product.viewed' 9285 | 'page.viewed' 9286 | 'cart.added' 9287 | 'cart.viewed' 9288 | 'cart.updated' 9289 | 'cart.removed' 9290 | 'cart.abandoned' 9291 | 'collection.viewed' 9292 | 'search.performed' 9293 | 'aircall_inbound_call' 9294 | 'aircall_outbound_call' 9295 | 'aircall_inbound_call_started' 9296 | 'aircall_outbound_call_started' 9297 | 'twilio_inbound_call_started' 9298 | 'twilio_outbound_call_started' 9299 /** a voicemail left on one of OUR Twilio numbers: recorded, transcribed after the call, routed to a lane (4 Oct 2026) */ 9300 | 'twilio_voicemail' 9301 | 'campaign.click' 9302 /** a video opened from a tracked link was PLAYED (start / half / end), from the player page (5 Oct 2026) */ 9303 | 'campaign.video_play' 9304 | 'campaign.touch' 9305 | 'social.post.published' 9306 | 'social.click' 9307 | 'call_now_recommended' 9308 | 'call_now_actioned' 9309 | 'agent.review' 9310 | 'agent.assigned' 9311 | 'agent.action_required' 9312 /** 9313 * @deprecated Phase 3 hard cutover (2026-05-05) moved completion signals to 9314 * the business-events substrate (see plan 9315 * \`~/.claude/plans/there-is-a-concept-breezy-lightning.md\`). Producer no 9316 * longer writes this eventType. Value retained ONLY for historical events- 9317 * customer rows still in the table â do NOT use for new producers or 9318 * workflow rule triggers. New equivalent: \`signalKind: 9319 * 'expert_analysis_completed'\` on the business-events table. 9320 */ 9321 | 'expert_analysis.completed' 9322 | 'modal.presented' 9323 | 'modal.dismissed' 9324 | 'referral.captured' 9325 | 'form.sighted' 9326 | 'page.exited' 9327 | 'frustration.rage_click' 9328 | 'exit_intent.detected' 9329 | 'partner.click' 9330 | 'agent.dismissed' 9331 | 'agent.action_rebalanced' 9332 | 'lead.reopened' 9333 | 'draft_order.created' 9334 | 'draft_order.updated' 9335 | 'draft_order.deleted' 9336 | 'draft_order_image.rendered' 9337 | 'sap.order_created' 9338 | 'sap.delivery_update' 9339 | 'sap.order_status' 9340 | 'sales_cycle.stage_changed' 9341 | 'customer.pii_extracted' 9342 | 'agent_note' 9343 | 'ai_sale.stage_changed' 9344 | 'zoho.stage_changed' 9345 | 'backfill_zoho_note' 9346 | 'zoho_contact' 9347 | 'zoho_lead' 9348 | 'manual.created' 9349 /** 9350 * BNPL finance application first-seen â emitted by provider scrapers 9351 * (humm-pending-applications-scraper; future payright-*) on the first 9352 * scrape that observes a row. Routed by lead-and-identity Pipe to 9353 * create-lead-and-identity which appends to \`Lead.financeApplications[]\`. 9354 * 9355 * Idempotent via deterministic id pattern: \`\${provider}-\${applicationId}-received\` 9356 * with \`ConditionExpression: attribute_not_exists(id)\` on PutItem. 9357 */ 9358 | 'finance.humm.application_received' 9359 /** 9360 * BNPL finance application status delta â emitted by provider scrapers 9361 * when a subsequent scrape observes \`recordStatus\` differs from the 9362 * previous scrape. Routed by lead-and-identity Pipe to 9363 * create-lead-and-identity which replaces the matching entry in 9364 * \`Lead.financeApplications[]\` (preserves firstSeenAt). 9365 * 9366 * Idempotent via timestamped id: \`\${provider}-\${applicationId}-status-\${nowIso}\`. 9367 * Status flip-then-flip-back generates two distinct events (correct). 9368 */ 9369 | 'finance.humm.application_status_changed' 9370 /** 9371 * Buyer-Close EXIT â emitted by the lead-disposition-flag-maintainer when a 9372 * lead's resolved dispositionFlag transitions OUT of BUYCLO/AGAM-BC (sold via 9373 * sale-code CDR / UI "Mark as Sold" / order.confirmed, or removed via UI 9374 * "Remove from BC" / hold). \`eventData.outcome\` = 'sold' | 'removed'. Powers 9375 * the change-log BC Today cell's inline exit counts. Deliberately EXCLUDED 9376 * from the lead-and-identity + campaign-triggers pipe allow-lists (no Lead 9377 * creation, no workflow firing). Idempotent via \`bcexit#\${leadId}#\${instantUtc}\`. 9378 */ 9379 | 'buyer_close.exited' 9380 | 'appointment.created' 9381 | 'appointment.updated' 9382 | 'appointment.cancelled' 9383 | 'appointment.accepted' 9384 | 'appointment.declined' 9385 /** 9386 * Appointment REMINDERS â the customer-facing half of the appointment 9387 * lifecycle, emitted by the \`appointment-reminder-scheduler\` Lambda (the only 9388 * producer) when a non-cancelled appointment comes due. Each TRIGGERS AN 9389 * OPERATOR-EDITABLE SMS WORKFLOW, exactly like the \`showroom.*\` family. 9390 * 9391 * â ï¸ These two are the ONLY \`appointment.*\` types on the campaign-triggers 9392 * pipe allow-list. The five audit types above are deliberately unrouted (no 9393 * workflow fires on them) â do not add them. 9394 */ 9395 | 'appointment.reminder_soon' 9396 | 'appointment.reminder_day_before' 9397 /** 9398 * SHOWROOM (in-person) visit lifecycle â the customer-facing half of a 9399 * \`mode:'showroom'\` appointment. Emitted by the ShopDash dataApi ALONGSIDE the 9400 * \`appointment.*\` audit event (which stays the internal record), because each
9401 * of these TRIGGERS AN OPERATOR-EDITABLE SMS WORKFLOW that texts the customer 9402 * the exact date, time and address. \`showroom.delayed\` is the running-late 9403 * case an operator raises on the day. 9404 */ 9405 | 'showroom.booked' 9406 | 'showroom.rescheduled' 9407 | 'showroom.delayed' 9408 | 'showroom.cancelled' 9409 /** 9410 * RESELLER showroom visit lifecycle â the customer-facing half of a 9411 * \`mode:'reseller'\` appointment (a visit to a THIRD-PARTY showroom, MMC 9412 * first). Emitted by the ShopDash dataApi alongside the \`appointment.*\` audit 9413 * event, exactly like the \`showroom.*\` family. Each TRIGGERS AN 9414 * OPERATOR-EDITABLE WORKFLOW that sends TWO separate SMS: one to the customer 9415 * (carrying the reseller's contact-card link) and one to the reseller's 9416 * contact person (carrying the customer's contact-card link â NEVER pricing). 9417 * There is no \`.delayed\`: the tenant cannot know a third-party showroom is 9418 * running late. 9419 */ 9420 | 'reseller_appointment.booked' 9421 | 'reseller_appointment.rescheduled' 9422 | 'reseller_appointment.cancelled' 9423 /** 9424 * An inbound SMS whose sender matched the RESELLER number index (a showroom 9425 * contact texting back about a booking â a third party, NOT the customer). 9426 * Written by toolcall-receive-sms INSTEAD of \`inbound_sms\`, deliberately on 9427 * NO pipe allow-list: no Lead is ever created for a reseller number, no 9428 * workflow fires, and the reseller's words never enter a customer thread 9429 * (design doc §3.7). Carries a top-level \`phone\` stamp so it appears in the 9430 * reseller's /messages thread; a \`reseller_reply\` agent action is raised 9431 * against the booking's lead when one is in range. 9432 */ 9433 | 'reseller_inbound_sms' 9434 /** 9435 * Business mailboxes (3 Oct 2026, plans/platform/business-mailboxes-on-the-platform.md): mail to or 9436 * from a business's accounts@ / ops@ / admin@ (or from a never-a-customer sender), and automated 9437 * mail (bounces, auto-replies, lists) to a customer mailbox. Like \`reseller_inbound_sms\`, these are 9438 * on NO pipe allow-list: no Lead, no workflow, no agent action. Written only for a tenant whose 9439 * record carries \`contactInfo.email.mailboxes\`. 9440 */ 9441 | 'business_inbound_email' 9442 | 'business_outbound_email' 9443 | 'automated_inbound_email' 9444 /** 9445 * AI answered a customer question inline in an SMS reply (delivery or 9446 * service/product). Written post-turn by manage-appointment.ts via a 9447 * classifier over (inbound, reply) â one event per question type per turn, 9448 * deterministic id \`qa_\${inboundEventId}_\${questionType}\` (write-once). 9449 * Deliberately absent from the EventBridge pipe allow-lists (no Lead 9450 * creation, no workflow firing). 9451 */ 9452 | 'ai.question_answered' 9453 /** 9454 * NEXT BEST ACTION DUE (piece C1 stub, 20 Sep 2026; plan \`unified-sales-agent-runtime.md\` 9455 * §7). Written by the NBA engine (piece D) when an AI-owned action's slot arrives; 9456 * routed by a \`cmp-\` rule to \`workflow-nba-ai-<tenant>\` whose step is 9457 * \`run_conversation_turn\` (SMS) or \`run_ai_workload\` â conversation-agent (chat / push). 9458 * â Never parked in campaign-scheduled-executions (24h TTL, needs a campaignId). 9459 */ 9460 | 'nba.action_due' 9461 /** 9462 * NEXT BEST ACTION audit rows (piece D). \`nba.computed\` = the computer's decision for 9463 * a lead (or an anonymous visitor, keyed by visitorId) with its candidates and reasons; 9464 * \`nba.action_executed\` = the due turn ran (entry, chosen tool, outbound id); 9465 * \`agent.action_expired\` = a human action passed its expiry and the AI took it over. 9466 * None is routed by the campaign-triggers pipe; they are audit + funnel rows. 9467 */ 9468 | 'nba.computed' 9469 | 'nba.action_executed' 9470 | 'agent.action_expired' 9471 /** 9472 * CONTENT ENGAGEMENT â consumer content-app (course/lesson) completion signals. 9473 * First producer: the Delta X Coach app (tenant \`deltaxcoach.com\`), emitted via 9474 * its JWT \`/journey-id\` route, so the email is verified. 9475 * 9476 * â ï¸ Payload shape, verified against all 165 prod rows 14â20 Aug 2026: the ONLY 9477 * identifier carried is \`eventData.customer.email\` (154/165; the other 11 are 9478 * logged-out and correctly link to nothing). There is NO root-level 9479 * \`eventData.email\` and NO cognitoSub / stripeCustomerId on these two types.
9480 * That is why create-lead-and-identity must list them in extractCustomerId's 9481 * storefront branch, not just in the dispatcher â see the comment there. 9482 * 9483 * Routed by the lead-and-identity Pipe to create-lead-and-identity, which 9484 * dispatches them down \`storefront_event_processEventCustomerRecord\` (same shape 9485 * as \`product.viewed\`: identity-bearing, non-conversational, no workflow fires). 9486 * 9487 * â ï¸ Deliberately ABSENT from: 9488 * - \`CAMPAIGN_RELEVANT_EVENT_TYPES\` (create-lead-and-identity) â no workflow fires. 9489 * - the campaign-triggers pipe allow-list â same reason. 9490 * - \`DEFAULT_STAGE_MAPPING\` / \`DEFAULT_REENTRY_EVENTS\` (@bigm/shared sales-cycle) â 9491 * a paying member finishing a lesson must NOT reopen their sales cycle. 9492 * - \`classifyEventForChannelSummary\` â non-conversational, so they bump 9493 * \`Lead.lastEventAt\` without creating a channel row. 9494 * Adding them to any of those lists is a behaviour change, not a tidy-up. 9495 */ 9496 | 'lesson.completed' 9497 | 'course.completed' 9498 // Winnings 3PL delivery lifecycle (sap-delivery-poller; supersedes the unused sap.* placeholders). 9499 // delivery.keyed is the one agent-written stage: ShopDash records "I keyed this in SAP" from the 9500 // booking sheet before the poller can confirm it (dataApi POST /data/sap/booking-keyed). 9501 | 'delivery.keyed' 9502 // delivery.reviewed is the second agent-written row: a service person checked an automated 9503 // booking (dataApi POST /data/sap/booking-review) and recorded "correct" or "problem". 9504 | 'delivery.reviewed' 9505 | 'delivery.booked' 9506 | 'delivery.date_changed' 9507 | 'delivery.dispatched' 9508 | 'delivery.completed' 9509 | 'delivery.failed' 9510 | 'delivery.cancelled' 9511 | 'delivery.returned'; 9512 9513/** 9514 * Array of all valid EventCustomerType values (for runtime use) 9515 */ 9516export const EVENT_CUSTOMER_TYPES: readonly EventCustomerType[] = [ 9517 'backfill_shopify_customer', 9518 'backfill_shopify_order', 9519 'backfill_wicked_contact', 9520 'customer.created', 9521 'customer.updated', 9522 'customer.deleted', 9523 'message.received', 9524 'messenger.inbound', 9525 'messenger.outbound', 9526 'chat.message.inbound', 9527 'chat.message.outbound', 9528 'checkout.abandoned', 9529 'checkout.started', 9530 'checkout.contact_info_submitted', 9531 'checkout.address_info_submitted', 9532 'checkout.shipping_info_submitted', 9533 'checkout.payment_info_submitted', 9534 'form.submitted', 9535 'metaform.leads', 9536 'inbound_email', 9537 'outbound_email', 9538 'max_inbound_call', 9539 'max_outbound_call', 9540 'max_inbound_call_started', 9541 'max_outbound_call_started', 9542 'inbound.call', 9543 'order.confirmed', 9544 'order.amended', 9545 'order.cancelled', 9546 'order.refunded', 9547 'inbound_sms', 9548 'outbound_sms', 9549 'outbound_call', 9550 'product.viewed', 9551 'page.viewed', 9552 'cart.added', 9553 'cart.viewed', 9554 'cart.updated', 9555 'cart.removed', 9556 'cart.abandoned', 9557 'collection.viewed', 9558 'search.performed', 9559 'aircall_inbound_call', 9560 'aircall_outbound_call', 9561 'aircall_inbound_call_started', 9562 'aircall_outbound_call_started', 9563 'twilio_inbound_call_started', 9564 'twilio_outbound_call_started', 9565 'twilio_voicemail', 9566 'campaign.click', 9567 'campaign.video_play', 9568 'campaign.touch', 9569 'social.post.published', 9570 'social.click', 9571 'call_now_recommended', 9572 'call_now_actioned', 9573 'agent.review', 9574 'agent.assigned', 9575 'agent.action_required', 9576 'expert_analysis.completed', 9577 'modal.presented', 9578 'modal.dismissed', 9579 'referral.captured', 9580 'form.sighted', 9581 'page.exited', 9582 'frustration.rage_click', 9583 'exit_intent.detected', 9584 'partner.click', 9585 'agent.dismissed', 9586 'agent.action_rebalanced', 9587 'lead.reopened', 9588 'draft_order.created', 9589 'draft_order.updated', 9590 'draft_order.deleted', 9591 'draft_order_image.rendered', 9592 'sap.order_created', 9593 'sap.delivery_update', 9594 'sap.order_status', 9595 'sales_cycle.stage_changed', 9596 'customer.pii_extracted', 9597 'agent_note', 9598 'ai_sale.stage_changed', 9599 'zoho.stage_changed', 9600 'backfill_zoho_note', 9601 'zoho_contact', 9602 'zoho_lead', 9603 'manual.created', 9604 'finance.humm.application_received', 9605 'finance.humm.application_status_changed', 9606 'buyer_close.exited', 9607 'appointment.created', 9608 'appointment.updated', 9609 'appointment.cancelled', 9610 'appointment.accepted', 9611 'appointment.declined', 9612 'appointment.reminder_soon', 9613 'appointment.reminder_day_before', 9614 'showroom.booked', 9615 'showroom.rescheduled', 9616 'showroom.delayed', 9617 'showroom.cancelled', 9618 'reseller_appointment.booked', 9619 'reseller_appointment.rescheduled', 9620 'reseller_appointment.cancelled', 9621 'reseller_inbound_sms', 9622 'business_inbound_email', 9623 'business_outbound_email',
9624 'automated_inbound_email', 9625 'ai.question_answered', 9626 'nba.action_due', 9627 'nba.computed', 9628 'nba.action_executed', 9629 'agent.action_expired', 9630 // Content engagement (consumer content apps â see EventCustomerType for the 9631 // deliberate exclusions from campaign / sales-cycle / channel-summary lists) 9632 'lesson.completed', 9633 'course.completed', 9634 'delivery.keyed', 9635 'delivery.reviewed', 9636 'delivery.booked', 9637 'delivery.date_changed', 9638 'delivery.dispatched', 9639 'delivery.completed', 9640 'delivery.failed', 9641 'delivery.cancelled', 9642 'delivery.returned', 9643] as const; 9644 9645/** 9646 * Event Customer record stored in DynamoDB Event table 9647 * 9648 * @template T - The eventType value. When specified, eventData will be type-safe. 9649 * Defaults to EventCustomerType for backward compatibility. 9650 * 9651 * @example 9652 * // Type-safe order confirmed event 9653 * const order: EventCustomer<'order.confirmed'> = { 9654 * eventType: 'order.confirmed', 9655 * eventData: { 9656 * customer: { email: "[email protected]" } 9657 * } 9658 * }; 9659 * 9660 * @example 9661 * // Generic event (backward compatible) 9662 * const event: EventCustomer = { 9663 * eventType: 'unknown_type', 9664 * eventData: { custom: "data" } 9665 * }; 9666 */ 9667export interface EventCustomer<T extends EventCustomerType = EventCustomerType> { 9668 /** Primary key - UUID */ 9669 id: string; 9670 9671 /** Tenant identifier (for multi-tenancy) */ 9672 tenantName: string; 9673 9674 /** Foreign key to Lead.id (set after processing) */ 9675 leadId?: string; 9676 9677 /** Type of event */ 9678 eventType: T; 9679 9680 /** Event-specific details stored as JSON object - type-safe based on eventType */ 9681 eventData: EventDataForType<T>; 9682 9683 /** UTC timestamp when the event started (required, ISO 8601) */ 9684 eventStartUtc?: string; 9685 9686 /** UTC timestamp when the event ended (optional, ISO 8601) */ 9687 eventEndUtc?: string; 9688 9689 /** Event lock state - true when event has been fully processed and won't be updated further */ 9690 eventLocked: boolean; 9691 9692 /** UTC timestamp when event was created (ISO 8601) */ 9693 createdAt: string; 9694 9695 /** Date partition for efficient daily aggregation queries (YYYY-MM-DD format, extracted from createdAt) */ 9696 eventDate: string; 9697 9698 /** Composite key for eventsByTypeTenantDay GSI (format: eventType#tenantName) */ 9699 eventType_tenantName: string; 9700 9701 /** App ID for multi-app tracking (optional) */ 9702 appId?: string; 9703 9704 /** Composite key for eventsByAppIdTenantDay GSI (format: appId#tenantName, optional) */ 9705 appId_tenantName?: string; 9706 9707 /** 9708 * Derived attribute for campaign query safety (format: eventType#campaign.campaignId) 9709 * Set only when eventData.campaign exists 9710 * Enables future eventsByCampaign GSI without refactor 9711 */ 9712 eventType_campaignId?: string; 9713 9714 /** UTC timestamp when event was last updated (ISO 8601) */ 9715 updatedAt: string; 9716 9717 /** 9718 * Canonical message group ID for EventBridge Pipe routing to SQS FIFO 9719 * Format: \${tenantName}#phone#\${normalizedPhone} or \${tenantName}#email#\${email} or \${tenantName}#anon#\${visitorId} 9720 * Required for events to be processed by create-lead-and-identity Lambda 9721 */ 9722 messageGroupId?: string; 9723 9724 /** 9725 * Visitor identifier for anonymous tracking. 9726 * Format: "anon_{uuid}" for anonymous visitors, or leadId once identified. 9727 * Used to correlate pre-identification events to a lead via eventsByVisitor GSI. 9728 */ 9729 visitorId?: string; 9730 9731 /** 9732 * SPARSE phone-thread key (digits-only E.164, e.g. "61412345678") backing 9733 * the \`eventsByPhone\` GSI. â Stamped ONLY on RESELLER-directed message 9734 * events (outbound via SendSmsRequest.stampPhone, inbound reseller replies) 9735 * so the index stays tiny â never stamp it on normal customer events, which 9736 * are keyed by leadId. 9737 */ 9738 phone?: string; 9739 9740 /** 9741 * Lead assignment status for identity resolution pipeline. 9742 * - UNASSIGNED: Event not yet linked to a lead 9743 * - ASSIGNED: Event linked to a lead via leadId 9744 */ 9745 leadStatus?: 'UNASSIGNED' | 'ASSIGNED'; 9746 9747 /** 9748 * Timestamp when the event was linked to a lead (ISO 8601) 9749 */ 9750 leadLinkedAt?: string; 9751 9752 /** 9753 * Provenance of a PROBABILISTIC lead link. ABSENT on every deterministically 9754 * linked event (visitor enrichment, tracked link, checkout identity) â so 9755 * "no leadLink" means the linkage is confirmed. Present only when a matcher 9756 * inferred the link (e.g. Meta lead â anonymous session via ad_id + time 9757 * proximity). Rows carrying leadLink are soft: lead-enrichment may overwrite 9758 * them on hard identification (clearing leadLink), and engine eligibility 9759 * checks must ignore them unless a step opts in. 9760 */ 9761 leadLink?: EventLeadLink; 9762 9763 /** 9764 * Campaign execution tracking - maps campaignId to execution metadata 9765 * Used to prevent double execution of campaigns on the same checkout 9766 */ 9767 campaignExecutions?: Record<string, { 9768 executedAt: string; 9769 executionId: string; 9770 status: 'pending' | 'completed' | 'failed'; 9771 }>; 9772} 9773 9774/** 9775 * Provenance of a probabilistic eventâlead link (see EventCustomer.leadLink). 9776 */ 9777export interface EventLeadLink { 9778 /** Matching technique that produced the link */ 9779 method: 'ad_time_probabilistic'; 9780 /** Cohort-measured precision of the technique at stamp time (0..1) */ 9781 confidence: number; 9782 /** Meta ad_id shared by the lead payload and the session landing URL */ 9783 adId?: string; 9784 /** Session start minus Meta created_time, in seconds */ 9785 deltaSec?: number; 9786 /** When the matcher stamped this link (ISO 8601) */ 9787 matchedAt: string; 9788 /** Version of the matcher that produced the link */ 9789 matcherVersion: string; 9790} 9791 9792/** 9793 * Event Customer attributes used in GSI queries 9794 */ 9795export interface EventCustomerGSIAttributes { 9796 /** For eventsByLead GSI */ 9797 leadId: string; 9798 createdAt: string; 9799 9800 /** For eventsByTypeDay GSI */ 9801 eventType: string; 9802 eventDate: string; 9803 9804 /** For eventsByTenantDay GSI */ 9805 tenantName: string; 9806 9807 /** For eventsByTypeTenantDay GSI */ 9808 eventType_tenantName: string; 9809 9810 /** For eventsByAppIdTenantDay GSI */
9811 appId_tenantName: string; 9812 9813 /** For eventsByVisitor GSI */ 9814 visitorId: string; 9815 9816 /** For eventsByLeadStatusCreatedAt and eventsByLeadStatusEventDate GSIs */ 9817 leadStatus: 'UNASSIGNED' | 'ASSIGNED'; 9818} 9819 9820/** 9821 * Event Customer creation input (omits auto-generated fields) 9822 * 9823 * @template T - The eventType value. When specified, eventData will be type-safe. 9824 * Defaults to EventCustomerType for backward compatibility. 9825 */ 9826export interface CreateEventCustomerInput<T extends EventCustomerType = EventCustomerType> { 9827 id?: string; 9828 tenantName: string; 9829 leadId?: string; 9830 eventType: T; 9831 eventData: EventDataForType<T>; 9832 eventStartUtc?: string; 9833 eventEndUtc?: string; 9834 eventLocked?: boolean; 9835 /** Composite key for eventsByTypeTenantDay GSI (auto-generated if not provided, format: eventType#tenantName) */ 9836 eventType_tenantName?: string; 9837 /** App ID for multi-app tracking (optional) */ 9838 appId?: string; 9839 /** Composite key for eventsByAppIdTenantDay GSI (auto-generated if not provided, format: appId#tenantName) */ 9840 appId_tenantName?: string; 9841 /** Canonical message group ID for EventBridge Pipe routing to SQS FIFO */ 9842 messageGroupId?: string; 9843 /** Visitor identifier for anonymous tracking */ 9844 visitorId?: string; 9845 /** Lead assignment status (UNASSIGNED | ASSIGNED) */ 9846 leadStatus?: 'UNASSIGNED' | 'ASSIGNED'; 9847 /** Timestamp when linked to lead (ISO 8601) */ 9848 leadLinkedAt?: string; 9849 /** Campaign execution tracking */ 9850 campaignExecutions?: Record<string, { 9851 executedAt: string; 9852 executionId: string; 9853 status: 'pending' | 'completed' | 'failed'; 9854 }>; 9855} 9856 9857/** 9858 * Event Customer update input (only updatable fields) 9859 * 9860 * @template T - The eventType value. When specified, eventData will be type-safe. 9861 * Defaults to EventCustomerType for backward compatibility. 9862 */ 9863export interface UpdateEventCustomerInput<T extends EventCustomerType = EventCustomerType> { 9864 leadId?: string; 9865 eventType?: T; 9866 eventData?: EventDataForType<T>; 9867 eventStartUtc?: string; 9868 eventEndUtc?: string; 9869 eventLocked?: boolean; 9870 updatedAt?: string; 9871 /** Visitor identifier for anonymous tracking */ 9872 visitorId?: string; 9873 /** Lead assignment status (UNASSIGNED | ASSIGNED) */ 9874 leadStatus?: 'UNASSIGNED' | 'ASSIGNED'; 9875 /** Timestamp when linked to lead (ISO 8601) */ 9876 leadLinkedAt?: string; 9877} 9878 9879/** 9880 * Storefront Event Type Definitions 9881 * 9882 * Types for storefront events from Shopify theme extensions. 9883 * These define the structure of eventData for storefront events stored in EventCustomer records. 9884 */ 9885 9886/** 9887 * Identity level for storefront events 9888 */ 9889export type IdentityLevel = 'ANONYMOUS' | 'SESSIONED' | 'AUTHENTICATED'; 9890 9891/** 9892 * Customer data from storefront event 9893 */ 9894export interface CustomerData { 9895 shopifyCustomerId?: string | null; 9896 email?: string | null; 9897 phone?: string | null; 9898 firstName?: string | null; 9899 lastName?: string | null; 9900 isLoggedIn?: boolean; 9901} 9902 9903/** 9904 * Page data from storefront event 9905 */ 9906export interface PageData { 9907 url: string; 9908 path: string; 9909 referrer: string | null; 9910 pageType: string; 9911} 9912 9913/** 9914 * Product data from storefront event 9915 */ 9916export interface ProductData { 9917 productId: string | null; 9918 handle: string | null; 9919 title: string | null; 9920 variantId: string | null; 9921 variantTitle: string | null; 9922 priceCents: number | null; 9923 /** Original price in cents before discounts (compare at price) */ 9924 compareAtPriceCents?: number | null; 9925 currencyCode: string | null; 9926 availability: boolean | null; 9927 /** Stock keeping unit (SKU) */ 9928 sku?: string | null; 9929 /** Product or variant image URL */ 9930 imageUrl?: string | null; 9931 /** Relative URL of the product */ 9932 productUrl?: string | null; 9933 /** Product vendor name */ 9934 vendor?: string | null; 9935 /** Product type */ 9936 type?: string | null; 9937} 9938 9939/** 9940 * Attribution data from storefront event (UTM parameters, etc.) 9941 */ 9942export interface AttributionData { 9943 utmSource?: string | null; 9944 utmMedium?: string | null; 9945 utmCampaign?: string | null; 9946 utmContent?: string | null; 9947 utmTerm?: string | null; 9948 gclid?: string | null; 9949 fbclid?: string | null; 9950 /** Extra URL parameters not covered by standard UTM/click fields (e.g. campaign_id, ad_id, WickedSource) */ 9951 customParams?: Record<string, string>; 9952} 9953 9954/** 9955 * Device type classification derived from user agent and screen metrics 9956 */ 9957export type DeviceType = 'mobile' | 'tablet' | 'desktop'; 9958 9959/** 9960 * Operating system family derived from user agent 9961 */ 9962export type OsFamily = 'iOS' | 'Android' | 'macOS' | 'Windows' | 'Linux' | 'Other'; 9963 9964/** 9965 * Actor who initiated an outbound communication event. 9966 * - 'workflow_automated': Deterministic, template-based automated processes (campaigns, scheduled sends, workflows) 9967 * - 'agent_physical': A real human (ShopDash UI manual send, MaxContact agent, Aircall agent) 9968 * - 'agent_ai': An AI agent toolcall (sms-reply-generator, AI voice/email reply) 9969 */ 9970export type EventActor = 'workflow_automated' | 'agent_physical' | 'agent_ai'; 9971 9972/** All valid EventActor values, in canonical order */ 9973export const EVENT_ACTORS: readonly EventActor[] = ['workflow_automated', 'agent_physical', 'agent_ai'] as const; 9974 9975/** 9976 * How the TEXT of an outbound SMS was produced (18 Sep 2026). Orthogonal to 9977 * \`EventActor\`, which says who initiated the send: the appointment AI's replies 9978 * and a template workflow's SMS both go out as \`workflow_automated\`, and inside 9979 * the AI campaign some bodies are code-built (A/B/C confirmations, review-layer 9980 * close lines) or rendered from an operator template. Expert feedback on the 9981 * /ai-agent page is offered ONLY where \`kind === 'model'\` â that is prose the 9982 * model wrote, the only thing a prompt change can improve. 9983 */ 9984export type SmsGenerationKind = 'model' | 'deterministic' | 'template'; 9985 9986export type SmsGenerationSource = 9987 | 'manage_appointment' // engine tool: appointment AI turn (model or its deterministic paths), runtime v1 9988 | 'ai_review' // engine step 5-det: deterministic decline / opt-out close line 9989 | 'generate_ai_reply' // engine tool: OpenAI reply generator (RETIRED 20 Sep 2026; kept for historical rows) 9990 | 'workflow_template' // engine send_sms rendered the workflow's own template 9991 | 'conversation_turn' // \`runConversationTurn\` in @bigm/shared (runtime v2), any channel 9992 | 'chat_reply' // legacy site chat replier (chat-reply-generator, RETIRED 20 Sep 2026) 9993 | 'nba' // next-best-action chooser (piece D): the decision's rationale, graded on /ai-agent 9994 | 'service_turn'; // AI Service agent for existing customers (promptId service-agent/*), graded on /ai-agent â AI Service tab 9995 9996/** 9997 * \`Generation\` is the channel-agnostic name (20 Sep 2026): the same object is 9998 * stamped on \`outbound_sms\`, \`chat.message.outbound\` and any future outbound row 9999 * a channel adapter writes. \`SmsGeneration\` stays as the original alias so no 10000 * caller changes. Voice turns are not events-customer rows (grading deferred). 10001 */ 10002export type GenerationKind = SmsGenerationKind; 10003export type GenerationSource = SmsGenerationSource; 10004 10005export interface SmsGeneration { 10006 kind: SmsGenerationKind; 10007 source: SmsGenerationSource;
10008 /** Model id that wrote the prose (kind === 'model' only). */ 10009 model?: string; 10010 /** \`@bigm/prompts\` id + registry version pinned at send time, e.g. 'appointment-booking/default.system' / 'v8'. */ 10011 promptId?: string; 10012 promptVersion?: string; 10013 /** The inbound_sms event this turn answered (\`context.eventId\` in the engine). */ 10014 inboundEventId?: string; 10015 /** Action the model chose on this turn: none | book | reschedule | cancel | escalate | ... */ 10016 action?: string; 10017 tokensIn?: number; 10018 tokensOut?: number; 10019 /** What the model was shown on this turn (plan WP1, 21 Sep 2026): the reviewer on 10020 * /ai-agent can tell a blind spot from a bad choice, and the feedback puller copies it 10021 * into the scenario case. Written on model turns only. */ 10022 context?: SmsGenerationContext; 10023} 10024/** The platform's view at model-call time, in ids and counts (never the prompt text). */ 10025export interface SmsGenerationContext { 10026 /** Context providers that RENDERED a non-empty block this turn (not the enabled list). */ 10027 providers: string[]; 10028 /** SMS rows fed to the model as conversation turns. */ 10029 historyRows: number; 10030 /** Showroom keys offered this turn (R1..Rn), empty when the reseller flow was off or no list existed. */ 10031 showroomKeys?: string[]; 10032 /** Runtime that produced the turn. */ 10033 runtime?: 'v1' | 'v2'; 10034} 10035export type Generation = SmsGeneration; 10036 10037/** 10038 * Browser/device context data from storefront event 10039 * 10040 * This interface represents the full device context captured by the web pixel 10041 * and form-fanout extension. Not all fields may be present in historical data. 10042 */ 10043export interface ContextData { 10044 // Core browser context (original fields) 10045 userAgent?: string | null; 10046 viewportWidth?: number | null; 10047 viewportHeight?: number | null; 10048 language?: string | null; 10049 timezoneOffset?: number | null; 10050 10051 // Device classification (from web pixel deviceContext) 10052 /** Device type: mobile, tablet, or desktop */ 10053 deviceType?: DeviceType | null; 10054 /** Operating system family: iOS, Android, macOS, Windows, Linux, Other */ 10055 osFamily?: OsFamily | null; 10056 /** Whether device supports touch input */ 10057 isTouch?: boolean | null; 10058 /** Physical screen width in pixels */ 10059 screenWidth?: number | null; 10060 /** Physical screen height in pixels */ 10061 screenHeight?: number | null; 10062} 10063 10064/** 10065 * Device context data - alias for ContextData 10066 * Matches the field name used by web pixel (deviceContext) 10067 */ 10068export type DeviceContext = ContextData; 10069 10070/** 10071 * Identity data calculated for storefront event 10072 */ 10073export interface IdentityData { 10074 level: IdentityLevel; 10075 ip?: string | null; 10076} 10077 10078/** 10079 * Intent score data calculated for storefront event 10080 */ 10081export interface IntentData { 10082 score: number; 10083} 10084 10085/** 10086 * Incoming storefront event payload from API Gateway 10087 */ 10088export interface StorefrontEventPayload { 10089 eventType: string; 10090 occurredAt: string; 10091 source: string; 10092 tenantName: string; 10093 shopDomain: string; 10094 visitorId: string; 10095 sessionId: string; 10096 customer?: CustomerData; 10097 page: PageData; 10098 product?: ProductData; 10099 attribution?: AttributionData; 10100 /** Browser context (legacy field name) */ 10101 context?: ContextData; 10102 /** Device context from web pixel (preferred field name) */ 10103 deviceContext?: DeviceContext; 10104 campaign?: Record<string, unknown>; 10105 /** Page engagement metrics sent by form-fanout */ 10106 engagement?: EngagementData; 10107 /** Frustration signal data sent by form-fanout */ 10108 frustration?: FrustrationData; 10109} 10110 10111/** 10112 * Processed storefront event data structure stored in EventCustomer.eventData 10113 */ 10114export interface StorefrontEventData { 10115 identity: IdentityData; 10116 visitorId: string; 10117 customer?: CustomerData; 10118 page: PageData; 10119 product?: ProductData; 10120 /** Browser context (legacy) - may contain device fields */ 10121 context?: ContextData; 10122 /** Device context from web pixel (preferred) */ 10123 deviceContext?: DeviceContext; 10124 attribution?: AttributionData; 10125 campaign?: Record<string, unknown>; 10126 intent: IntentData; 10127 /** Page engagement metrics (form.sighted, page.exited events) */ 10128 engagement?: EngagementData; 10129 /** Frustration signal data (frustration.rage_click events) */ 10130 frustration?: FrustrationData; 10131 /** Form detection results from page scan (on page.viewed and product.viewed) */ 10132 formsDetected?: FormsDetectedData; 10133} 10134 10135/** 10136 * Page engagement metrics captured by form-fanout theme extension 10137 */ 10138export interface EngagementData { 10139 /** Time user actively engaged with page in milliseconds (excludes tab-hidden time) */ 10140 engagedTimeMs?: number; 10141 /** Maximum scroll depth reached (0-1 as decimal, e.g. 0.75 = 75%) */ 10142 maxScrollDepth?: number; 10143 /** Whether a form was visible in the viewport during this page visit */ 10144 formSighted?: boolean; 10145 /** Milliseconds after page load when form first entered viewport */ 10146 formSightedAfterMs?: number; 10147 /** Form element identifier (id attribute, action URL, or 'unknown') */ 10148 formId?: string; 10149 /** Percentage of form element visible when sighted (0-100) */ 10150 viewportPercent?: number; 10151 /** What triggered exit intent: 'mouse_leave_top' | 'rapid_scroll_up' */ 10152 exitIntentTrigger?: string; 10153 /** Human-readable form label (dialog heading for popups, form heading/aria-label for embedded) */ 10154 formLabel?: string; 10155 /** Whether the form is inside a popup/modal or embedded on the page: 'popup' | 'embedded' */ 10156 formContext?: 'popup' | 'embedded'; 10157 /** Form provider: 'klaviyo' | 'zoho' | 'globo' | 'native' | 'third_party' */ 10158 formProvider?: string; 10159} 10160 10161/** 10162 * User frustration signal data captured by form-fanout theme extension 10163 */ 10164export interface FrustrationData { 10165 /** CSS-like selector describing the clicked element: "a#logo.header-logo" */ 10166 element: string; 10167 /** Truncated innerText of the element (max 50 chars) */ 10168 elementText?: string; 10169 /** Number of rapid clicks detected (3+) */ 10170 clickCount: number; 10171 /** Click X position relative to viewport */ 10172 areaX?: number; 10173 /** Click Y position relative to viewport */ 10174 areaY?: number; 10175} 10176 10177/** 10178 * Form detection results from scanFormsOnPage() in form-fanout theme extension. 10179 * Attached to page.viewed and product.viewed events. 10180 */ 10181export interface FormsDetectedData { 10182 /** Total number of forms detected on the page */ 10183 totalForms: number; 10184 /** Whether any form contains an email input */ 10185 hasEmailInput: boolean; 10186 /** Whether any form contains a phone input */ 10187 hasPhoneInput: boolean; 10188 /** IDs of detected forms (max 5) */ 10189 formIds: string[]; 10190 /** Form providers found: 'native', 'klaviyo', 'third_party' */ 10191 providers: string[]; 10192} 10193 10194/** 10195 * Modal/popup event data from form-fanout theme extension. 10196 * Used for modal.presented and modal.dismissed events. 10197 */ 10198export interface ModalEventData { 10199 /** Modal classification */ 10200 modalType?: string; 10201 /** Form ID within the modal (if any) */ 10202 modalFormId?: string; 10203 /** Modal heading text (max 100 chars) */ 10204 modalTitle?: string; 10205 /** Whether the modal contains form elements */ 10206 hasForm?: boolean; 10207 /** Unique modal identifier for tracking present/dismiss pairs */ 10208 modalId?: string; 10209 /** Time the modal was displayed before dismissal (ms) - only on modal.dismissed */ 10210 displayDurationMs?: number; 10211} 10212 10213// ============================================================================ 10214// Event-Specific Data Interfaces 10215// ============================================================================ 10216 10217/** 10218 * Shopify address structure used in orders and customers 10219 */ 10220export interface ShopifyAddress { 10221 first_name?: string | null; 10222 last_name?: string | null; 10223 address1?: string | null; 10224 address2?: string | null; 10225 city?: string | null; 10226 province?: string | null; 10227 province_code?: string | null; 10228 country?: string | null; 10229 country_code?: string | null; 10230 zip?: string | null; 10231 phone?: string | null; 10232 company?: string | null; 10233 name?: string | null; 10234 latitude?: number | null; 10235 longitude?: number | null; 10236} 10237 10238/** 10239 * Shopify customer structure nested in order events 10240 */ 10241export interface ShopifyCustomerNested { 10242 id?: number | string; 10243 email?: string | null; 10244 phone?: string | null; 10245 first_name?: string | null; 10246 last_name?: string | null; 10247 default_address?: ShopifyAddress; 10248 addresses?: ShopifyAddress[]; 10249 verified_email?: boolean; 10250 tags?: string; 10251 note?: string | null; 10252 created_at?: string; 10253 updated_at?: string; 10254} 10255 10256/** 10257 * EventData for shopify_order and backfill_shopify_order events 10258 */ 10259export interface ShopifyOrderEventData { 10260 /** Shopify order ID */ 10261 id?: number | string; 10262 /** Human-readable order number */ 10263 order_number?: number; 10264 /** Order contact email */ 10265 contact_email?: string | null; 10266 email?: string | null; 10267 phone?: string | null; 10268 /** Customer information */ 10269 customer?: ShopifyCustomerNested; 10270 /** Billing address */ 10271 billing_address?: ShopifyAddress; 10272 /** Shipping address */ 10273 shipping_address?: ShopifyAddress; 10274 /** Order totals */ 10275 total_price?: string; 10276 subtotal_price?: string; 10277 total_tax?: string; 10278 total_discounts?: string; 10279 currency?: string; 10280 /** Order status */ 10281 financial_status?: string; 10282 fulfillment_status?: string | null; 10283 /** Timestamps */ 10284 created_at?: string; 10285 updated_at?: string; 10286 processed_at?: string; 10287 cancelled_at?: string | null; 10288 /** Line items (loosely typed for flexibility) */ 10289 line_items?: Array<Record<string, unknown>>; 10290 /** Additional order fields */ 10291 note?: string | null; 10292 tags?: string; 10293 source_name?: string; 10294 gateway?: string; 10295 /** Links to the source PO PDF + source email PDF for salesorder-ingest bookings (public URLs). */ 10296 poDocuments?: { poPdfUrl?: string; emailPdfUrl?: string }; 10297} 10298 10299/** 10300 * Canonical persisted shape for draft-order line items in events-customer. 10301 *
10302 * The UI (\`formatOrderDetailsHTML\` in shopdash) depends on \`title\`, \`price\`, 10303 * and \`quantity\` being present. All three writers (workflow, scheduled poll, 10304 * Shopify webhook) MUST route through \`buildDraftOrderEventPayload\` in 10305 * \`@bigm/shared\` so this shape is guaranteed end-to-end. 10306 */ 10307export interface PersistedDraftOrderLineItem { 10308 /** Product title â required, UI depends on it */ 10309 title: string; 10310 /** Shopify variant ID â required */ 10311 variant_id: number; 10312 /** Line-item quantity â required */ 10313 quantity: number; 10314 /** Unit price as Shopify returns it (string-encoded decimal) â required */ 10315 price: string; 10316 /** Variant option label (e.g., "Large / Blue") */ 10317 variant_title?: string | null; 10318 /** Shopify product ID */ 10319 product_id?: number; 10320 /** Stock keeping unit */ 10321 sku?: string | null; 10322 /** Product vendor */ 10323 vendor?: string; 10324 /** Whether the line is taxable */ 10325 taxable?: boolean; 10326 /** Whether the variant is a gift card */ 10327 gift_card?: boolean; 10328 /** Item weight in grams */ 10329 grams?: number; 10330 /** Shopify often duplicates title here */ 10331 name?: string; 10332 /** Line-item properties */ 10333 properties?: Array<{ name: string; value: string }>; 10334 /** Per-line discount applied to this item */ 10335 applied_discount?: { 10336 description?: string; 10337 value_type?: 'percentage' | 'fixed_amount'; 10338 value?: string; 10339 amount?: string; 10340 title?: string; 10341 }; 10342 /** Total discount attributed to this line */ 10343 total_discount?: string; 10344 /** 10345 * Variant's catalog compareAtPrice at the time the draft event was written 10346 * (decimal string, e.g. "100.00"). Stamped from ProductCatalog by every 10347 * writer that routes through \`buildDraftOrderEventPayload\`. When set and 10348 * greater than \`price\`, the UI renders a strikethrough "Normal/Final" 10349 * comparison. Omitted when the variant is not on sale or the catalog 10350 * lookup failed (non-fatal). 10351 */ 10352 compare_at_price?: string; 10353} 10354 10355/** 10356 * EventData for draft_order.created events. 10357 * Extends ShopifyOrderEventData (minus \`line_items\`, which we narrow to the 10358 * persisted shape) because the Shopify draft order API returns the same core 10359 * fields (customer, addresses, pricing). 10360 */ 10361export interface DraftOrderEventData extends Omit<ShopifyOrderEventData, 'line_items'> { 10362 /** Shopify draft order ID */ 10363 draft_order_id?: number | string; 10364 /** Human-readable draft order name, e.g. "#D1001" */ 10365 name?: string; 10366 /** Draft order status */ 10367 status?: 'open' | 'invoice_sent' | 'completed'; 10368 /** URL for the invoice sent to the customer */ 10369 invoice_url?: string; 10370 /** When the invoice was sent */ 10371 invoice_sent_at?: string | null; 10372 /** When the draft order was completed (converted to order) */ 10373 completed_at?: string | null; 10374 /** Shopify order ID if the draft was converted to a real order */ 10375 order_id?: number | string | null; 10376 /** Note attributes (key-value pairs, used for campaign attribution) */ 10377 note_attributes?: Array<{ name: string; value: string }>; 10378 /** Applied discount on the entire draft order */ 10379 applied_discount?: { 10380 description?: string; 10381 value_type?: 'percentage' | 'fixed_amount'; 10382 value?: string; 10383 amount?: string; 10384 title?: string; 10385 }; 10386 /** 10387 * Narrowed line items (overrides parent's loose Record<string, unknown>[]). 10388 * Guaranteed to carry title + price + quantity when written via the 10389 * canonical builder in @bigm/shared. 10390 */ 10391 line_items?: PersistedDraftOrderLineItem[]; 10392 /** Workflow campaign that created this draft order (workflow path only) */ 10393 campaign_id?: string; 10394 /** Lead associated with the draft order (workflow path only) */ 10395 lead_id?: string; 10396 /** Actor that triggered creation â read by dismiss_and_close_lead actor gate */ 10397 actor?: EventActor; 10398 /** 10399 * Custom shipping line committed on the draft (price "0.00" = free delivery, 10400 * e.g. "FREE White Glove Delivery"). Absent/null when no shipping line is set 10401 * and Shopify resolves shipping from the store's profiles at checkout. 10402 */ 10403 shipping_line?: { 10404 title?: string; 10405 price?: string; 10406 custom?: boolean; 10407 handle?: string | null; 10408 } | null; 10409 /** Public S3 URL for the rendered preview image (stamped by draft-order-image-renderer Lambda) */ 10410 draft_order_image_url?: string; 10411 /** ISO8601 timestamp when the preview image was last rendered */ 10412 draft_order_image_rendered_at?: string; 10413 /** S3 key for the rendered preview image (under bigm-mms-cards bu
10413cket, draft-orders/ prefix) */ 10414 draft_order_image_s3_key?: string; 10415} 10416 10417/** 10418 * EventData for SAP ERP events (order creation, delivery updates, order status) 10419 */ 10420export interface SapOrderEventData { 10421 /** SAP sales order number */ 10422 sapOrderNumber: string; 10423 /** SAP document type (ZWSO, ZRE, ZEX, etc.) */ 10424 docType: string; 10425 /** Shopify order number or customer reference used for linkage */ 10426 customerReference?: string; 10427 /** Requested delivery date (YYYYMMDD) */ 10428 deliveryDate?: string; 10429 /** Net order value */ 10430 netValue?: string; 10431 /** Order/delivery status */ 10432 status?: string; 10433 /** Line items */ 10434 lineItems?: Array<{ 10435 sku: string; 10436 quantity: string; 10437 description?: string; 10438 }>; 10439 /** Delivery outcomes from SAP */ 10440 deliveryOutcomes?: string; 10441 /** Shipping/tracking details */ 10442 shippingDetails?: string; 10443 /** SAP facility code (2000=NSW, 3000=VIC, etc.) */ 10444 facility?: string; 10445} 10446 10447/** 10448 * EventData for shopify_customer and backfill_shopify_customer events 10449 */ 10450export interface ShopifyCustomerEventData { 10451 /** Shopify customer ID */ 10452 id?: number | string; 10453 email?: string | null; 10454 phone?: string | null; 10455 first_name?: string | null; 10456 last_name?: string | null; 10457 default_address?: ShopifyAddress; 10458 addresses?: ShopifyAddress[]; 10459 verified_email?: boolean; 10460 tags?: string; 10461 note?: string | null; 10462 created_at?: string; 10463 updated_at?: string; 10464 orders_count?: number; 10465 total_spent?: string; 10466 state?: string; 10467 accepts_marketing?: boolean; 10468} 10469 10470/** 10471 * EventData for \`zoho_contact\` and \`zoho_lead\` events â landed by the 10472 * full-Zoho-org backfill crawler. One event per Zoho Contact (or Leads-module 10473 * record), capturing the structured custom fields that Zoho-using agents 10474 * populate over time (Height, Weight, Customer_Address, Driveway_Details, 10475 * Chair_Colour, Measurements, Lead_Source, Other_Information). 10476 * 10477 * The text-pii-extractor flattens these into a single labelled-line text 10478 * blob and runs the LLM through the \`agent-note\` channel â third-person, 10479 * agent-stamped data follows the same precision rules as a Zoho Note. 10480 * 10481 * \`customFieldsRaw\` carries any non-canonicalised remaining fields so we 10482 * can audit what Zoho returned without losing fidelity. 10483 */ 10484export interface ZohoStructuredEventData { 10485 /** Zoho record id (Contact or Lead module). */ 10486 zohoRecordId: string; 10487 /** 'Contacts' | 'Leads' â the source module. */ 10488 zohoModule: string; 10489 /** Display name from Zoho (Full_Name). */ 10490 fullName?: string; 10491 firstName?: string; 10492 lastName?: string; 10493 phone?: string; 10494 mobile?: string; 10495 email?: string; 10496 /** Owner of the Zoho record (the agent who manages this customer). */ 10497 ownerName?: string; 10498 ownerZohoId?: string; 10499 /** 10500 * Free-form fields stamped on Zoho Contacts over time. Field set validated 10501 * against the live masseuse Zoho schema (probe-zoho-record.ts, 2026-04-25). 10502 * \`Height\` / \`Weight\` / \`Chair_Colour\` are NOT in this Zoho org's schema â 10503 * they were dropped from the default fetch. Height/weight come through 10504 * elsewhere (call notes, Other_Information). 10505 */ 10506 customerAddress?: string; 10507 drivewayDetails?: string; 10508 /** Real Zoho api_name: \`Chair_Type_Colour_Confirmed\`. */ 10509 chairColourConfirmed?: string; 10510 measurements?: string; 10511 otherInformation?: string; 10512 reasonForPurchase?: string; 10513 /** Multiselect picklist â high-yield source for painAreas merge. */ 10514 whereIsThePain?: string[]; 10515 yearOfBirth?: string; 10516 gender?: string; 10517 mailingAddress?: string; 10518 mailingStreet?: string; 10519 mailingStreet2?: string; 10520 mailingCity?: string; 10521 mailingState?: string; 10522 mailingZip?: string; 10523 mailingCountry?: string; 10524 /** Zoho's own Lead_Source field (separate from our LeadSourceChannel). */ 10525 zohoLeadSource?: string; 10526 /** Catch-all for unparsed/unmapped custom fields, for audit + future mining. */ 10527 customFieldsRaw?: Record<string, unknown>; 10528 /** Zoho timestamps. */ 10529 originalCreatedAt?: string; 10530 originalModifiedAt?: string; 10531 /** Identifiers propagated for create-lead-and-identity / leadId resolution. */ 10532 phoneForResolution?: string; 10533 emailForResolution?: string; 10534} 10535 10536/** 10537 * EventData for \`agent_note\` (real-time) and \`backfill_zoho_note\` (historical). 10538 *
10539 * Notes are agent-authored narrative content about a customer. Unlike SMS/email 10540 * channels where the customer's utterance is primary, here the AGENT is the 10541 * speaker narrating about the customer. The extractor channel for these is 10542 * \`'agent-note'\` and applies third-party-attribution rules harder (agents 10543 * frequently mention family members, competitors, recipients â those must NOT 10544 * be attributed to the customer's record). 10545 * 10546 * Source precedence for \`body\`: 10547 * - Zoho Notes API â \`Note_Content\` (verified against /crm/v8/Notes) 10548 * - Future: UI-typed manual notes 10549 * - Future: outbound_email bodies (pending event-email table mining) 10550 */ 10551/** 10552 * Every move of \`Lead.aiSale.stage\`, so the funnel on /ai-agent is a count of rows 10553 * rather than a scan of leads (plan \`sales-ai-mhc-ai-sale-mode.md\`). 10554 */ 10555/** 10556 * The platform moved this lead's Zoho Deal \`Stage\`, which is what moves its MaxContact 10557 * LIST, which is what stops the dialler ringing them. 10558 * 10559 * Chris, 23 Sep 2026: he wants the move on the lead "as a customer event so that it can be 10560 * seen in the event timeline and also in the AI agent UI ... in the vertical scroll 10561 * timeline". Without a row, the single most consequential thing the automation does to a 10562 * lead (taking them out of the dial list) would be invisible to the person reviewing it. 10563 * 10564 * Mechanism, confirmed by both vendors in Ardento ticket 13235 and then measured over 10565 * 4,000 Deals: write \`Stage\`, and Ardento's existing function derives 10566 * \`MaxContact_List_Name\` and pushes the list change to MAX. \`Closed Won\` lands on \`Sale\` 10567 * (97%), \`Buyer Closing\` on \`BuyerClosing\` (100%). A disposition cannot be set by API at 10568 * all, so the list is the only lever. 10569 */ 10570export interface ZohoStageChangedEventData { 10571 /** The Deal Stage before, when we knew it. */ 10572 from?: string; 10573 /** The Deal Stage we wrote, e.g. \`Closed Won\`, \`Buyer Closing\`. */ 10574 to: string; 10575 /** The MAX list this Stage is expected to land the lead on, e.g. \`Sale\`, \`BuyerClosing\`. */ 10576 maxList?: string; 10577 /** Why the platform moved it, e.g. \`ai_sale_active\`, \`order_paid\`, \`handed_off\`. */ 10578 reason: string; 10579 /** The Zoho Deal this was written to. */ 10580 dealId?: string; 10581 /** Who moved it. \`agent_ai\` when the sales AI took the lead over. */ 10582 actor?: EventActor; 10583 /** False when the Stage was NOT written to Zoho (dry run, or the write path is off). */ 10584 written: boolean; 10585 /** Why it was not written, when \`written\` is false. */ 10586 skippedReason?: string; 10587} 10588 10589export interface AiSaleStageChangedEventData { 10590 from?: string; 10591 to: string; 10592 /** Why it moved: \`nba_selected\`, \`question_asked\`, \`product_picked\`, \`card_sent\`, 10593 * \`order_paid\`, \`delivery_booked\`, a hand-off reason, or a close reason. */ 10594 reason: string; 10595 promptVersion?: string; 10596 modelTier?: string; 10597 draftOrderId?: string; 10598 orderId?: string; 10599 variantId?: string; 10600 /** The tenant's mode at the time: a \`shadow\` row changed no customer-facing reply. */ 10601 mode?: 'shadow' | 'live'; 10602} 10603 10604export interface AgentNoteEventData { 10605 /** Required â the note text. For Zoho notes this is Note_Content. */ 10606 body: string; 10607 /** Optional note title. For Zoho notes this is Note_Title (usually null). */ 10608 title?: string; 10609 /** Origin of the note â determines how aggressively to trust the content. 10610 * \`ai\` = written by the sales AI at the end of a conversation segment, in the 10611 * shape of the agents' own Zoho notes (plan \`sales-ai-mhc-ai-sale-mode.md\`). */ 10612 noteType: 'zoho' | 'manual' | 'email_body' | 'ai'; 10613 /** Display name of the authoring agent (from parent Deal.Owner for Zoho). */ 10614 authorAgentName?: string; 10615 /** Zoho user id of the authoring agent, when known. */ 10616 authorAgentZohoId?: string; 10617 /** Zoho Note id â used as part of the deterministic events-customer id. */ 10618 zohoNoteId?: string; 10619 /** Zoho parent module ("Deals" / "Contacts" / "Leads"). */ 10620 zohoParentModule?: string; 10621 /** Zoho parent record id. */ 10622 zohoParentId?: string; 10623 /** Original note Created_Time from Zoho (ISO 8601). */ 10624 originalCreatedAt?: string; 10625 /** Identifiers propagated so create-lead-and-identity can resolve leadId. */ 10626 phone?: string; 10627 email?: string; 10628} 10629 10630/** 10631 * EventData for inbound_email and outbound_email events 10632 */ 10633export interface EmailEventData { 10634 /** Sender email address */ 10635 from?: string; 10636 /** Recipient email address(es) */ 10637 to?: string | string[]; 10638 /** Alternative sender fields */ 10639 sender?: string; 10640 senderEmail?: string; 10641 /** Email content */ 10642 subject?: string; 10643 body?: string; 10644 message?: string; 10645 /** Email metadata */ 10646 messageId?: string; 10647 threadId?: string; 10648 inReplyTo?: string; 10649 /** Attachments (loosely typed) */ 10650 attachments?: Array<Record<string, unknown>>; 10651 /** Campaign context (if campaign-related) */ 10652 campaignId?: string; 10653 campaignContext?: Record<string, unknown>; 10654 /** Actor who initiated this outbound email â see EventActor */ 10655 actor?: EventActor; 10656} 10657
10658/** 10659 * EventData for inbound_sms and outbound_sms events 10660 */ 10661export interface SmsEventData { 10662 /** Phone number SMS was sent from */ 10663 from: string; 10664 /** Phone number SMS was sent to */ 10665 to?: string; 10666 /** SMS content. For outbound rows produced by \`toolcall-send-sms\` the 10667 * top-level \`body\` is often absent â the same value lives under nested 10668 * \`twilioResponse.body\` and \`request.body\` (see those fields below). */ 10669 body?: string; 10670 message?: string; 10671 /** Outbound SMS only â verbatim Twilio response object. Carries the 10672 * authoritative \`body\` when the top-level field isn't populated. */ 10673 twilioResponse?: { 10674 sid?: string; 10675 body?: string; 10676 from?: string; 10677 to?: string; 10678 status?: string; 10679 [k: string]: unknown; 10680 }; 10681 /** Outbound SMS only â echo of the inbound request the caller made. Carries 10682 * the original \`body\` before any masking applied by the handler. */ 10683 request?: { 10684 to?: string; 10685 body?: string; 10686 twilioPhoneNumber?: string; 10687 [k: string]: unknown; 10688 }; 10689 /** Twilio message identifiers */ 10690 messageSid?: string; 10691 MessageSid?: string; 10692 SmsSid?: string; 10693 /** Message status */ 10694 status?: string; 10695 SmsStatus?: string; 10696 /** Segment count */ 10697 NumSegments?: string; 10698 /** MMS media: number of attachments ("0" if none). Twilio sends as string. */ 10699 NumMedia?: string; 10700 /** 10701 * MMS media URLs as stamped by the inbound webhook. Twilio posts one field 10702 * per attachment as \`MediaUrl0\`, \`MediaUrl1\`, â¦, \`MediaUrl9\` (max 10). Each 10703 * value is a REST URL of the form 10704 * \`https://api.twilio.com/2010-04-01/Accounts/{AC}/Messages/{MM}/Media/{ME}\` 10705 * which 302-redirects to a pre-signed CloudFront URL when fetched with HTTP 10706 * Basic auth (subaccountSid:authToken). The frontend resolves these via the 10707 * \`/data/mms-media\` dataApi proxy so the auth token never leaves the backend. 10708 */ 10709 MediaUrl0?: string; 10710 MediaUrl1?: string; 10711 MediaUrl2?: string; 10712 MediaUrl3?: string; 10713 MediaUrl4?: string; 10714 MediaUrl5?: string; 10715 MediaUrl6?: string; 10716 MediaUrl7?: string; 10717 MediaUrl8?: string; 10718 MediaUrl9?: string; 10719 /** MIME type per attachment, parallel to MediaUrl{N}. */ 10720 MediaContentType0?: string; 10721 MediaContentType1?: string; 10722 MediaContentType2?: string; 10723 MediaContentType3?: string; 10724 MediaContentType4?: string; 10725 MediaContentType5?: string; 10726 MediaContentType6?: string; 10727 MediaContentType7?: string; 10728 MediaContentType8?: string; 10729 MediaContentType9?: string; 10730 /** 10731 * S3 mirror of inbound MMS media. Populated by the \`toolcall-receive-sms\` 10732 * Lambda on webhook receipt: each \`MediaUrl{N}\` is downloaded from Twilio 10733 * (with Basic auth) and re-uploaded to a tenant-scoped key in 10734 * \`bigm-inbound-mms\`. This is the durable source of truth for MMS photos â 10735 * the raw \`MediaUrl{N}\` fields are a pre-signed CloudFront reference that 10736 * requires auth to hit directly, so we can't show them to browsers. 10737 * 10738 * The frontend reads these via \`dataApi\` which converts \`s3MediaKeys\` to 10739 * short-lived presigned GET URLs before returning them as \`mediaUrl[]\` on 10740 * the UnifiedMessage shape. 10741 */ 10742 s3MediaBucket?: string; 10743 s3MediaKeys?: string[]; 10744 s3MediaContentTypes?: string[]; 10745 /** Campaign context (if campaign-related) */ 10746 campaignId?: string; 10747 campaignContext?: Record<string, unknown>; 10748 /** Actor who initiated this outbound SMS â see EventActor */ 10749 actor?: EventActor; 10750 /** How the text was produced (model prose vs code-built vs template) â see SmsGeneration. Absent on rows older than 18 Sep 2026 and on callers that do not stamp it. */ 10751 generation?: SmsGeneration; 10752} 10753 10754/** 10755 * Attachment in a Facebook Messenger message 10756 */ 10757export interface MessengerAttachment { 10758 type: 'image' | 'video' | 'audio' | 'file' | 'fallback'; 10759 payload: { 10760 url?: string; 10761 title?: string; 10762 sticker_id?: number; 10763 }; 10764} 10765 10766/** 10767 * EventData for messenger.inbound and messenger.outbound events 10768 */ 10769export interface MessengerEventData { 10770 /** Meta unique message ID */ 10771 mid: string; 10772 /** Page-scoped user ID (customer) */ 10773 psid: string; 10774 /** Facebook Page ID */ 10775 pageId: string; 10776 /** Message text content */ 10777 text?: string; 10778 /** Images, videos, files */ 10779 attachments?: MessengerAttachment[]; 10780 /** Direction of the message */ 10781 direction: 'inbound' | 'outbound'; 10782 /** Sender name from Graph API if available */ 10783 senderName?: string; 10784 /** Outbound only â Meta app_id of the page-side tool that sent the reply (from the message echo) */ 10785 sourceAppId?: string; 10786 /** Outbound only â human label for sourceAppId when known (e.g. 'Zoho', 'Meta Business Suite') */ 10787 sourceApp?: string; 10788 /** Outbound only â display name of the human agent who sent from ShopDash */ 10789 sentByName?: string; 10790 /** Actor who initiated this outbound messenger message â see EventActor (only set for messenger.outbound) */ 10791 actor?: EventActor; 10792} 10793
10794/** 10795 * EventData for site-chat message events (chat.message.inbound, chat.message.outbound). 10796 * Sourced from the chat-messages table via the chat-to-customer EventBridge Pipe. 10797 */ 10798export interface ChatMessageEventData { 10799 /** Chat session ID (PK partition in chat-messages table) */ 10800 sessionId: string; 10801 /** Chat message ID (SK in chat-messages table) */ 10802 messageId: string; 10803 /** Message body text */ 10804 body: string; 10805 /** Who sent it: visitor (inbound), agent (outbound human), ai (outbound bot), system (outbound automated) */ 10806 senderType: 'visitor' | 'agent' | 'ai' | 'system'; 10807 /** Agent identifier when senderType=agent (MaxContact agent ID or email) */ 10808 agentId?: string; 10809 /** Display name for outbound messages */ 10810 agentName?: string; 10811 /** Optional media attachment URLs */ 10812 mediaUrl?: string[]; 10813 /** URL of the page where the chat originated (visitor side) */ 10814 pageUrl?: string; 10815 /** Visitor identifier from the chat session (used for identity resolution) */ 10816 visitorId?: string; 10817 /** Actor who sent this outbound chat message â see EventActor (only set for chat.message.outbound) */ 10818 actor?: EventActor; 10819 /** How the TEXT of an AI outbound chat message was produced (20 Sep 2026): the 10820 * same provenance object \`outbound_sms\` carries, so the /ai-agent feedback 10821 * surface can grade a site-chat turn with no second UI (plan A §4). Only set 10822 * for \`chat.message.outbound\` written by the conversation runtime. */ 10823 generation?: SmsGeneration; 10824} 10825 10826/** 10827 * EventData for call events (max_inbound_call, max_outbound_call, inbound.call, outbound_call) 10828 */ 10829export interface CallEventData { 10830 /** Phone number call was made from */ 10831 from: string; 10832 /** Phone number call was made to */ 10833 to?: string; 10834 /** Call duration in seconds */ 10835 duration?: number; 10836 /** Call status */ 10837 status?: string; 10838 /** Recording information */ 10839 recordingId?: string; 10840 recordingUrl?: string; 10841 RecordingUrl?: string; 10842 /** Call metadata */ 10843 CallSid?: string; 10844 callSid?: string; 10845 direction?: string; 10846 /** Timestamp fields */ 10847 startTime?: string; 10848 endTime?: string; 10849 /** Actor who initiated this outbound call â see EventActor */ 10850 actor?: EventActor; 10851 /** 10852 * Originating telephony provider for this call event. Optional â 10853 * legacy events without \`source\` are inferred from event-type prefix 10854 * (\`max_*\` â max, \`aircall_*\` â aircall, bare \`outbound_call\`/\`inbound.call\` â twilio). 10855 */ 10856 source?: 'twilio' | 'max' | 'aircall'; 10857 /** 10858 * For inbound calls routed through owner-first TwiML: true if the lead's owner 10859 * answered in their browser; false if the call fell through to forwardingNumber. 10860 * Undefined for non-owner-routed calls. 10861 */ 10862 answeredByOwner?: boolean; 10863 /** 10864 * Snapshot of \`lead.ownedByAgent\` at the time of the call event. 10865 * Recorded so analytics can group calls by owner without joining back to the lead table. 10866 */ 10867 ownedByAgent?: string; 10868 /** 10869 * S3 key (within the \`mcagent-recordings\` bucket) for the dual-channel recording. 10870 * Populated by the Twilio recordingStatusCallback handler post-call. 10871 */ 10872 recordingS3Key?: string; 10873 // --- MaxContact CDR fields (max_inbound_call / max_outbound_call) --------- 10874 // Written by lead-webhook-call-record from the MAX CallDataRecord and read by 10875 // report-aggregates, phone-usage, cli-health and the /phone-numbers route. 10876 // All optional: Twilio/Aircall call events do not carry them. 10877 /** Presented A-party caller ID on an outbound call (digits, may carry a leading 0 or +61). */ 10878 callerId?: string; 10879 /** Dialled B-party number on an inbound call (the number the customer rang). */ 10880 dnis?: string; 10881 /** MAX list the lead sat in when dialled. 0 / absent = No List / Manual. */ 10882 listId?: number | string; 10883 /** Talk time in MILLISECONDS on the CDR (aggregates store seconds). */ 10884 talkTime?: number; 10885 /** MaxContact disposition / result code (see max-result-codes.ts). */ 10886 resultCode?: string; 10887 /** MaxContact user id of the dialling agent. */ 10888 userId?: string | number; 10889 /** MAX CDR call start, ISO-8601 UTC (7-digit fractional seconds, \`Z\` suffix; older rows may lack the \`Z\`). Prefer the top-level \`eventStartUtc\`. */ 10890 startDateTime?: string; 10891 /** Transcript / CDR classifier stamp â full shape in @bigm/shared call-classifier. */ 10892 callClassification?: { state: string; reason?: string; [key: string]: unknown }; 10893} 10894 10895/** 10896 * EventData for Aircall call events (aircall_inbound_call, aircall_outbound_call) 10897 * Extends CallEventData with Aircall-specific fields 10898 */ 10899export interface AircallCallEventData extends CallEventData {
10900 /** Aircall internal call ID */ 10901 aircallCallId?: number; 10902 /** Aircall number ID */ 10903 aircallNumberId?: number; 10904 /** Aircall number display name */ 10905 aircallNumberName?: string; 10906 /** Agent name who handled the call */ 10907 agentName?: string; 10908 /** Agent email */ 10909 agentEmail?: string; 10910 /** Tags applied to the call */ 10911 tags?: string[]; 10912 /** Comments left on the call */ 10913 comments?: string[]; 10914 /** Reason for missed call (if applicable) */ 10915 missedCallReason?: string; 10916 /** Teams assigned to the call */ 10917 teams?: string[]; 10918 /** Voicemail recording URL (if left) */ 10919 voicemailUrl?: string; 10920 /** Raw digits dialed by the caller */ 10921 rawDigits?: string; 10922 /** Whether a recording exists in S3 */ 10923 hasRecording?: boolean; 10924} 10925 10926/** 10927 * A voicemail left on one of our Twilio numbers (eventType \`twilio_voicemail\`, 4 Oct 2026). 10928 * One row per recording; the row is UPDATED as it moves: recorded â copied â transcribed â routed. 10929 * The transcript and the lane live here so Messages, the queues and the agent task all read one row. 10930 */ 10931export interface VoicemailEventData { 10932 voicemail: { 10933 /** Twilio RecordingSid (REâ¦) and CallSid (CAâ¦) */ 10934 recordingSid: string; 10935 callSid: string; 10936 /** Twilio's recording URL (no extension; .wav/.mp3 served with the subaccount token) */ 10937 recordingUrl: string; 10938 /** our own copy: s3://<bucket>/<key> */ 10939 s3Bucket?: string; 10940 s3Key?: string; 10941 durationSec: number; 10942 /** E.164 caller and the number of ours they rang */ 10943 from: string; 10944 to: string; 10945 status: 'recorded' | 'empty' | 'copied' | 'transcribed' | 'transcription_failed' | 'routed'; 10946 transcript?: string; 10947 transcriptConfidence?: number; 10948 transcribedAt?: string; 10949 transcriptionError?: string; 10950 /** the routing decision (sales / service / business) and why */ 10951 route?: { 10952 lane: 'sales' | 'service' | 'business'; 10953 intent: string; 10954 summary: string; 10955 urgency: 'low' | 'normal' | 'high'; 10956 callbackRequested: boolean; 10957 callerStanding: 'existing_customer' | 'new_customer' | 'business_contact' | 'unknown'; 10958 decidedBy: 'rule' | 'model'; 10959 needsReview?: boolean; 10960 at: string; 10961 }; 10962 /** what was raised from it */ 10963 action?: { kind: 'agent_task' | 'business_item'; id: string; lane: 'sales' | 'service' | 'business'; at: string }; 10964 }; 10965} 10966 10967/** 10968 * EventData for form.submitted and metaform.leads events 10969 */ 10970export interface FormEventData { 10971 /** Contact information from form */ 10972 email?: string; 10973 phone?: string; 10974 firstName?: string; 10975 lastName?: string; 10976 name?: string; 10977 /** Form content */ 10978 message?: string; 10979 comments?: string; 10980 /** Form identification */ 10981 formId?: string; 10982 sourceUrl?: string; 10983 /** Session tracking */ 10984 visitorId?: string; 10985 sessionId?: string; 10986 /** Dynamic form fields */ 10987 fields?: Record<string, unknown>; 10988 /** Identifier mapping */ 10989 identifiers?: Record<string, unknown>; 10990 /** Cart context (for checkout forms) */ 10991 cart?: { 10992 token?: string; 10993 attributes?: Record<string, unknown>; 10994 }; 10995 /** Device context captured at form submission */ 10996 deviceContext?: DeviceContext; 10997 /** Page URL where the form was submitted */ 10998 pageUrl?: string; 10999 /** HTTP referrer URL from the browser at form submission */ 11000 referrer?: string; 11001} 11002 11003/** 11004 * EventData for checkout.* events 11005 */ 11006export interface CheckoutEventData { 11007 /** Checkout identifiers */ 11008 token?: string; 11009 cartToken?: string; 11010 cart_token?: string; 11011 checkout_token?: string; 11012 /** Session tracking */ 11013 visitorId?: string; 11014 sessionId?: string; 11015 /** Customer information */ 11016 customer?: CustomerData; 11017 email?: string; 11018 phone?: string; 11019 /** Cart information */ 11020 cart?: { 11021 token?: string; 11022 attributes?: Record<string, unknown>; 11023 line_items?: Array<Record<string, unknown>>; 11024 }; 11025 /** Checkout state */ 11026 total_price?: string; 11027 subtotal_price?: string; 11028 currency?: string; 11029 /** Addresses */ 11030 billing_address?: ShopifyAddress; 11031 shipping_address?: ShopifyAddress; 11032 /** Timestamps */ 11033 created_at?: string; 11034 updated_at?: string; 11035 completed_at?: string | null; 11036 abandoned_checkout_url?: string;
11037 /** Device context captured at checkout */ 11038 deviceContext?: DeviceContext; 11039} 11040 11041/** Platforms the social publisher can post to. */ 11042export type SocialPlatform = 'instagram' | 'facebook'; 11043 11044/** Post formats the social publisher supports (IG container media types + Page posts). */ 11045export type SocialPostFormat = 'image' | 'carousel' | 'reel' | 'story' | 'page_post' | 'page_photo'; 11046 11047/** 11048 * EventData for \`social.post.published\` â one row per post the platform published, 11049 * written by \`@bigm/shared\` \`social/meta-publisher\` right after Meta returns the id. 11050 * Tenant-level (no lead): \`messageGroupId\` is \`\${tenant}#social#\${platform}\`. 11051 */ 11052export interface SocialPostPublishedEventData { 11053 platform: SocialPlatform; 11054 format: SocialPostFormat; 11055 /** Meta media/post id (IG media id or \`\${pageId}_\${postId}\`). */ 11056 mediaId: string; 11057 permalink?: string; 11058 /** media-assets ids used, in order. */ 11059 assetIds: string[]; 11060 /** improvement-actions row that was approved, when the post came from the board. */ 11061 actionId?: string; 11062 /** Attribution campaign id (\`social-<actionId>\`) minted with the short link, if any. */ 11063 campaignId?: string; 11064 shortUrl?: string; 11065 shortCode?: string; 11066 caption?: string; 11067 /** Operator who tapped Approve (dataApi principal). */ 11068 publishedBy?: string; 11069 /** IG container id (kept for idempotency / audit). */ 11070 containerId?: string; 11071} 11072 11073/** 11074 * EventData for \`social.click\` â a click on a social short link, written by the 11075 * campaign-redirect Lambda for \`linkType: 'social'\` rows. No lead, no campaign-context row. 11076 */ 11077export interface SocialClickEventData { 11078 platform: SocialPlatform | string; 11079 campaignId: string; 11080 actionId?: string; 11081 clickId: string; 11082 shortCode: string; 11083 /** Destination the visitor was sent to (with utm + \`c\` code). */ 11084 url: string; 11085 userAgent?: string; 11086 ipHash?: string; 11087} 11088 11089/** 11090 * EventData for campaign.click and campaign.touch events 11091 */ 11092export interface CampaignClickEventData extends CampaignAttribution { 11093 /** Unique click identifier */ 11094 clickId?: string; 11095 /** URL that was clicked */ 11096 url?: string; 11097 /** Session context */ 11098 visitorId?: string; 11099 sessionId?: string; 11100 /** Additional tracking */ 11101 userAgent?: string; 11102 ip?: string; 11103} 11104 11105/** 11106 * EventData for campaign.video_play (5 Oct 2026, Chris: "knowing that they've hit the link and played the video"). 11107 * Written by campaign-redirect when the player page it serves for a video link reports playback. One row per 11108 * link per stage (deterministic id), so a replay never double counts. 11109 */ 11110export interface CampaignVideoPlayEventData extends CampaignAttribution { 11111 /** start = playback began, half = passed 50%, end = reached the end */ 11112 stage: 'start' | 'half' | 'end'; 11113 /** the short code the customer opened */ 11114 code: string; 11115 /** the video itself */ 11116 url?: string; 11117 userAgent?: string; 11118} 11119 11120/** 11121 * EventData for call_now_recommended events (system-generated) 11122 * Created when sustained browsing activity is detected for an identified lead. 11123 */ 11124export interface CallNowRecommendedEventData { 11125 /** Event type that triggered the recommendation (e.g., 'product.viewed') */ 11126 triggerEventType: string; 11127 /** ID of the triggering event */ 11128 triggerEventId: string; 11129 /** Product title from the trigger event (if applicable) */ 11130 triggerProductTitle?: string; 11131 /** Number of high-intent events counted in the detection window */ 11132 activityCount: number; 11133 /** Activity window timing */ 11134 activityWindow: { 11135 start: string; // ISO timestamp of first activity 11136 end: string; // ISO timestamp of last activity 11137 durationSeconds: number; 11138 }; 11139} 11140 11141/** 11142 * EventData for call_now_actioned events (auto-detected or manual) 11143 * Created when a call-now recommendation is resolved. 11144 */ 11145export interface CallNowActionedEventData { 11146 /** ID of the call_now_recommended event that was actioned */ 11147 callNowEventId: string; 11148 /** Action taken: 'called' (auto-detected), 'dismissed' (manual), 'expired' (cleanup) */ 11149 action: 'called' | 'dismissed' | 'expired'; 11150 /** Seconds from recommendation to action (key metric for response time analytics) */ 11151 elapsedSeconds: number; 11152 11153 // For 'called' action (auto-detected from MaxContact/Aircall) 11154 /** Whether the action was auto-detected (true) or manual (false) */ 11155 automatic?: boolean; 11156 /** ID of the max_outbound_call/aircall event that resolved this */ 11157 callEventId?: string; 11158 /** Type of call event: 'max_outbound_call' | 'aircall_outbound_call' */ 11159 callEventType?: string; 11160 11161 // For 'dismissed' action (manual) 11162 /** Cognito user ID who dismissed */ 11163 dismissedBy?: string; 11164 /** Optional reason for dismissal */ 11165 dismissReason?: string; 11166 11167 // Outcome (populated after CallDataRecord arrives) 11168 /** Disposition code from CallDataRecord: BUYHOT, BUYCLO, CALLBACK, etc. */ 11169 dispositionCode?: string; 11170} 11171 11172// ============================================================================ 11173// Agent Review Events (replaces call_now_recommended/actioned for new events) 11174// ============================================================================ 11175 11176/** 11177 * EventData for agent.review events. 11178 * Created when a workflow flags a lead for staff review. 11179 */ 11180export interface AgentReviewEventData { 11181 triggerEventType: string; 11182 triggerEventId: string; 11183 triggerProductTitle?: string; 11184 activityCount: number; 11185 activityWindow: { 11186 start: string; 11187 end: string; 11188 durationSeconds: number; 11189 }; 11190} 11191 11192// ============================================================================ 11193// Agent Dismissed Event 11194// ============================================================================ 11195 11196export interface AgentDismissedEventData { 11197 conversationKey: string; 11198 channel: string; 11199 lastInboundEventId: string;
11200 dismissedByEmail: string; 11201 dismissedByName: string; 11202} 11203 11204/** 11205 * Appointment lifecycle event data (\`appointment.created|updated|cancelled\`). 11206 * Immutable audit trail for the mutable \`appointments\` table â captures who 11207 * changed what (the table holds current state). 11208 */ 11209export interface AppointmentEventData { 11210 appointmentId: string; 11211 leadId?: string; 11212 /** ISO 8601 UTC slot start (after the change) */ 11213 startTimeUtc?: string; 11214 /** ISO 8601 UTC slot end */ 11215 endTimeUtc?: string; 11216 /** Assigned agent at the time of the event (if any) */ 11217 assignedAgentId?: string; 11218 /** Present on appointment.cancelled (and any soft-cancel update) */ 11219 cancelled?: boolean; 11220 /** Whether the assigned agent has accepted (appointment.accepted + reset events) */ 11221 accepted?: boolean; 11222 /** Actor who effected this change â see EventActor */ 11223 actor?: EventActor; 11224 /** Booking campaign that owns the thread */ 11225 campaign?: CampaignAttribution; 11226 /** What the appointment is about (AppointmentTopic in @bigm/shared) â set by the AI book/reschedule tool input */ 11227 topic?: string; 11228 /** 11229 * The OPENER workflow that started this conversation (immutable cohort 11230 * attribution). \`campaign.campaignId\` flips to the reply-handler workflow 11231 * after turn 1; this field keeps the original trigger. 11232 */ 11233 originCampaignId?: string; 11234} 11235 11236/** 11237 * \`showroom.*\` event payload â everything the confirmation SMS template needs, 11238 * SNAPSHOTTED at emit time so the message a customer received can always be 11239 * reconstructed even if the tenant later edits its showroom config. 11240 * 11241 * The workflow's SMS template renders these as \`{{appointmentDate}}\`, 11242 * \`{{appointmentTime}}\`, \`{{locationName}}\`, \`{{locationAddress}}\`, \`{{mapsUrl}}\` 11243 * and (delay only) \`{{delayMinutes}}\`. 11244 */ 11245export interface ShowroomEventData extends AppointmentEventData { 11246 /** Showroom display name at the time of the event, e.g. "South Melbourne Showroom" */ 11247 locationName?: string; 11248 /** One-line address at the time of the event, e.g. "117 York Street, South Melbourne VIC 3205" */ 11249 locationAddress?: string; 11250 /** Optional maps deep link for the SMS */ 11251 mapsUrl?: string; 11252 /** showroom.rescheduled â the slot the visit moved FROM */ 11253 previousStartTimeUtc?: string; 11254 /** showroom.delayed â how many minutes later the visit will now start */ 11255 delayMinutes?: number; 11256 /** Free-text operator reason (delay / cancel), never sent verbatim to the customer */ 11257 reason?: string; 11258} 11259 11260/** 11261 * \`reseller_appointment.*\` event payload â everything BOTH SMS templates need, 11262 * SNAPSHOTTED at emit time (same rule as \`ShowroomEventData\`): the messages 11263 * either party received can always be reconstructed even if the reseller 11264 * registry is later edited. 11265 * 11266 * The customer-leg template renders \`{{firstName}}\`, \`{{appointmentDate}}\`, 11267 * \`{{appointmentTime}}\`, \`{{locationName}}\`, \`{{locationAddress}}\`, 11268 * \`{{resellerContactName}}\` and \`{{resellerCardUrl}}\`; the reseller-leg 11269 * template renders \`{{resellerContactName}}\`, \`{{customerName}}\`, 11270 * \`{{appointmentDate}}\`, \`{{appointmentTime}}\` and \`{{customerCardUrl}}\`. 11271 * The reseller-leg template must NEVER contain pricing (22 Jul 2026 incident). 11272 */ 11273export interface ResellerAppointmentEventData extends AppointmentEventData { 11274 /** Reseller (table PK) at the time of the event. */ 11275 resellerName?: string; 11276 /** Which of the reseller's locations, when it has more than one. */ 11277 resellerLocationLabel?: string; 11278 /** Person the customer should ask for. */ 11279 resellerContactName?: string; 11280 /** Reseller-leg SMS destination â E.164 without \`+\`. The \`send_reseller_sms\` 11281 * engine step reads this; it is NOT the lead's number. */ 11282 resellerPhone?: string; 11283 /** Location display name at event time (mirrors ShowroomEventData). */ 11284 locationName?: string; 11285 /** One-line location address at event time. */ 11286 locationAddress?: string; 11287 /** Optional maps deep link for the customer SMS. */ 11288 mapsUrl?: string; 11289 /** Short URL to the RESELLER's vCard (sent to the customer). */ 11290 resellerCardUrl?: string; 11291 /** Short URL to the CUSTOMER's vCard (sent to the reseller; 30-day TTL). */ 11292 customerCardUrl?: string; 11293 /** IANA timezone the display strings are rendered in (the location's zone). */ 11294 locationTimezone?: string; 11295 /** Display date, rendered in locationTimezone at emit time (e.g. "Thursday 4 11296 * September") â snapshotted so both templates read one truth, dash-free. */ 11297 appointmentDate?: string; 11298 /** Display time in locationTimezone (e.g. "2.00pm"). */ 11299 appointmentTime?: string; 11300 /** reseller_appointment.rescheduled â the slot the visit moved FROM. */ 11301 previousStartTimeUtc?: string; 11302 /** Free-text operator reason (cancel), never sent verbatim to either party. */ 11303 reason?: string; 11304} 11305 11306/** 11307 * \`appointment.reminder_soon\` / \`appointment.reminder_day_before\` payload â 11308 * emitted by the \`appointment-reminder-scheduler\` Lambda, which is the ONLY 11309 * producer. Everything the reminder SMS template needs is SNAPSHOTTED here at 11310 * emit time (same rule as \`ShowroomEventData\`), so the message a customer 11311 * received can be reconstructed even if the appointment later moves. 11312 * 11313 * Rendered by the workflow's SMS template as \`{{appointmentDate}}\`, 11314 * \`{{appointmentTime}}\`, \`{{locationLine}}\` (and the raw \`{{locationName}}\` / 11315 * \`{{locationAddress}}\` / \`{{mapsUrl}}\` when a tenant wants them separately). 11316 * 11317 * â ï¸ These two are the ONLY \`appointment.*\` event types carried by the 11318 * campaign-triggers pipe. The \`appointment.created|updated|cancelled|accepted| 11319 * declined\` family stays audit-only and is DELIBERATELY absent from that 11320 * allow-list â do not add them. 11321 */ 11322export interface AppointmentReminderEventData extends AppointmentEventData {
11323 /** Which reminder in the cadence this is. */ 11324 reminderKind: AppointmentReminderKind; 11325 /** 'phone' (callback), 'showroom' (in-person visit) or 'reseller' (visit to a 11326 * third-party showroom). Absent on the row â 'phone'. Reminder templates 11327 * BRANCH on this â reseller/showroom copy names the location and never says 11328 * anyone will call. */ 11329 mode: AppointmentMode; 11330 /** Location name at emit time â set for showroom AND reseller modes. */ 11331 locationName?: string; 11332 /** One-line location address at emit time â set for showroom AND reseller modes. */ 11333 locationAddress?: string; 11334 /** Optional maps deep link for the SMS. */ 11335 mapsUrl?: string; 11336} 11337 11338/** 11339 * \`nba.action_due\` payload (plan A §7). \`eventId\` on the row is \`nba_<actionId>_<slotAt>\` 11340 * so the conversational handler's per-event dedup prevents a double send. 11341 */ 11342export interface NbaActionDueEventData { 11343 actionId: string; 11344 /** Entry in the tenant's action catalogue (piece D). */ 11345 catalogueId: string; 11346 channel: 'sms' | 'chat' | 'push' | 'email'; 11347 owner: 'ai'; 11348 slotAt: string; 11349 expiresAt: string; 11350 originCampaignId?: string; 11351 payload?: Record<string, unknown>; 11352} 11353 11354/** \`nba.computed\` (piece D): the computer's decision for a lead or an anonymous visitor. */ 11355export interface NbaComputedEventData { 11356 /** \`none\` = no candidate; \`ai\` = a scheduled AI action was written; \`agent\` = an agent action was 11357 * raised; \`keep_pending\` = a live human action stands (its expiry / fallback may have been set). */ 11358 decision: 'none' | 'ai' | 'agent' | 'keep_pending' | 'would_take_over'; 11359 catalogueId?: string; 11360 channel?: string; 11361 slotAt?: string; 11362 slotReason?: string; 11363 owner?: 'agent' | 'ai'; 11364 /** The chooser's one-line reason and confidence (provenance inferred). */ 11365 rationale?: string; 11366 confidence?: number; 11367 /** \`suggest\` = recorded only, nothing will run (piece D); \`live\` = D-exec. */ 11368 mode?: 'suggest' | 'live'; 11369 /** The lead's best contact hours per channel as derived at this compute. */ 11370 contactTiming?: Record<string, unknown>; 11371 /** would_take_over: the expired human action and the fallback the AI would run. */ 11372 expiredActionType?: string; 11373 /** Same shape as the outbound rows' stamp so the review tab and the feedback store treat the decision like a turn. */ 11374 generation?: SmsGeneration; 11375 candidates: Array<{ catalogueId: string; score: number; channel: string; reasons: string[] }>; 11376 reasons: string[]; 11377 /** Entries that were considered and blocked, with the blocking reason. */ 11378 blocked?: string[]; 11379 /** The event that triggered the recompute. */ 11380 triggerEventId?: string; 11381 triggerEventType?: string; 11382 /** Anonymous visitor (no lead): the row is keyed by \`visitorId\`, \`subject\` is \`visitor\` and 11383 * \`sessionId\` names the chat session whose META carries the same stamp (decision 12). */ 11384 visitorId?: string; 11385 subject?: 'lead' | 'visitor'; 11386 sessionId?: string; 11387 /** Visitor rows: \`site\` = a chat-api session, \`app\` = the tenant's own app (turn mode, e.g. Delta X). */ 11388 surface?: 'site' | 'app'; 11389 /** Visitor rows: the last lines of the thread as the engine read them (numbers and emails redacted). */ 11390 history?: string[]; 11391 /** \`agent\` decisions: the operator-lane type the AI would raise (recorded only in suggest mode). */ 11392 actionType?: string; 11393 /** \`computed\` = rules only; \`inferred\` = the chooser picked among the candidates. */ 11394 provenance: 'computed' | 'inferred'; 11395 computedAt: string; 11396} 11397 11398/** \`nba.action_executed\` (piece D): the due turn ran for an AI-owned action. */ 11399export interface NbaActionExecutedEventData { 11400 actionId: string; 11401 catalogueId: string; 11402 channel: string; 11403 slotAt: string; 11404 /** The tool the model chose among the candidates (provenance \`inferred\`), or \`none\`. */ 11405 chosenTool: string; 11406 candidates: Array<{ catalogueId: string; score: number }>; 11407 outboundEventId?: string; 11408 outcome: 'sent' | 'silent' | 'escalated' | 'failed' | 'not_supported' | 'suggest_only' | 'cancelled'; 11409 reason?: string; 11410 originCampaignId?: string; 11411} 11412 11413/** \`agent.action_expired\` (piece D): a human action passed its expiry; the AI took it over. */ 11414export interface AgentActionExpiredEventData { 11415 actionType: string; 11416 assignedAgentMaxId?: string; 11417 expiredAt: string; 11418 createdAt?: string; 11419 /** The AI entry scheduled in its place (absent when the takeover map had none). */ 11420 aiFallbackCatalogueId?: string | null; 11421 slotAt?: string; 11422 reason: 'expired_takeover' | 'expired_no_fallback'; 11423} 11424 11425/** 11426 * AI question-answered event data (\`ai.question_answered\`). 11427 * One event per question type per turn; id \`qa_\${inboundEventId}_\${questionType}\` 11428 * with attribute_not_exists guard, so re-processing a turn never double-counts. 11429 */ 11430export interface AiQuestionAnsweredEventData { 11431 questionType: 'delivery' | 'service' | 'price' | 'product'; 11432 leadId: string; 11433 actor: 'agent_ai'; 11434 /** The reply-handler campaign that produced the answer (turn attribution) */ 11435 campaign?: CampaignAttribution; 11436 /** The opener workflow that started the conversation (cohort attribution) */ 11437 originCampaignId?: string; 11438 /** The customer inbound_sms event the AI answered */ 11439 inboundEventId?: string; 11440 /** Set only by the historical backfill script */ 11441 backfilled?: boolean; 11442} 11443 11444/** 11445 * Winnings 3PL delivery lifecycle event data (\`delivery.*\`). 11446 * Written by the \`sap-delivery-poller\` Lambda (and its backfill script) from 11447 * ZSD_SALES_ORDER_GET_SRV DeliveryOutcomes. One event per delivery per stage 11448 * (deterministic id \`delivery#{deliveryNumber}#{stage}\` â write-once). 11449 * \`leadId\` is stamped by the WRITER (inherited from the order.confirmed event), 11450 * NOT by the lead-and-identity pipe; delivery.* is deliberately absent from 11451 * both EventBridge pipe filters, so these events trigger no workflows. 11452 */ 11453export interface DeliveryEventData { 11454 /** SAP sales order number (zero-padded 10-digit). */ 11455 salesOrder: string; 11456 /** SAP delivery number the stage belongs to. */ 11457 deliveryNumber: string; 11458 /** SAP Cust. Reference = Shopify order name without '#' (MHC keeps its prefix). */ 11459 customerReference: string; 11460 shopifyOrderId?: number; 11461 /** e.g. \`#30469\` / \`#MHC1734\`. */ 11462 shopifyOrderName?: string; 11463 /** e.g. \`VIC 3PL Warehouse\`. */ 11464 facility?: string; 11465 state?: string; 11466 /** Requested delivery date (YYYY-MM-DD) as at this event. */ 11467 requestedDeliveryDate?: string; 11468 /** delivery.date_changed only. */ 11469 previousDate?: string; 11470 newDate?: string; 11471 /** Raw SAP DeliveryOutcomes status text, e.g. \`Delivery Complete\`. */ 11472 outcomeStatus?: string; 11473 outcomeCode?: number; 11474 /** SAP change_date for the outcome (DD.MM.YYYY as returned). */ 11475 changeDate?: string; 11476 /** changeDate normalised to YYYY-MM-DD. */ 11477 changeDateIso?: string; 11478 /** SAP change_time (HH:MM:SS) â combined with changeDate for the event instant. */ 11479 changeTime?: string; 11480 /** Human-readable outcome reason, e.g. \`Delivered to customer\`. */ 11481 reason?: string; 11482 /** Raw SAP outcome comments blob. */ 11483 comments?: string; 11484 /** Manhattan WMS order number (outcome \`order_number\`, e.g. \`SO09015327\`). */ 11485 wmsOrderNumber?: string; 11486 /** Shopify product image, resolved via the inventory reconciliation overlay at write time. */ 11487 productImageUrl?: string; 11488 /** Shopify variant id from the source order's line items â lets the UI resolve the image via ProductCatalog (same as sales-order hovers) */ 11489 shopifyVariantId?: string; 11490 shopifyProductId?: string; 11491 /** Shopify product title matching the image (may differ from the SAP description). */ 11492 shopifyProductTitle?: string; 11493 /** delivery.keyed only â the operator who recorded keying the order in SAP, or 'automation'. */ 11494 keyedBy?: string; 11495 /** delivery.keyed only â the agent's delivery notes appended to the SAP shipping instructions. */ 11496 agentNotes?: string; 11497 /** 11498 * delivery.keyed with source 'winnings_sap_api' â the exact ET_SOHeaderSet body that was POSTed, 11499 * kept so the review card can show what was booked without a second SAP read. 11500 */ 11501 sapRequest?: Record<string, unknown>; 11502 /** delivery.keyed with source 'winnings_sap_api' â the SAP DeliveryNote returned by the create. */ 11503 sapDeliveryNote?: string; 11504 /** delivery.keyed with source 'winnings_sap_api' â the SAP DeliveryDate returned by the create. */ 11505 sapDeliveryDate?: string; 11506 /** delivery.reviewed only â who reviewed the automated booking (Cognito email). */ 11507 reviewedBy?: string; 11508 /** delivery.reviewed only â 'correct' or 'problem'. */ 11509 reviewOutcome?: 'correct' | 'problem'; 11510 /** delivery.reviewed only â the reviewer's note (required when the outcome is 'problem'). */ 11511 reviewNotes?: string; 11512 /** delivery.reviewed only â the delivery.keyed row that was reviewed. */ 11513 reviewedEventId?: string; 11514 /** 11515 * Set when the automation could NOT book and raised a delivery_review instead: the sheet's 11516 * blockers, so the review explains what a human has to do by hand. 11517 */ 11518 automationBlockers?: string[]; 11519 /** Delivery ship-to from SAP ShippingDetails (authoritative for the delivery). */ 11520 shipTo?: { name?: string; house_no?: string; street?: string; city?: string; region?: string; postal_code?: string; country?: string; mobile?: string }; 11521 /** SAP parent SKUs on the order (TAQ/TAN lines). */ 11522 parentSkus?: string[]; 11523 /** Full SAP line listing â parent (priced) and child (box) SKUs with descriptions, for agent-facing display. */ 11524 lines?: { sku?: string; description?: string; category?: string; role: 'parent' | 'child'; qty?: string }[]; 11525 productTitles?: string[]; 11526 /** Contact snapshot from the source order event (lead-linking fallback). */ 11527 customer?: { first_name?: string; last_name?: string; phone?: string; email?: string }; 11528 /** 11529 * winnings_sap_poll / winnings_sap_backfill â poller-confirmed stages read back from SAP. 11530 * shopdash_booking_sheet â an operator's keyed/requested booking from the wizard (no SAP write). 11531 * winnings_sap_api â the platform created the SAP order through ZSD_SALES_ORDER_SRV 11532 * (poller automation, or the wizard's Book button once the create route shipped). 11533 * shopdash_booking_review â the delivery.reviewed row. 11534 */ 11535 source: 'winnings_sap_poll' | 'winnings_sap_backfill' | 'shopdash_booking_sheet' | 'winnings_sap_api' | 'shopdash_booking_review'; 11536} 11537 11538// ============================================================================ 11539// EventData Type Map 11540// ============================================================================ 11541 11542/** 11543 * Map of eventType to its corresponding eventData structure. 11544 * Used for type-safe access to event-specific data. 11545 */ 11546export type EventDataMap = { 11547 // Shopify events 11548 'backfill_shopify_order': ShopifyOrderEventData; 11549 'backfill_shopify_customer': ShopifyCustomerEventData;
11550 'order.confirmed': ShopifyOrderEventData; 11551 'order.amended': ShopifyOrderEventData; // Emitted on every orders/updated webhook (drives real-time auto-stamp) 11552 'order.cancelled': ShopifyOrderEventData; // Same structure as order.confirmed but with cancellation fields 11553 'order.refunded': GenericEventData; // Refund-specific structure 11554 'customer.created': ShopifyCustomerEventData; 11555 'customer.updated': ShopifyCustomerEventData; 11556 'customer.deleted': ShopifyCustomerEventData; 11557 'message.received': GenericEventData; 11558 11559 // Messenger events 11560 'messenger.inbound': MessengerEventData; 11561 'messenger.outbound': MessengerEventData; 11562 11563 // Storefront events 11564 'product.viewed': StorefrontEventData; 11565 'page.viewed': StorefrontEventData; 11566 'cart.added': StorefrontEventData; 11567 'cart.viewed': StorefrontEventData; 11568 'cart.updated': StorefrontEventData; 11569 'cart.removed': StorefrontEventData; 11570 'cart.abandoned': StorefrontEventData; 11571 'collection.viewed': StorefrontEventData; 11572 'search.performed': StorefrontEventData; 11573 11574 // Engagement events 11575 'form.sighted': StorefrontEventData; 11576 'page.exited': StorefrontEventData; 11577 'frustration.rage_click': StorefrontEventData; 11578 'exit_intent.detected': StorefrontEventData; 11579 11580 // Modal events 11581 'modal.presented': ModalEventData; 11582 'modal.dismissed': ModalEventData; 11583 11584 // Referral events 11585 'referral.captured': StorefrontEventData; 11586 11587 // Checkout events 11588 'checkout.abandoned': CheckoutEventData; 11589 'checkout.started': CheckoutEventData; 11590 'checkout.contact_info_submitted': CheckoutEventData; 11591 'checkout.address_info_submitted': CheckoutEventData; 11592 'checkout.shipping_info_submitted': CheckoutEventData; 11593 'checkout.payment_info_submitted': CheckoutEventData; 11594 11595 // Communication events 11596 'inbound_email': EmailEventData; 11597 'outbound_email': EmailEventData; 11598 'inbound_sms': SmsEventData; 11599 'outbound_sms': SmsEventData; 11600 'outbound_call': CallEventData; 11601 'max_inbound_call': CallEventData; 11602 'max_outbound_call': CallEventData; 11603 'max_inbound_call_started': CallEventData; 11604 'max_outbound_call_started': CallEventData; 11605 'inbound.call': CallEventData; 11606 'aircall_inbound_call': AircallCallEventData; 11607 'aircall_outbound_call': AircallCallEventData; 11608 'aircall_inbound_call_started': AircallCallEventData; 11609 'aircall_outbound_call_started': AircallCallEventData; 11610 'twilio_inbound_call_started': CallEventData; 11611 'twilio_outbound_call_started': CallEventData; 11612 'twilio_voicemail': VoicemailEventData; 11613 'chat.message.inbound': ChatMessageEventData; 11614 'chat.message.outbound': ChatMessageEventData; 11615 11616 // Form events 11617 'form.submitted': FormEventData; 11618 'metaform.leads': FormEventData; 11619 11620 // Campaign events 11621 'campaign.click': CampaignClickEventData; 11622 'campaign.video_play': CampaignVideoPlayEventData; 11623 'campaign.touch': CampaignClickEventData; 11624 11625 // Social events (tenant-level, no lead) 11626 'social.post.published': SocialPostPublishedEventData; 11627 'social.click': SocialClickEventData; 11628 11629 // Call-now events (legacy - kept for backwards compat with existing DynamoDB records) 11630 'call_now_recommended': CallNowRecommendedEventData; 11631 'call_now_actioned': CallNowActionedEventData; 11632 11633 // Agent review events 11634 'agent.review': AgentReviewEventData; 11635 11636 // Agent conversation events 11637 'agent.dismissed': AgentDismissedEventData; 11638 11639 // Draft order events 11640 'draft_order.created': DraftOrderEventData; 11641 'draft_order.updated': DraftOrderEventData; 11642 'draft_order.deleted': DraftOrderEventData; 11643 11644 // SAP ERP events 11645 'sap.order_created': SapOrderEventData; 11646 'sap.delivery_update': SapOrderEventData; 11647 'sap.order_status': SapOrderEventData; 11648 11649 // Agent-authored notes (real-time + Zoho backfill) 11650 'agent_note': AgentNoteEventData; 11651 'ai_sale.stage_changed': AiSaleStageChangedEventData; 11652 'zoho.stage_changed': ZohoStageChangedEventData; 11653 'backfill_zoho_note': AgentNoteEventData; 11654 11655 // Zoho org full-record backfill (structured fields per Contact / Lead) 11656 'zoho_contact': ZohoStructuredEventData; 11657 'zoho_lead': ZohoStructuredEventData; 11658 11659 // Appointment lifecycle (SMS-booked consultations) 11660 'appointment.created': AppointmentEventData; 11661 'appointment.updated': AppointmentEventData; 11662 'appointment.cancelled': AppointmentEventData; 11663 'appointment.accepted': AppointmentEventData; 11664 'appointment.declined': AppointmentEventData; 11665 'appointment.reminder_soon': AppointmentReminderEventData; 11666 'appointment.reminder_day_before': AppointmentReminderEventData; 11667 11668 // Showroom (in-person) visit lifecycle â each drives a customer SMS workflow 11669 'showroom.booked': ShowroomEventData; 11670 'showroom.rescheduled': ShowroomEventData; 11671 'showroom.delayed': ShowroomEventData;
11672 'showroom.cancelled': ShowroomEventData; 11673 11674 // AI inline question answering (delivery / service) 11675 'ai.question_answered': AiQuestionAnsweredEventData; 11676 'nba.action_due': NbaActionDueEventData; 11677 'nba.computed': NbaComputedEventData; 11678 'nba.action_executed': NbaActionExecutedEventData; 11679 'agent.action_expired': AgentActionExpiredEventData; 11680 11681 // Winnings 3PL delivery lifecycle 11682 'delivery.keyed': DeliveryEventData; 11683 'delivery.reviewed': DeliveryEventData; 11684 'delivery.booked': DeliveryEventData; 11685 'delivery.date_changed': DeliveryEventData; 11686 'delivery.dispatched': DeliveryEventData; 11687 'delivery.completed': DeliveryEventData; 11688 'delivery.failed': DeliveryEventData; 11689 'delivery.cancelled': DeliveryEventData; 11690 'delivery.returned': DeliveryEventData; 11691}; 11692 11693/** 11694 * Generic fallback for backfill and unknown event types. 11695 * Allows any additional properties beyond defined fields. 11696 */ 11697export type GenericEventData = Record<string, unknown>; 11698 11699/** 11700 * Get the correct eventData type for a given eventType. 11701 * Uses intersection with Record<string, unknown> to allow extra dynamic fields. 11702 * 11703 * @template T - The eventType value 11704 * @returns The specific eventData type if known, or GenericEventData for unknown types 11705 */ 11706export type EventDataForType<T extends EventCustomerType> = 11707 T extends keyof EventDataMap 11708 ? EventDataMap[T] & Record<string, unknown> 11709 : GenericEventData; 11710 11711// ============================================================================ 11712// Type Guards 11713// ============================================================================ 11714 11715/** Storefront event types */ 11716export const STOREFRONT_EVENT_TYPES = [ 11717 'product.viewed', 'page.viewed', 'cart.added', 'cart.viewed', 11718 'cart.updated', 'cart.removed', 'cart.abandoned', 'collection.viewed', 'search.performed', 11719 'modal.presented', 'modal.dismissed', 11720 'form.sighted', 'page.exited', 'frustration.rage_click' 11721] as const; 11722 11723/** Checkout event types */ 11724export const CHECKOUT_EVENT_TYPES = [ 11725 'checkout.abandoned', 'checkout.started', 'checkout.contact_info_submitted', 11726 'checkout.address_info_submitted', 'checkout.shipping_info_submitted', 11727 'checkout.payment_info_submitted' 11728] as const; 11729 11730/** SMS event types */ 11731export const SMS_EVENT_TYPES = ['inbound_sms', 'outbound_sms'] as const; 11732 11733/** Email event types */ 11734export const EMAIL_EVENT_TYPES = ['inbound_email', 'outbound_email'] as const; 11735 11736/** Call event types */ 11737export const CALL_EVENT_TYPES = [ 11738 'max_inbound_call', 'max_outbound_call', 'inbound.call', 'outbound_call', 11739 'aircall_inbound_call', 'aircall_outbound_call' 11740] as const; 11741 11742/** Form event types */ 11743export const FORM_EVENT_TYPES = ['form.submitted', 'metaform.leads'] as const; 11744 11745/** Campaign event types */ 11746export const CAMPAIGN_EVENT_TYPES = ['campaign.click', 'campaign.touch'] as const; 11747 11748/** Social event types â tenant-level rows (messageGroupId \`\${tenant}#social#\${platform}\`), never lead-scoped. */ 11749export const SOCIAL_EVENT_TYPES = ['social.post.published', 'social.click'] as const; 11750 11751/** Messenger event types */ 11752export const MESSENGER_EVENT_TYPES = ['messenger.inbound', 'messenger.outbound'] as const; 11753 11754/** Site-chat message event types */ 11755export const CHAT_MESSAGE_EVENT_TYPES = ['chat.message.inbound', 'chat.message.outbound'] as const; 11756 11757/** 11758 * Event types that count as a REAL INTERACTION with/by the lead â a call, a 11759 * message either way, a form fill, a chat, or an order. Drives 11760 * \`Lead.lastInteractionAt\` (stamped forward-only by create-lead-and-identity 11761 * alongside \`lastEventAt\`). 11762 * 11763 * Deliberately EXCLUDES passive/system events (customer.updated sync sweeps, 11764 * customer.fields_synced, pii extraction, zoho agent_note imports, ad views, 11765 * campaign clicks, page views): a bulk Shopify customer sync on 2026-07-19 11766 * bumped \`lastEventAt\` on ~200 stale BC leads and dumped them into the Sales 11767 * Portal "1-3d" BC age bucket â the bug this field exists to fix. 11768 */ 11769export const INTERACTION_EVENT_TYPES = [ 11770 ...CALL_EVENT_TYPES, 11771 'max_inbound_call_started', 'max_outbound_call_started', 11772 'aircall_inbound_call_started', 'aircall_outbound_call_started', 11773 ...SMS_EVENT_TYPES, 11774 ...EMAIL_EVENT_TYPES, 11775 'form.submitted', 'metaform.leads', 11776 ...MESSENGER_EVENT_TYPES, 11777 ...CHAT_MESSAGE_EVENT_TYPES, 11778 'order.confirmed', 11779] as const; 11780 11781/** Check if an event type is a real interaction (see INTERACTION_EVENT_TYPES). */ 11782export function isInteractionEventType(eventType: string): boolean { 11783 return (INTERACTION_EVENT_TYPES as readonly string[]).includes(eventType); 11784} 11785 11786/** Shopify order event types */ 11787export const SHOPIFY_ORDER_EVENT_TYPES = ['backfill_shopify_order', 'order.confirmed', 'order.amended', 'order.cancelled', 'order.refunded'] as const; 11788 11789/** Shopify customer event types */ 11790export const SHOPIFY_CUSTOMER_EVENT_TYPES = ['backfill_shopify_customer', 'customer.created', 'customer.updated', 'customer.deleted'] as const; 11791 11792/** Draft order event types */ 11793export const DRAFT_ORDER_EVENT_TYPES = ['draft_order.created', 'draft_order.updated', 'draft_order.deleted'] as const; 11794 11795/** Lead creation event types - genuine PII capture events that populate the Lead table */ 11796export const LEAD_CREATION_EVENT_TYPES = [ 11797 'form.submitted', 11798 'metaform.leads', 11799 'max_inbound_call', 11800 'aircall_inbound_call', 11801 'inbound.call', 11802 'inbound_sms', 11803 'inbound_email', 11804 'messenger.inbound', 11805 'checkout.contact_info_submitted', 11806 'customer.created', 11807] as const; 11808 11809/** Storefront event type union */ 11810export type StorefrontEventType = typeof STOREFRONT_EVENT_TYPES[number]; 11811 11812/** Checkout event type union */ 11813export type CheckoutEventType = typeof CHECKOUT_EVENT_TYPES[number]; 11814 11815/** SMS event type union */ 11816export type SmsEventType = typeof SMS_EVENT_TYPES[number]; 11817 11818/** Email event type union */ 11819export type EmailEventType = typeof EMAIL_EVENT_TYPES[number]; 11820 11821/** Call event type union */ 11822export type CallEventType = typeof CALL_EVENT_TYPES[number]; 11823 11824/** Form event type union */ 11825export type FormEventType = typeof FORM_EVENT_TYPES[number]; 11826 11827/** Campaign event type union */ 11828export type CampaignEventType = typeof CAMPAIGN_EVENT_TYPES[number]; 11829 11830/** Messenger event type union */ 11831export type MessengerEventType = typeof MESSENGER_EVENT_TYPES[number]; 11832 11833/** Site-chat message event type union */ 11834export type ChatMessageEventType = typeof CHAT_MESSAGE_EVENT_TYPES[number]; 11835 11836/** Shopify order event type union */ 11837export type ShopifyOrderEventType = typeof SHOPIFY_ORDER_EVENT_TYPES[number]; 11838 11839/** Shopify customer event type union */ 11840export type ShopifyCustomerEventType = typeof SHOPIFY_CUSTOMER_EVENT_TYPES[number]; 11841 11842/** Draft order event type union */ 11843export type DraftOrderEventType = typeof DRAFT_ORDER_EVENT_TYPES[number]; 11844 11845/** Lead creation event type union */ 11846export type LeadCreationEventType = typeof LEAD_CREATION_EVENT_TYPES[number]; 11847 11848/** Lead quality based on customer effort/intent when enquiring */ 11849export type LeadQuality = 'high' | 'medium' | 'low' | 'logistics'; 11850 11851/** Maps each lead creation event type to a quality tier based on customer effort */ 11852export const LEAD_CREATION_EVENT_QUALITY: Record<LeadCreationEventType, LeadQuality> = { 11853 'max_inbound_call': 'high', 11854 'aircall_inbound_call': 'high', 11855 'inbound.call': 'high', 11856 'inbound_sms': 'high', 11857 'inbound_email': 'medium', 11858 'messenger.inbound': 'medium', 11859 'form.submitted': 'medium', 11860 'metaform.leads': 'low', 11861 'checkout.contact_info_submitted': 'low', 11862 'customer.created': 'low', 11863}; 11864 11865/** Get lead quality for an event type. Returns undefined for non-lead-creation events. */ 11866export function getLeadQuality(eventType: string): LeadQuality | undefined { 11867 return (LEAD_CREATION_EVENT_QUALITY as Record<string, LeadQuality>)[eventType]; 11868} 11869 11870/** 11871 * Type guard to check if event is a storefront event 11872 */ 11873export function isStorefrontEvent( 11874 event: EventCustomer 11875): event is EventCustomer<StorefrontEventType> { 11876 return (STOREFRONT_EVENT_TYPES as readonly string[]).includes(event.eventType); 11877} 11878 11879/** 11880 * Type guard to check if event is a checkout event 11881 */ 11882export function isCheckoutEvent( 11883 event: EventCustomer 11884): event is EventCustomer<CheckoutEventType> { 11885 return (CHECKOUT_EVENT_TYPES as readonly string[]).includes(event.eventType); 11886} 11887 11888/** 11889 * Type guard to check if event is an SMS event 11890 */ 11891export function isSmsEvent( 11892 event: EventCustomer 11893): event is EventCustomer<SmsEventType> { 11894 return (SMS_EVENT_TYPES as readonly string[]).includes(event.eventType); 11895} 11896 11897/** 11898 * Type guard to check if event is an email event 11899 */ 11900export function isEmailEvent( 11901 event: EventCustomer 11902): event is EventCustomer<EmailEventType> { 11903 return (EMAIL_EVENT_TYPES as readonly string[]).includes(event.eventType); 11904} 11905 11906/** 11907 * Type guard to check if event is a call event 11908 */ 11909export function isCallEvent( 11910 event: EventCustomer 11911): event is EventCustomer<CallEventType> {
11912 return (CALL_EVENT_TYPES as readonly string[]).includes(event.eventType); 11913} 11914 11915/** 11916 * Type guard to check if event is a site-chat message event 11917 */ 11918export function isChatMessageEvent( 11919 event: EventCustomer 11920): event is EventCustomer<ChatMessageEventType> { 11921 return (CHAT_MESSAGE_EVENT_TYPES as readonly string[]).includes(event.eventType); 11922} 11923 11924/** 11925 * Type guard to check if event is a form event 11926 */ 11927export function isFormEvent( 11928 event: EventCustomer 11929): event is EventCustomer<FormEventType> { 11930 return (FORM_EVENT_TYPES as readonly string[]).includes(event.eventType); 11931} 11932 11933/** 11934 * Type guard to check if event is a campaign click/touch event 11935 */ 11936export function isCampaignEvent( 11937 event: EventCustomer 11938): event is EventCustomer<CampaignEventType> { 11939 return (CAMPAIGN_EVENT_TYPES as readonly string[]).includes(event.eventType); 11940} 11941 11942/** 11943 * Type guard to check if event is a Shopify order event 11944 */ 11945export function isShopifyOrderEvent( 11946 event: EventCustomer 11947): event is EventCustomer<ShopifyOrderEventType> { 11948 return (SHOPIFY_ORDER_EVENT_TYPES as readonly string[]).includes(event.eventType); 11949} 11950 11951/** 11952 * Type guard to check if event is a Shopify customer event 11953 */ 11954export function isShopifyCustomerEvent( 11955 event: EventCustomer 11956): event is EventCustomer<ShopifyCustomerEventType> { 11957 return (SHOPIFY_CUSTOMER_EVENT_TYPES as readonly string[]).includes(event.eventType); 11958} 11959 11960/** 11961 * Type guard to check if event is a draft order event 11962 */ 11963export function isDraftOrderEvent( 11964 event: EventCustomer 11965): event is EventCustomer<DraftOrderEventType> { 11966 return (DRAFT_ORDER_EVENT_TYPES as readonly string[]).includes(event.eventType); 11967} 11968 11969/** 11970 * Type guard to check if event is a lead creation event 11971 */ 11972export function isLeadCreationEvent( 11973 event: EventCustomer 11974): event is EventCustomer<LeadCreationEventType> { 11975 return (LEAD_CREATION_EVENT_TYPES as readonly string[]).includes(event.eventType); 11976} 11977 11978/** 11979 * Type guard to check if event is a messenger event 11980 */ 11981export function isMessengerEvent( 11982 event: EventCustomer 11983): event is EventCustomer<MessengerEventType> { 11984 return (MESSENGER_EVENT_TYPES as readonly string[]).includes(event.eventType); 11985} 11986 11987/** Engagement event types - page engagement and frustration signals */ 11988export const ENGAGEMENT_EVENT_TYPES = ['form.sighted', 'page.exited', 'frustration.rage_click', 'exit_intent.detected'] as const; 11989 11990/** Engagement event type union */ 11991export type EngagementEventType = typeof ENGAGEMENT_EVENT_TYPES[number]; 11992 11993/** 11994 * Type guard to check if event is an engagement/frustration event 11995 */ 11996export function isEngagementEvent( 11997 event: EventCustomer 11998): event is EventCustomer<EngagementEventType> { 11999 return (ENGAGEMENT_EVENT_TYPES as readonly string[]).includes(event.eventType); 12000} 12001 12002// ============================================================================ 12003// Type-Safe EventCustomer Variants 12004// ============================================================================ 12005 12006/** Type-safe EventCustomer for order confirmed events */ 12007export type OrderConfirmedEvent = EventCustomer<'order.confirmed'>; 12008 12009/** Type-safe EventCustomer for backfill Shopify customer events */ 12010export type BackfillShopifyCustomerEvent = EventCustomer<'backfill_shopify_customer'>; 12011 12012/** Type-safe EventCustomer for storefront events */ 12013export type StorefrontEvent = EventCustomer<typeof STOREFRONT_EVENT_TYPES[number]>; 12014 12015/** Type-safe EventCustomer for checkout events */ 12016export type CheckoutEvent = EventCustomer<typeof CHECKOUT_EVENT_TYPES[number]>; 12017 12018/** Type-safe EventCustomer for SMS events */ 12019export type SmsEvent = EventCustomer<typeof SMS_EVENT_TYPES[number]>; 12020 12021/** Type-safe EventCustomer for email events */ 12022export type EmailEvent = EventCustomer<typeof EMAIL_EVENT_TYPES[number]>; 12023 12024/** Type-safe EventCustomer for call events */ 12025export type CallEvent = EventCustomer<typeof CALL_EVENT_TYPES[number]>; 12026 12027/** Type-safe EventCustomer for form events */ 12028export type FormEvent = EventCustomer<typeof FORM_EVENT_TYPES[number]>; 12029 12030/** Type-safe EventCustomer for campaign events */ 12031export type CampaignEvent = EventCustomer<typeof CAMPAIGN_EVENT_TYPES[number]>; 12032 12033/** Type-safe EventCustomer for messenger events */ 12034export type MessengerEvent = EventCustomer<typeof MESSENGER_EVENT_TYPES[number]>; 12035 12036// ============================================================================ 12037// WebPixel Event Nested Payload Types 12038// ============================================================================ 12039 12040/**
12041 * Device context captured by the web pixel on the storefront. 12042 * 12043 * IMPORTANT: This is ONLY available inside payload.deviceContext, NOT at the top level 12044 * of eventData. Use getDeviceContext() to safely extract it. 12045 */ 12046export interface WebPixelDeviceContext { 12047 /** Device classification: mobile, tablet, or desktop */ 12048 deviceType?: DeviceType | null; 12049 /** Operating system family: iOS, Android, macOS, Windows, Linux, Other */ 12050 osFamily?: OsFamily | null; 12051 /** Whether device supports touch input */ 12052 isTouch?: boolean | null; 12053 /** Physical screen width in pixels */ 12054 screenWidth?: number | null; 12055 /** Physical screen height in pixels */ 12056 screenHeight?: number | null; 12057 /** Viewport width in pixels */ 12058 viewportWidth?: number | null; 12059 /** Viewport height in pixels */ 12060 viewportHeight?: number | null; 12061 /** Browser user agent string */ 12062 userAgent?: string | null; 12063} 12064 12065/** 12066 * Customer identity from web pixel events. 12067 */ 12068export interface WebPixelCustomerData { 12069 shopifyCustomerId?: string | null; 12070 email?: string | null; 12071 phone?: string | null; 12072 firstName?: string | null; 12073 lastName?: string | null; 12074 isLoggedIn?: boolean; 12075} 12076 12077/** 12078 * The nested payload object inside webpixel eventData. 12079 * 12080 * Contains device context and session info that are NOT duplicated at the top level. 12081 * product/page ARE duplicated at top level for efficient DynamoDB querying. 12082 * deviceContext is ONLY here. 12083 */ 12084export interface WebPixelNestedPayload { 12085 /** Event occurrence timestamp (ISO 8601) */ 12086 occurredAt?: string; 12087 /** Event type (e.g., "product.viewed") */ 12088 eventType?: string; 12089 /** Event source (e.g., "storefront") */ 12090 source?: string; 12091 /** Tenant name (shop domain) */ 12092 tenantName?: string; 12093 /** Shop domain */ 12094 shopDomain?: string; 12095 12096 // Session tracking (ONLY here, not at top level) 12097 /** Visitor ID for anonymous tracking */ 12098 visitorId?: string; 12099 /** Session ID for session-scoped tracking */ 12100 sessionId?: string; 12101 /** Lead ID if attributed */ 12102 leadId?: string; 12103 /** Campaign ID if campaign-attributed */ 12104 campaignId?: string; 12105 /** Attribution status */ 12106 attributionStatus?: string; 12107 12108 // Device context (ONLY here, not at top level) 12109 /** Device context captured by web pixel */ 12110 deviceContext?: WebPixelDeviceContext; 12111 12112 // Customer identity (may be at top level too) 12113 /** Customer identity data */ 12114 customer?: WebPixelCustomerData; 12115 12116 // Cart context 12117 /** Cart information */ 12118 cart?: { 12119 token?: string; 12120 attributes?: Record<string, unknown>; 12121 }; 12122 12123 // Duplicated fields (also at top level for querying) 12124 /** Page data */ 12125 page?: PageData; 12126 /** Product data */ 12127 product?: ProductData; 12128} 12129 12130/** 12131 * Complete webpixel event data structure as stored in DynamoDB. 12132 * 12133 * IMPORTANT: product/page are duplicated at top level for efficient DynamoDB GSI queries. 12134 * deviceContext is ONLY in payload - use getDeviceContext() helper to safely extract it. 12135 */ 12136export interface WebPixelEventData { 12137 // TOP LEVEL - duplicated for DynamoDB GSI queries 12138 /** Product data (duplicated from payload for querying) */ 12139 product?: ProductData; 12140 /** Page data (duplicated from payload for querying) */ 12141 page?: PageData; 12142 /** Shopify metadata */ 12143 shopify?: { 12144 shopDomain: string; 12145 source: 'webpixel'; 12146 eventType: string; 12147 }; 12148 12149 // Identity info (may be at top level) 12150 /** Identity summary */ 12151 identity?: { 12152 email?: string | null; 12153 phone?: string | null; 12154 firstName?: string | null; 12155 lastName?: string | null; 12156 customerId?: string | null; 12157 }; 12158 12159 // Cart context (may be at top level) 12160 /** Cart information */ 12161 cart?: { 12162 attributes?: Record<string, unknown>; 12163 }; 12164 12165 // NESTED PAYLOAD - contains deviceContext, session info 12166 /** 12167 * The original payload containing device context and session info. 12168 * Use getDeviceContext() to safely extract device information. 12169 */ 12170 payload?: WebPixelNestedPayload; 12171} 12172 12173// ============================================================================ 12174// Fan-Form Event Types 12175// ============================================================================ 12176 12177/** 12178 * Fan-form submission event data structure. 12179 * 12180 * Form events come from multiple sources (Klaviyo, native forms, etc.) and are 12181 * normalized by the form-submit-processor Lambda. 12182 */ 12183export interface FanFormEventData { 12184 // Identity fields (normalized) 12185 /** Normalized email address */ 12186 email?: string; 12187 /** Normalized phone number (E.164 format) */ 12188 phone?: string; 12189 /** First name */ 12190 firstName?: string; 12191 /** Last name */ 12192 lastName?: string; 12193 /** Full name (if not split) */ 12194 name?: string; 12195 12196 // Form identification 12197 /** Form identifier */ 12198 formId?: string; 12199 /** Page URL where form was submitted */ 12200 sourceUrl?: string; 12201 /** Page URL (alternative field) */ 12202 pageUrl?: string; 12203 12204 // Provider info 12205 /** Form provider (e.g., "klaviyo", "native") */ 12206 provider?: string; 12207 /** Event source (string or object with name) */ 12208 source?: string | { name?: string }; 12209 12210 // Session tracking 12211 /** Visitor ID for anonymous tracking */ 12212 visitorId?: string; 12213 /** Session ID for session-scoped tracking */ 12214 sessionId?: string; 12215 /** Lead ID if attributed */ 12216 leadId?: string; 12217 /** Campaign ID if campaign-attributed */ 12218 campaignId?: string; 12219 12220 // Form content 12221 /** Message field from form */ 12222 message?: string; 12223 /** Comments field from form */ 12224 comments?: string; 12225 12226 // All raw form fields 12227 /** All raw form field values */ 12228 fields?: Record<string, unknown>; 12229 12230 // Normalized identifiers 12231 /** Normalized identity fields */ 12232 identifiers?: { 12233 email?: string; 12234 phone?: string; 12235 }; 12236 12237 // Cart context (for checkout forms) 12238 /** Cart context if form was on checkout */ 12239 cart?: { 12240 token?: string; 12241 attributes?: Record<string, unknown>; 12242 }; 12243 12244 // Device context (if captured by form)
12245 /** Device context captured at form submission */ 12246 deviceContext?: WebPixelDeviceContext; 12247 12248 // Provider metadata 12249 /** Provider-specific metadata */ 12250 meta?: { 12251 provider?: string; 12252 /** Path to form HTML snapshot in S3 */ 12253 formHtmlPath?: string; 12254 consentMethod?: string | null; 12255 stepName?: string | null; 12256 source?: string | null; 12257 [key: string]: unknown; 12258 }; 12259 12260 // UTM attribution 12261 /** UTM tracking parameters */ 12262 utmParams?: { 12263 utm_source?: string; 12264 utm_medium?: string; 12265 utm_campaign?: string; 12266 utm_term?: string; 12267 utm_content?: string; 12268 }; 12269} 12270 12271// ============================================================================ 12272// Device Context Helper Functions 12273// ============================================================================ 12274 12275/** 12276 * Extract device context from an event's eventData, checking all possible locations. 12277 * 12278 * Use this helper instead of manually checking paths to avoid bugs where 12279 * deviceContext is nested differently than expected. 12280 * 12281 * @param eventData - The eventData object from an EventCustomer record 12282 * @returns The device context if found, or null 12283 * 12284 * @example 12285 * const deviceContext = getDeviceContext(event.eventData); 12286 * if (deviceContext?.deviceType === 'mobile') { 12287 * // Mobile-specific logic 12288 * } 12289 */ 12290export function getDeviceContext(eventData: Record<string, unknown> | null | undefined): WebPixelDeviceContext | null { 12291 if (!eventData || typeof eventData !== 'object') return null; 12292 12293 // 1. Check top-level deviceContext (some events may have it here) 12294 if (eventData.deviceContext && typeof eventData.deviceContext === 'object') { 12295 return eventData.deviceContext as WebPixelDeviceContext; 12296 } 12297 12298 // 2. Check payload.deviceContext (webpixel events store it here) 12299 const payload = eventData.payload as Record<string, unknown> | undefined; 12300 if (payload?.deviceContext && typeof payload.deviceContext === 'object') { 12301 return payload.deviceContext as WebPixelDeviceContext; 12302 } 12303 12304 // 3. Check legacy context field (older events) 12305 if (eventData.context && typeof eventData.context === 'object') { 12306 const context = eventData.context as Record<string, unknown>; 12307 if (context.deviceType) { 12308 return context as WebPixelDeviceContext; 12309 } 12310 } 12311 12312 return null; 12313} 12314 12315/** 12316 * Get a human-readable device label based on device type and OS family. 12317 * 12318 * @param deviceContext - Device context from getDeviceContext() 12319 * @returns Human-readable label like "iPhone", "iPad", "Android phone", "Desktop", or null 12320 * 12321 * @example 12322 * const deviceContext = getDeviceContext(event.eventData); 12323 * const label = getDeviceLabel(deviceContext); 12324 * // label might be "iPhone", "iPad", "Android tablet", "Desktop", "Mobile", etc. 12325 */ 12326export function getDeviceLabel(deviceContext: WebPixelDeviceContext | null | undefined): string | null { 12327 if (!deviceContext?.deviceType) return null; 12328 12329 const deviceType = String(deviceContext.deviceType).toLowerCase(); 12330 const osFamily = deviceContext.osFamily ? String(deviceContext.osFamily).toLowerCase() : ''; 12331 const isTouch = deviceContext.isTouch === true; 12332 12333 // Mobile devices 12334 if (deviceType === 'mobile' || deviceType === 'phone') { 12335 if (osFamily === 'ios') return 'iPhone'; 12336 if (osFamily === 'android') return 'Android phone'; 12337 // macOS + touch on mobile = iPhone (Safari desktop mode on iPhone) 12338 if (osFamily === 'macos' && isTouch) return 'iPhone'; 12339 return 'Mobile'; 12340 } 12341 12342 // Tablets 12343 if (deviceType === 'tablet') { 12344 if (osFamily === 'ios') return 'iPad'; 12345 if (osFamily === 'android') return 'Android tablet'; 12346 // macOS + touch on tablet = iPad (Safari desktop mode) 12347 // Real Macs don't have touchscreens, so this must be an iPad 12348 if (osFamily === 'macos' && isTouch) return 'iPad'; 12349 return 'Tablet'; 12350 } 12351 12352 // Desktop 12353 if (deviceType === 'desktop') { 12354 return 'Desktop'; 12355 } 12356 12357 return null; 12358} 12359 12360`,je=`/** 12361 * Event Email Type Definitions 12362 * 12363 * Based on Terraform schema: terraform/SHARED/create-dynamo-event-email 12364 * 12365 * Email tracking table - Tracks both inbound and outbound email events (sent, received, delivered, bounced, complained) from AWS SES 12366 * PRIMARY KEY = trackingId (UUID generated before sending for outbound, Email Message-ID header for inbound) 12367 * TIMESTAMPS = createdAt, updatedAt 12368 * 12369 * GSIs: 12370 * - sesMessageId-index: Match SES events (bounces, complaints, deliveries) to email records 12371 * - appId-campaignId-index: Query by app/campaign (outbound emails) 12372 * - emailsByCreatedDay: All emails created on a specific date (for daily aggregations) 12373 * - emailsByTypeTenantDay: All emails of a specific type for a specific tenant and date (composite GSI) 12374 * - emailsByToDomainDay: All emails for a specific email domain and date (for querying by recipient domain) 12375 */ 12376 12377import type { EventActor } from './event-customer.js'; 12378 12379/** 12380 * Email Direction Enum 12381 */
12382export type EmailDirection = 'inbound' | 'outbound'; 12383 12384/** 12385 * Email Status Enum 12386 * 12387 * Outbound statuses: sent, opened, delivered, bounced, complained 12388 * Inbound statuses: received 12389 */ 12390export type EmailStatus = 12391 | 'sent' 12392 | 'opened' 12393 | 'delivered' 12394 | 'bounced' 12395 | 'complained' 12396 | 'received'; 12397 12398/** 12399 * Email Type Enum (outbound only) 12400 */ 12401export type EmailType = 12402 | 'marketing' 12403 | 'admin-reply' 12404 | 'sales-action' 12405 | 'admin-action' 12406 | 'transactional'; 12407 12408/** 12409 * Open tracking event details 12410 */ 12411export interface EmailOpenDetail { 12412 /** ISO timestamp of the open */ 12413 timestamp: string; 12414 /** IP address of the email client */ 12415 ipAddress: string; 12416 /** User agent string from the email client */ 12417 userAgent: string; 12418} 12419 12420/** 12421 * Event Email record stored in DynamoDB email tracking table 12422 */ 12423export interface EventEmail { 12424 /** Primary key - UUID generated before sending (outbound) or Email Message-ID header (inbound) */ 12425 trackingId: string; 12426 12427 /** SES message ID returned after sending (outbound only, used for matching SES events) */ 12428 sesMessageId?: string; 12429 12430 /** Email direction: "inbound" or "outbound" */ 12431 direction: EmailDirection; 12432 12433 /** Recipient email address */ 12434 to?: string; 12435 12436 /** Sender email address */ 12437 from?: string; 12438 12439 /** Sender display name (optional) */ 12440 fromName?: string; 12441 12442 /** Email subject line */ 12443 subject?: string; 12444 12445 /** Plain text email body (optional) */ 12446 body?: string; 12447 12448 /** HTML email content (optional) */ 12449 htmlContent?: string; 12450 12451 /** Current status - see EmailStatus enum */ 12452 status: EmailStatus; 12453 12454 /** Tenant identifier (for multi-tenancy) - required */ 12455 tenantName: string; 12456 12457 /** Additional metadata as JSON object (optional) */ 12458 metadata?: Record<string, unknown>; 12459 12460 /** UTC timestamp when record was created (ISO 8601) */ 12461 createdAt: string; 12462 12463 /** Date partition for efficient daily aggregation queries (YYYY-MM-DD format, extracted from createdAt) */ 12464 emailCreatedDate: string; 12465 12466 /** UTC timestamp when record was last updated (ISO 8601) */ 12467 updatedAt: string; 12468 12469 // Outbound-specific fields 12470 12471 /** Application/shop identifier (e.g., "shopify-shop-123") */ 12472 appId?: string; 12473 12474 /** Campaign identifier (e.g., "summer-sale-2024") */ 12475 campaignId?: string; 12476 12477 /** Lead ID - links email to a lead (set by campaign execution engine for campaign emails) */ 12478 leadId?: string; 12479 12480 /** Type of email - see EmailType enum (outbound only) */ 12481 emailType?: EmailType; 12482 12483 /** Actor who initiated this outbound email â see EventActor. Propagated to outbound_email events-customer record by transform-email-to-customer-event. */ 12484 actor?: EventActor; 12485 12486 /** Composite key for emailsByTypeTenantDay GSI (format: emailType#tenantName) */ 12487 emailType_tenantName?: string; 12488 12489 /** ISO timestamp when email was sent */ 12490 sentAt?: string; 12491 12492 /** ISO timestamp when delivery event received */ 12493 deliveredAt?: string; 12494 12495 /** ISO timestamp when bounce event received */ 12496 bouncedAt?: string; 12497 12498 /** ISO timestamp when complaint event received */ 12499 complainedAt?: string; 12500 12501 /** ISO timestamp when delivery delay event received */ 12502 deliveryDelayAt?: string; 12503 12504 /** ISO timestamp when reject event received */ 12505 rejectedAt?: string; 12506 12507 /** ISO timestamp when rendering failure event received */ 12508 renderingFailureAt?: string; 12509 12510 /** Bounce type: "Permanent" or "Transient" */ 12511 bounceType?: string; 12512 12513 /** Bounce subtype (e.g., "General", "NoEmail") */ 12514 bounceSubType?: string; 12515 12516 /** Bounce reason/diagnostic code */ 12517 bounceReason?: string; 12518 12519 /** Rejection reason (for REJECT events) */ 12520 rejectionReason?: string; 12521 12522 /** Rendering failure reason (for RENDERING_FAILURE events) */ 12523 renderingFailureReason?: string; 12524 12525 /** ISO timestamp when email was first opened */ 12526 openedAt?: string; 12527 12528 /** Total number of times email was opened */ 12529 openCount?: number; 12530 12531 /** ISO timestamp of most recent open */ 12532 lastOpenedAt?: string; 12533 12534 /** Array of open events with timestamp, IP address, and user agent */ 12535 openDetails?: EmailOpenDetail[]; 12536 12537 /** trackingId of the inbound email being replied to (for linking outbound to inbound). For inbound emails, this is the same as trackingId. */ 12538 inboundMessageId?: string; 12539 12540 // Inbound-specific fields 12541 12542 /** S3 bucket name where raw email is stored */ 12543 s3Bucket?: string; 12544 12545 /** S3 object key for the raw email file (enables direct file access) */ 12546 s3Key?: string; 12547 12548 /** Array of all recipient addresses (if multiple) */ 12549 toAddresses?: string[]; 12550 12551 /** Email domain extracted from "To" address (e.g., "nutriprime.com.au") */ 12552 toDomain?: string; 12553 12554 /** ISO timestamp when email was received */ 12555 receivedAt?: string; 12556
12557 /** Array of outbound trackingIds that replied to this inbound email */ 12558 replyMessageIds?: string[]; 12559 12560 // Business mailboxes (3 Oct 2026) â stamped at arrival; classification only for a tenant with a mailbox map 12561 /** Cc addresses */ 12562 ccAddresses?: string[]; 12563 /** RFC 3834 Auto-Submitted header value */ 12564 autoSubmitted?: string; 12565 /** Precedence header value (bulk / list / junk / auto_reply) */ 12566 precedence?: string; 12567 /** List-Id header value */ 12568 listId?: string; 12569 /** In-Reply-To header value */ 12570 inReplyTo?: string; 12571 /** 'mailbox-map' when the tenant's mailbox map classified this message */ 12572 classifiedBy?: string; 12573 /** the mailbox it was filed to (sales, service, accounts, ops, admin, other) */ 12574 mailbox?: string; 12575 /** customer mail runs today's pipeline; business mail is filed, never a Lead */ 12576 mailboxKind?: 'customer' | 'business'; 12577 /** customer mail written by machinery (bounce, auto-reply, list) */ 12578 automated?: boolean; 12579 automatedKind?: string; 12580 /** why it was classified the way it was */ 12581 classificationReason?: string; 12582} 12583 12584/** 12585 * Event Email attributes used in GSI queries 12586 */ 12587export interface EventEmailGSIAttributes { 12588 /** For appId-campaignId-index GSI */ 12589 appId?: string; 12590 campaignId?: string; 12591 12592 /** For emailsByCreatedDay GSI */ 12593 emailCreatedDate: string; 12594 tenantName: string; 12595 12596 /** For emailsByTypeTenantDay GSI */ 12597 emailType_tenantName?: string; 12598 12599 /** For emailsByToDomainDay GSI */ 12600 toDomain?: string; 12601} 12602 12603/** 12604 * Event Email creation input (omits auto-generated fields) 12605 */ 12606export interface CreateEventEmailInput { 12607 trackingId: string; 12608 sesMessageId?: string; 12609 direction: EmailDirection; 12610 tenantName: string; 12611 to?: string; 12612 from?: string; 12613 fromName?: string; 12614 subject?: string; 12615 body?: string; 12616 htmlContent?: string; 12617 status: EmailStatus; 12618 metadata?: Record<string, unknown>; 12619 appId?: string; 12620 campaignId?: string; 12621 leadId?: string; 12622 emailType?: EmailType; 12623 /** Actor who initiated this outbound email â see EventActor */ 12624 actor?: EventActor; 12625 sentAt?: string; 12626 deliveredAt?: string; 12627 bouncedAt?: string; 12628 complainedAt?: string; 12629 deliveryDelayAt?: string; 12630 rejectedAt?: string; 12631 renderingFailureAt?: string; 12632 bounceType?: string; 12633 bounceSubType?: string; 12634 bounceReason?: string; 12635 rejectionReason?: string; 12636 renderingFailureReason?: string; 12637 openedAt?: string; 12638 openCount?: number; 12639 lastOpenedAt?: string; 12640 openDetails?: EmailOpenDetail[]; 12641 inboundMessageId?: string; 12642 s3Bucket?: string; 12643 s3Key?: string; 12644 toAddresses?: string[]; 12645 toDomain?: string; 12646 receivedAt?: string; 12647 replyMessageIds?: string[]; 12648 /** Date partition for efficient daily aggregation queries (YYYY-MM-DD format, auto-generated from createdAt if not provided) */ 12649 emailCreatedDate?: string; 12650 /** Composite key for emailsByTypeTenantDay GSI (auto-generated if not provided, format: emailType#tenantName) */ 12651 emailType_tenantName?: string; 12652} 12653 12654/** 12655 * Event Email update input (only updatable fields) 12656 */ 12657export interface UpdateEventEmailInput { 12658 sesMessageId?: string; 12659 direction?: EmailDirection; 12660 tenantName?: string; 12661 to?: string; 12662 from?: string; 12663 fromName?: string; 12664 subject?: string; 12665 body?: string; 12666 htmlContent?: string; 12667 status?: EmailStatus; 12668 metadata?: Record<string, unknown>; 12669 appId?: string; 12670 campaignId?: string; 12671 leadId?: string; 12672 emailType?: EmailType; 12673 sentAt?: string; 12674 deliveredAt?: string; 12675 bouncedAt?: string; 12676 complainedAt?: string; 12677 deliveryDelayAt?: string; 12678 rejectedAt?: string; 12679 renderingFailureAt?: string; 12680 bounceType?: string; 12681 bounceSubType?: string; 12682 bounceReason?: string; 12683 rejectionReason?: string; 12684 renderingFailureReason?: string; 12685 openedAt?: string; 12686 openCount?: number; 12687 lastOpenedAt?: string; 12688 openDetails?: EmailOpenDetail[]; 12689 inboundMessageId?: string; 12690 s3Bucket?: string; 12691 s3Key?: string; 12692 toAddresses?: string[]; 12693 toDomain?: string; 12694 receivedAt?: string; 12695 replyMessageIds?: string[]; 12696 emailCreatedDate?: string; 12697 emailType_tenantName?: string; 12698 updatedAt?: string; 12699 12700 // Business mailboxes (3 Oct 2026) â stamped at arrival; classification only for a tenant with a mailbox map 12701 /** Cc addresses */ 12702 ccAddresses?: string[]; 12703 /** RFC 3834 Auto-Submitted header value */ 12704 autoSubmitted?: string; 12705 /** Precedence header value (bulk / list / junk / auto_reply) */ 12706 precedence?: string; 12707 /** List-Id header value */ 12708 listId?: string; 12709 /** In-Reply-To header value */ 12710 inReplyTo?: string; 12711 /** 'mailbox-map' when the tenant's mailbox map classified this message */ 12712 classifiedBy?: string; 12713 /** the mailbox it was filed to (sales, service, accounts, ops, admin, other) */ 12714 mailbox?: string; 12715 /** customer mail runs today's pipeline; business mail is filed, never a Lead */ 12716 mailboxKind?: 'customer' | 'business'; 12717 /** customer mail written by machinery (bounce, auto-reply, list) */ 12718 automated?: boolean; 12719 automatedKind?: string; 12720 /** why it was classified the way it was */ 12721 classificationReason?: string; 12722} 12723 12724`,$e=`/** 12725 * Expert Analysis Lifecycle types â the "Change" entity that promotes an accepted 12726 * AI recommendation through Trigger -> Analysis -> Recommend -> Action -> Monitor -> Closed. 12727 * 12728 * Pattern: Process Manager + closed-loop ML feedback workflow with a human-in-the-loop gate. 12729 * It is a deterministic workflow (per Anthropic's "Building effective agents"), NOT an 12730 * autonomous agent â each LLM call is a prompt-chain step with programmatic gates between 12731 * stages. 12732 * 12733 * Storage: DynamoDB table \`expert-analysis-changes\`. 12734 * PK = tenantId, SK = changeId (ULID) 12735 * GSI byStatus â PK tenantId, SK \`\${status}#\${nextCheckAt}\` (drives monitor sweeper) 12736 * GSI byAnalysisId â PK sourceAnalysisId (back-link to originating analysis row) 12737 * 12738 * The originating analysis row in \`expert-analysis-results\` is read-only from this domain's 12739 * perspective â Reject still flips userStatus on the nested rec via the existing PUT route. 12740 */ 12741 12742// ---------------------------------------------------------------------------- 12743// Universal recommendation primitives 12744// ---------------------------------------------------------------------------- 12745// 12746// Citation / Evidence / Metric / Guardrail / TargetEntity are the building blocks 12747// that let one Recommendation + Change schema work across every domain (Meta ads, 12748// Shopify pages, workflows, messaging templates, agent pools, AI agents themselves). 12749// 12750// Two design rules enforced by these types: 12751// 1. "Agents cite, they don't invent." Every Evidence carries a Citation with a 12752// sampleSize and source. Trust is explicit: 'computed' (from a real DDB
12752/API 12753// query) vs 'inferred' (agent reasoning). The UI renders these differently. 12754// 2. "Every change is an experiment." Every ProposedChange carries a primary 12755// Metric, a Window, and (optional) Guardrails â so the monitor agent has a 12756// deterministic verdict gate after Apply. 12757 12758/** Where a number came from â used inside Citation. */ 12759export type CitationSource = 12760 | 'events-customer' 12761 | 'event-shopify' 12762 | 'event-facebook' 12763 | 'event-email' 12764 | 'campaign-sends' 12765 | 'campaign-context' 12766 | 'form-submissions' 12767 | 'meta-insights-api' 12768 | 'shopify-admin-api' 12769 | 's3-template' 12770 | 'agents-table' 12771 | 'industry-benchmark' 12772 | 'agent-inference' 12773 // Social presence (14 Sep 2026) 12774 | 'meta-graph' 12775 | 'media-assets'; // not computed; agent reasoning. Always paired with trust='inferred'. 12776 12777export interface Citation { 12778 source: CitationSource; 12779 /** Number of records the metric was computed over. UI surfaces this so users can 12780 * judge confidence (e.g. "1,832 sends" vs "8 sends"). */ 12781 sampleSize: number; 12782 windowStart?: string; 12783 windowEnd?: string; 12784 /** Optional debug/audit string describing the underlying query. */ 12785 query?: string; 12786 /** Deep link into ShopDash (/messages, /events, /leads) for drill-down. */ 12787 drillDownUrl?: string; 12788} 12789 12790/** A single fact-or-claim that backs a recommendation. */ 12791export interface Evidence { 12792 /** Plain English statement, executive-readable. */ 12793 claim: string; 12794 metric?: { name: string; value: number; unit?: string }; 12795 comparison?: { benchmark: number; benchmarkSource: string }; 12796 citation: Citation; 12797 /** 'computed' = real query result. 'inferred' = agent reasoning. The UI shows 12798 * 'inferred' claims in a separate, clearly-labelled section. */ 12799 trust: 'computed' | 'inferred'; 12800} 12801 12802/** The thing being changed. Generalises templateId/campaignId/adId/etc. */ 12803export type TargetEntityKind = 12804 // Social accounts (an Instagram business account or a Facebook Page) 12805 | 'social_account' 12806 // Messaging 12807 | 'sms_template' 12808 | 'email_template' 12809 | 'site_modal' 12810 // Workflows 12811 | 'workflow' 12812 | 'workflow_step' 12813 | 'workflow_trigger' 12814 // Meta ads 12815 | 'meta_ad' 12816 | 'meta_adset' 12817 | 'meta_campaign' 12818 | 'meta_budget' 12819 // Shopify 12820 | 'shopify_product' 12821 | 'shopify_collection' 12822 | 'shopify_page' 12823 | 'shopify_theme_section' 12824 // Operations 12825 | 'discount_rule' 12826 | 'agent_pool' 12827 | 'pool_membership' 12828 // Meta-loop (AI agents recommending changes to AI agents) 12829 | 'ai_agent' 12830 | 'ai_agent_prompt' 12831 | 'ai_agent_metric_default'; 12832 12833export interface TargetEntity { 12834 kind: TargetEntityKind; 12835 /** Stable identifier within its kind (e.g. templateId, campaignId, adId). */ 12836 id: string; 12837 /** Human-readable label for cards / diffs. */ 12838 label: string; 12839} 12840 12841/** The user-chosen primary metric for this Change's experiment. */ 12842export interface RecommendationMetric { 12843 /** Stable name from the metric catalog (e.g. 'reply_rate', 'cpl', 'order_cvr'). */ 12844 name: string; 12845 source: CitationSource; 12846 /** Where to measure: 'self' = the entity itself; 'parent' = its container (campaign/account/pool); 12847 * 'global' = tenant-wide rollup. STOP-style changes that delete the entity must use 'parent'. */ 12848 scope: 'self' | 'parent' | 'global'; 12849 currentValue?: number; 12850 /** Suggested success delta (e.g. +0.10 = "+10pp"). User can override at apply-time. */ 12851 targetDelta?: number; 12852 unit?: string; 12853} 12854 12855/** A regression signal that auto-fails the experiment if breached. */ 12856export interface Guardrail { 12857 name: string; 12858 source: CitationSource; 12859 /** e.g. -0.05 = "fail if metric drops more than 5pp from baseline". */ 12860 regressionThreshold: number; 12861 unit?: string; 12862} 12863 12864export interface MetricWindow { 12865 days: number; 12866 /** Minimum sample size required before a verdict can be issued (else 'pending'). */ 12867 minSampleSize?: number; 12868} 12869 12870/** 12871 * Three kinds of recommendation. Only \`kind='change'\` flows through Stage 4-6 12872 * (Action / Monitor / Close). Insights and investigations are read-only advice 12873 * that lives in a separate UI section. 12874 */ 12875export type RecommendationKind = 'change' | 'insight' | 'investigation'; 12876 12877// ---------------------------------------------------------------------------- 12878// Status + reason 12879// ---------------------------------------------------------------------------- 12880 12881/** 12882 * Five-state lifecycle. \`closedReason\` carries the success/failure/manual flavor 12883 * so the status enum stays small (mirrors Statsig / Eppo experiment status patterns). 12884 */ 12885export type ChangeStatus = 12886 | 'proposed' // recommendation has been promoted to a Change but not yet applied 12887 | 'applied' // action executed, baseline captured, monitoring not yet active (transient) 12888 | 'monitoring' // sweeper picks up nextCheckAt <= now() and runs verdict checks 12889 | 'closed' // terminal; closedReason carries the verdict flavor 12890 | 'reverted' // operator manually undid the change while in monitoring 12891 | 'rejected'; // user dismissed without applying; never moved past 'proposed' 12892 12893/** 12894 * Why the Change closed. \`success\`/\`failure\` come from the auto-monitor agent. 12895 * \`manual_*\` come from a user clicking Close in the UI. 12896 */ 12897export type ClosedReason = 12898 | 'success' 12899 | 'failure' 12900 | 'manual_success' 12901 | 'manual_failure' 12902 | 'manual_skip'; 12903 12904/** What kicked off the originating analysis run. */ 12905export type ChangeTrigger = 'cron' | 'event' | 'manual'; 12906
12907// ---------------------------------------------------------------------------- 12908// Proposed action (what Apply will do) 12909// ---------------------------------------------------------------------------- 12910 12911/** 12912 * Apply-side execution type. v1 ships the first 4 (template + workflow-config); 12913 * the rest are forward-compat additions for future domains. Each new value is a 12914 * 4-place plumbing change: ChangeActionType union + agent tool schema + 12915 * executeRecommendation() switch + metric catalog entry. See 12916 * BigM/Doco/AI/UNIVERSAL_RECOMMENDATION_LIFECYCLE.md for the recipe. 12917 */ 12918export type ChangeActionType = 12919 // Social publishing (14 Sep 2026 â apply path in dataApi \`applyImprovementPublishPost\`) 12920 | 'publish_post' 12921 // Messaging templates (v1 â apply path wired) 12922 | 'update_template' 12923 | 'create_template' 12924 | 'delete_template' 12925 // Workflows (v1 â apply path wired in ExpertDomainView.executeRecommendation) 12926 | 'update_workflow_config' 12927 | 'pause_workflow' 12928 | 'resume_workflow' 12929 | 'create_workflow' 12930 | 'update_workflow' 12931 // Meta ads (forward-compat) 12932 | 'pause_ad' 12933 | 'resume_ad' 12934 | 'update_ad_budget' 12935 | 'update_adset_targeting' 12936 | 'pause_campaign' 12937 // Shopify (forward-compat) 12938 | 'update_product_description' 12939 | 'update_collection_image' 12940 | 'update_theme_section' 12941 | 'update_homepage_banner' 12942 // Discount rules (forward-compat) 12943 | 'update_discount' 12944 | 'create_discount' 12945 // Agent pools (forward-compat) 12946 | 'add_agent_to_pool' 12947 | 'remove_agent_from_pool' 12948 | 'set_pool_size' 12949 | 'change_pool_strategy' 12950 // Meta-loop: AI agents recommending changes to AI agents (forward-compat) 12951 | 'update_agent_prompt' 12952 | 'update_agent_metric_default' 12953 | 'register_new_agent' 12954 // Continuous-improvement board (2026-09) â see ./improvement.ts for the 12955 // autonomous-vs-development mode of each. 12956 | 'attach_media_asset' 12957 | 'change_form_capture' 12958 | 'update_shopify_page' 12959 | 'update_amplify_ui' 12960 | 'investigate'; 12961 12962export type ChannelType = 'sms' | 'email' | 'site'; 12963 12964/** 12965 * Snapshot of an SMS template at propose-time (used in proposedChange.before/after). 12966 * Email/site previews are deferred per plan v2; their before/after shape lives here as 12967 * a forward-compatible union but the apply path only handles SMS body in v1. 12968 */ 12969export interface SmsTemplateSnapshot { 12970 body: string; 12971 mediaUrls?: string[]; 12972} 12973export interface EmailTemplateSnapshot { 12974 subject?: string; 12975 html?: string; 12976 text?: string; 12977} 12978export interface SiteTemplateSnapshot { 12979 html?: string; 12980} 12981export type TemplateSnapshot = 12982 | SmsTemplateSnapshot 12983 | EmailTemplateSnapshot 12984 | SiteTemplateSnapshot; 12985 12986/** 12987 * Snapshot of a workflow-context row's mutable fields (only the keys being changed 12988 * appear in \`before\`/\`after\`; we don't snapshot the entire row). 12989 */ 12990export type WorkflowConfigSnapshot = Record<string, unknown>; 12991 12992/** 12993 * Per-entity diff used inside ProposedChange.entities[] when a single recommendation 12994 * coordinates changes across multiple entities (e.g. "shift $20 from Ad A to Ad B"). 12995 * Apply must be all-or-nothing across entities. 12996 */ 12997export interface ProposedEntityDiff { 12998 entityKind: TargetEntityKind; 12999 entityId: string; 13000 entityLabel: string; 13001 before: unknown | null; 13002 after: unknown | null; 13003} 13004 13005export interface ProposedChange { 13006 type: ChangeActionType; 13007 13008 /** Universal way to identify what is being changed. Replaces the per-domain 13009 * channelType/templateId/targetCampaignId trio (which are kept below as 13010 * legacy convenience pointers for v1 callers â duplicates the targetEntity.id 13011 * for the existing apply paths). */ 13012 targetEntity: TargetEntity; 13013 13014 // Legacy convenience fields â populated for v1 paths (template + workflow_config) 13015 // so existing callers don't break. New action types should rely on targetEntity. 13016 channelType?: ChannelType; 13017 templateId?: string; 13018 targetCampaignId?: string; 13019 13020 /** before is null for create-style changes (e.g. create_template). 13021 * after is null for delete-style changes (e.g. delete_template, pause_ad). */ 13022 before: TemplateSnapshot | WorkflowConfigSnapshot | unknown | null; 13023 after: TemplateSnapshot | WorkflowConfigSnapshot | unknown | null; 13024 13025 /** Multi-entity coordinated diffs (budget shifts, pool reassignments). 13026 * When set, before/after carry the summary; entities[] carries the per-entity detail. */ 13027 isMultiEntity?: boolean; 13028 entities?: ProposedEntityDiff[]; 13029 13030 // Experiment design â drives Stage 5 (Monitor). Required for kind='change' recs. 13031 /** The single primary metric the user picked at apply-time to score success. */ 13032 metric: RecommendationMetric; 13033 /** How long to wait before the monitor is allowed to issue a terminal verdict. */ 13034 window: MetricWindow; 13035 /** Optional regression signals that auto-fail the change if breached. */ 13036 guardrails?: Guardrail[]; 13037 /** false = Stage 5 is skipped (e.g. pricing changes that can't be A/B-tested 13038 * cleanly). The Change still applies and audits, but the monitor never auto-closes 13039 * it â only manual close via UI. */ 13040 monitorable: boolean; 13041 13042 /** One-line plain-English summary of the diff, shown in the ChangeCard. */ 13043 diffSummary: string; 13044} 13045 13046// ---------------------------------------------------------------------------- 13047// Recommendation snapshot (immutable copy at propose-time) 13048// ---------------------------------------------------------------------------- 13049 13050/** 13051 * Why we snapshot rather than back-reference: analyses get re-run, recommendations get 13052 * superseded. The audit trail must show what was proposed *when* Apply was clicked, 13053 * not what the row says today. 13054 */ 13055export interface RecommendationSnapshot { 13056 /** Distinguishes change vs insight vs investigation. Only \`kind='change'\` should 13057 * ever produce a Change row; the snapshot exists on the Change so the field is 13058 * effectively always 'change' here, but kept for symmetry with the live rec. */ 13059 kind?: RecommendationKind; 13060 action: 'STOP' | 'KEEP' | 'GROW'; 13061 adName?: string; 13062 campaignName?: string; 13063 what: string; 13064 why: string; 13065 when?: string; 13066 /** Structured evidence with citations + trust labels. Legacy string[] is auto-migrated 13067 * by the backfill script into [{ claim, trust:'inferred', citation:{source:'agent-inference'} }]. */ 13068 evidence?: Evidence[]; 13069 impact?: { summary: string; monthlyValue?: number }; 13070 timeline?: string; 13071 monitoring?: string; 13072 priority?: number; 13073 confidence?: 'HIGH' | 'MEDIUM' | 'LOW'; 13074} 13075 13076// ---------------------------------------------------------------------------- 13077// Metrics baseline + success threshold 13078// ---------------------------------------------------------------------------- 13079 13080/** 13081 * Per-action-type baseline shape. Captured at apply-time; the monitor agent compares 13082 * the same metric set over the post-apply window. 13083 * 13084 * Tightened in plan v2 â see \`BigM/Doco/DASHBOARD/CEO_DASHBOARD_LIFECYCLE.md\` for the 13085 * full table per action type. 13086 */ 13087export interface SmsBaseline {
13088 channel: 'sms'; 13089 windowDays: number; 13090 sends: number; 13091 deliveries: number; 13092 replies: number; 13093 attributedOrders: number; 13094} 13095export interface EmailBaseline { 13096 channel: 'email'; 13097 windowDays: number; 13098 sends: number; 13099 deliveries: number; 13100 opens: number; 13101 clicks: number; 13102 attributedOrders: number; 13103} 13104export interface WorkflowConfigBaseline { 13105 channel: 'workflow'; 13106 windowDays: number; 13107 workflowExecutions: number; 13108 leadsCreated: number; 13109 attributedOrders: number; 13110} 13111export type MetricsBaseline = SmsBaseline | EmailBaseline | WorkflowConfigBaseline; 13112 13113/** 13114 * Programmatic gate the monitor agent scores against. v1 only ships the SMS shape; 13115 * email/workflow shapes are forward-compat placeholders. 13116 */ 13117export interface SmsSuccessThreshold { 13118 channel: 'sms'; 13119 /** Minimum delta in open-rate (decimal â 0.10 == +10%) for verdict 'success'. */ 13120 openRateDelta_min: number; 13121 /** Minimum delta in attributedOrders count (0 == "not lower than baseline"). */ 13122 attributedOrdersDelta_min: number; 13123} 13124export interface EmailSuccessThreshold { 13125 channel: 'email'; 13126 openRateDelta_min: number; 13127 clickRateDelta_min: number; 13128 attributedOrdersDelta_min: number; 13129} 13130export interface WorkflowSuccessThreshold { 13131 channel: 'workflow'; 13132 attributedOrdersDelta_min: number; 13133} 13134export type SuccessThreshold = 13135 | SmsSuccessThreshold 13136 | EmailSuccessThreshold 13137 | WorkflowSuccessThreshold; 13138 13139// ---------------------------------------------------------------------------- 13140// Monitoring check (one row per agent verdict run) 13141// ---------------------------------------------------------------------------- 13142 13143export type MonitoringVerdict = 13144 | 'pending' // check ran but agent abstained (low confidence / missing data) 13145 | 'on_track' // metrics moving in expected direction, threshold not yet met 13146 | 'concerning' // metrics moving wrong direction but not enough to declare failure 13147 | 'success' // SuccessThreshold met â triggers terminal close (closedReason='success') 13148 | 'failure'; // sustained anti-signal â triggers terminal close (closedReason='failure') 13149 13150export interface MetricsDelta { 13151 /** Map of metric name â { baseline, current, delta, deltaPct }. Shape is dynamic 13152 * because metrics differ per channel; documented in CEO_DASHBOARD_LIFECYCLE.md. */ 13153 [metricName: string]: { 13154 baseline: number; 13155 current: number; 13156 delta: number; 13157 deltaPct?: number; 13158 }; 13159} 13160 13161export interface MonitoringCheck { 13162 /** ISO timestamp of when the agent ran. */ 13163 checkedAt: string; 13164 verdict: MonitoringVerdict; 13165 metricsDelta: MetricsDelta; 13166 /** Free-text agent reasoning, ⤠500 chars per check. */ 13167 agentNotes: string; 13168} 13169 13170export interface MonitoringState { 13171 enabled: boolean; 13172 monitorWindowDays: number; 13173 /** Sweeper picks up rows where status='monitoring' AND nextCheckAt <= now(). */ 13174 nextCheckAt?: string; 13175 lastCheckedAt?: string; 13176 metricsBaseline: MetricsBaseline; 13177 successThreshold: SuccessThreshold; 13178 /** Bounded â capped at ~30 entries (one per day for the longest reasonable window). 13179 * Documented cap; the schema does not enforce it. */ 13180 checks: MonitoringCheck[]; 13181} 13182 13183// ---------------------------------------------------------------------------- 13184// Change log (audit trail, embedded array) 13185// ---------------------------------------------------------------------------- 13186 13187export type ChangeLogActor = 13188 | 'system' // automation (sweeper, scheduler, etc.) 13189 | 'agent' // an LLM agent run (analysis or monitor) 13190 | string; // a Cognito identity (email or sub) for human actions 13191 13192export type ChangeLogAction = 13193 | 'proposed' 13194 | 'applied' 13195 | 'monitor_check' 13196 | 'closed' 13197 | 'rejected' 13198 | 'reopened' 13199 // Continuous-improvement board transitions (2026-09), shared so one log 13200 // vocabulary covers both queues. 13201 | 'approved' 13202 | 'executing' 13203 | 'verified' 13204 | 'regressed' 13205 | 'dismissed' 13206 | 'handed_to_development' 13207 | 'expired' 13208 | 'failed' 13209 // A development-mode action done (observed by the scan or marked by a person), 13210 // and an applied change undone from the board. 13211 | 'completed' 13212 | 'reverted' 13213 // A proposal edited on the board before approval (caption, hashtags, media, owner tasks). 13214 | 'edited'; 13215 13216export interface ChangeLogEntry { 13217 /** ISO timestamp. */ 13218 ts: string; 13219 actor: ChangeLogActor; 13220 action: ChangeLogAction; 13221 /** Optional structured payload (e.g. { verdict, metricsDelta } for monitor_check, 13222 * { reason, note } for closed). */ 13223 payload?: Record<string, unknown>; 13224} 13225 13226// ---------------------------------------------------------------------------- 13227// The Change row itself 13228// ---------------------------------------------------------------------------- 13229 13230export interface Change { 13231 // Keys ----------------------------------------------------------------- 13232 /** Hash key â same tenant boundary as expert-analysis-results. */ 13233 tenantId: string; 13234 /** Sort key â ULID for time-orderable scans within a tenant. */ 13235 changeId: string; 13236 13237 // Provenance ----------------------------------------------------------- 13238 /** Back-link to the analysis row that produced the originating recommendation. */ 13239 sourceAnalysisId: string; 13240 /** Recommendation id within that analysis's structuredInsights.recommendations[]. */ 13241 sourceRecId: string; 13242 /** e.g. 'meta-ads', 'workflows', 'shopify', 'calls', 'leads', 'ceo'. */ 13243 expertDomain: string; 13244 /** What kicked off the originating analysis run. */ 13245 trigger: ChangeTrigger; 13246 /** Immutable snapshot of the recommendation at propose-time. */ 13247 recommendationSnapshot: RecommendationSnapshot; 13248 13249 // Action --------------------------------------------------------------- 13250 proposedChange: ProposedChange; 13251 13252 // Lifecycle ------------------------------------------------------------ 13253 status: ChangeStatus; 13254 closedReason?: ClosedReason; 13255 appliedAt?: string; 13256 appliedBy?: string; 13257 closedAt?: string; 13258 closedBy?: string; 13259 /** Set when an operator clicks Undo this fix on a Change still in \`monitoring\`. 13260 * Status flips to 'reverted' (terminal); the \`before\` snapshot is restored. */ 13261 revertedAt?: string; 13262 revertedBy?: string; 13263 13264 // Monitoring ---------------------------------------------------------- 13265 monitoring: MonitoringState; 13266 13267 // Audit --------------------------------------------------------------- 13268 /** Bounded â capped at ~30 entries documented in CEO_DASHBOARD_LIFECYCLE.md. */ 13269 changeLog: ChangeLogEntry[]; 13270 13271 // Standard timestamps ------------------------------------------------- 13272 createdAt: string; 13273 updatedAt: string; 13274 /** TTL â 180 days after closedAt. Set by the close handler. */ 13275 expiresAt?: number; 13276 13277 // GSI helpers --------------------------------------------------------- 13278 /** Composite SK for \`byStatus\` GSI: \`\${status}#\${nextCheckAt ?? '9999'}\` */ 13279 statusSortKey?: string; 13280} 13281 13282// ---------------------------------------------------------------------------- 13283// Input shapes for the dataApi routes 13284// ---------------------------------------------------------------------------- 13285 13286export interface ProposeChangeInput { 13287 sourceAnalysisId: string; 13288 sourceRecId: string; 13289 proposedChange: ProposedChange; 13290 /** Optional override; defaults from monitorWindowDays config per action type. */ 13291 monitorWindowDays?: number; 13292} 13293 13294export interface CloseChangeInput { 13295 reason: ClosedReason; 13296 note?: string; 13297} 13298`,ze=`/** 13299 * External-channel order types â the sale-property contract for orders entered 13300 * by staff (phone, showroom) and admin (reseller, costco). 13301 * 13302 * Storage model (EXTERNAL_CHANNEL_ORDERS_PLAN.md): discrete \`custom.*\` 13303 * metafields for the operational contract; two JSON metafields 13304 * (\`custom.sale_details\`, \`custom.delivery_details\`) for the evolving long 13305 * tail. Choice lists are validated in app code, NOT by Shopify â adding a 13306 * value is a config change here, never a metafield-definition migration. 13307 */ 13308 13309import type { LeadSourceChannel } from './lead'; 13310 13311export const SALES_CHANNELS = ['online', 'phone', 'showroom', 'reseller', 'costco'] as const; 13312export type OrderSalesChannel = (typeof SALES_CHANNELS)[number]; 13313 13314/** 13315 * Reseller pick-list + match tokens for custom.reseller / channel_reference 13316 * normalisation. HAND-WRITTEN \`as const\` on purpose â deriving from 13317 * RESELLER_REGISTRY would collapse the ResellerName literal union to string.
13318 * The parity test in __tests__/resellers.test.ts asserts this stays in sync 13319 * with RESELLER_REGISTRY (own-showroom entries excluded). Order matters: 13320 * matchReseller is first-match \`includes\`. 13321 */ 13322export const RESELLERS = [ 13323 { name: 'Superior Lifestyle', match: ['superior lifestyle'] }, 13324 { name: 'Healthezone Pty Ltd', match: ['healthezone'] }, 13325 { name: 'Sleeptime', match: ['sleeptime'] }, 13326 { name: 'Wellness Pillars Club', match: ['wellness pillars'] }, 13327 { name: 'Bad Backs', match: ['bad backs'] }, 13328 { name: 'Forty Winks', match: ['kadnet', 'forty winks', 'forty winks cannington'] }, // legal entity Kadnet Pty Ltd; renamed 3 Oct 2026 13329 { name: 'Thriftway Furniture', match: ['thriftway'] }, 13330 { name: 'Hartley Wells', match: ['hartley wells', 'leongatha'] }, 13331 { name: 'Footscray Betta Home Living', match: ['footscray betta', 'betta home living'] }, 13332 { name: 'Legends Wellness Hunter', match: ['legends wellness'] }, 13333 { name: 'Beds for Backs', match: ['beds for backs', 'b4b'] }, 13334 { name: 'Back To Sleep', match: ['back to sleep'] }, 13335 { name: 'Right Choice Mobility', match: ['right choice'] }, 13336 { name: 'Glory Box Furniture', match: ['glory box'] }, 13337 { name: 'Joe Calvi Fine Furniture', match: ['joe calvi'] }, 13338 { name: 'Central Coast Adjustable Beds', match: ['central coast adjustable'] }, 13339 { name: 'Le-Gees Furniture', match: ['le-gees', 'le gees', 'legees'] }, 13340 { name: 'Liberty Healthcare', match: ['liberty healthcare'] }, 13341 { name: 'Easysleep', match: ['easysleep', 'easy sleep'] }, 13342 { name: 'Massage Chairs Mandurah', match: ['massage chairs mandurah', 'mandurah'] }, 13343 { name: 'The Sleep Centre', match: ['sleep centre'] }, 13344 { name: 'The Posture Care Chair Company', match: ['posture care'] }, 13345 { name: 'Sleepdoctor Fyshwick', match: ['sleepdoctor', 'sleep doctor'] }, 13346 { name: 'APE Medical', match: ['ape medical'] }, 13347 { name: 'Fitness Warehouse', match: ['fitness warehouse'] }, 13348 { name: 'Pulse Point Recovery', match: ['pulse point'] }, 13349 // NZ before AU â AU's 'costco' token is generic and matchReseller is first-match. 13350 { name: 'Costco Wholesale NZ', match: ['costco wholesale new zealand', 'costco nz'] }, 13351 { name: 'Costco Wholesale AU', match: ['costco'] }, 13352] as const; 13353export type ResellerName = (typeof RESELLERS)[number]['name']; 13354 13355/** Token match (not equality) â the PO parser renders reseller names inconsistently run-to-run. */ 13356export function matchReseller(text: string | undefined | null): ResellerName | null { 13357 if (!text) return null; 13358 const t = text.toLowerCase(); 13359 return RESELLERS.find((r) => r.match.some((m) => t.includes(m)))?.name ?? null; 13360} 13361 13362/** 13363 * Display-grouping map: first-touch lead channel â default sales channel. 13364 * NOT attribution logic â leadSource.channel is read AS-IS; this only picks the 13365 * sales-channel bucket shown when no explicit custom.sales_channel metafield is 13366 * set. null = no auto value. reseller/costco/showroom never auto-derive â they 13367 * only come from the explicit metafield. 13368 */ 13369export const LEAD_CHANNEL_TO_SALES_CHANNEL: Record<LeadSourceChannel, OrderSalesChannel | null> = { 13370 facebook: 'online', 13371 google: 'online', 13372 paid_ad_click: 'online', 13373 google_organic: 'online', 13374 google_shopping: 'online', 13375 google_display: 'online', 13376 facebook_organic: 'online', 13377 instagram_organic: 'online', 13378 youtube_organic: 'online', 13379 bing_organic: 'online', 13380 ai_referral: 'online', 13381 shop_app: 'online', 13382 organic_non_direct: 'online', 13383 organic_direct: 'online', 13384 email_inbound: 'online', 13385 messenger_inbound: 'online', 13386 chat_inbound: 'online', 13387 max_list: 'phone', 13388 sms_inbound: 'phone', 13389 staff_created: 'phone', 13390 unknown: null, 13391 zoho_backfill: null, 13392 passion_backfill: null, 13393}; 13394 13395export const PAYMENT_METHODS = [ 13396 'outright', 13397 'humm', 13398 'payright', 13399 'bank_transfer', 13400 'eway', 13401 'paypal', 13402 'reseller_terms', 13403 'costco', 13404] as const; 13405export type PaymentMethod = (typeof PAYMENT_METHODS)[number]; 13406 13407/** 13408 * How draftOrderComplete records payment per method. 13409 * 'paid' â funds already collected (markAsPaid). 'pending' â funds arrive 13410 * later from the financier/reseller (payment pending). 13411 */ 13412export const PAYMENT_COMPLETION_MODE: Record<PaymentMethod, 'paid' | 'pending'> = { 13413 outright: 'paid', 13414 bank_transfer: 'paid', 13415 eway: 'paid', 13416 paypal: 'paid',
13417 humm: 'pending', 13418 payright: 'pending', 13419 reseller_terms: 'pending', 13420 costco: 'pending', 13421}; 13422 13423/** 13424 * Predefined payment display types for the sales feed / sale-confirmation 13425 * pickers (the values stamped on custom.payment_method + salesAttribution). 13426 * Fixed vocabulary â adding a value is a config change here, never a 13427 * runtime/tenant edit. reseller_terms + costco are the PO-ingest values. 13428 */ 13429export const PAYMENT_TYPES = [ 13430 'Upfront-eWay', 13431 'Payright', 13432 'Humm', 13433 'Zip', 13434 'Cash', 13435 'Other', 13436 'reseller_terms', 13437 'costco', 13438] as const; 13439export type PaymentType = (typeof PAYMENT_TYPES)[number]; 13440 13441export const DISPATCHING_STATES = ['NSW', 'VIC', 'QLD', 'SA', 'WA', 'TAS', 'NT', 'ACT'] as const; 13442export type DispatchingState = (typeof DISPATCHING_STATES)[number]; 13443 13444export const DELIVERY_TYPES = ['white_glove', 'kerbside', 'pickup'] as const; 13445export type DeliveryType = (typeof DELIVERY_TYPES)[number]; 13446 13447export const ENTRY_POINTS = ['front', 'back', 'side', 'garage'] as const; 13448export type EntryPoint = (typeof ENTRY_POINTS)[number]; 13449 13450/** \`custom.sale_details\` JSON metafield shape. All fields optional at the type level; requiredness is per-channel (see validation). */ 13451export interface SaleDetails { 13452 payment?: { 13453 /** "Payment Amounts/Deposit:" line, e.g. "$500 deposit / $5300 balance". */ 13454 amountsDeposit?: string; 13455 /** Count of card transactions (+ optional note). */ 13456 cardTransactions?: string; 13457 sameNameOnCard?: 'Y' | 'N' | string; 13458 /** 100-points ID check when 3+ card transactions. */
13459 id100Points?: 'Yes' | 'No' | 'N/A' | string; 13460 /** eWAY gateway results. */ 13461 gatewayTransactions?: Array<{ number?: string; response?: string; amount?: string }>; 13462 }; 13463 approval?: { 13464 /** Managers who approved a non-standard price. Multi via list. */ 13465 approvedBy?: string[]; 13466 }; 13467 attribution?: { 13468 /** Auto-derived from lead.leadSource.channel â never agent-typed. */ 13469 leadSource?: string; 13470 referredBy?: string; 13471 reasonForPurchase?: string; 13472 occupation?: string; 13473 /** 4-digit year of birth. */ 13474 yob?: string; 13475 }; 13476 sale?: { 13477 /** Floor-stock identifier, e.g. "TDP24-BL-FL-10". */ 13478 floorModel?: string; 13479 delayedDispatch?: string; 13480 }; 13481} 13482 13483/** \`custom.delivery_details\` JSON metafield shape â the delivery survey. Feeds Winnings white-glove booking. */ 13484export interface DeliveryDetails { 13485 driveway?: string; 13486 stepsAtEntry?: string; 13487 entryPoint?: EntryPoint | string; 13488 pastTroubleLargeItems?: string; 13489 doorMeasurements?: string; 13490 doorWidth?: string; 13491 upstairsDelivery?: 'Y' | 'N' | 'N/A' | string; 13492 personTakingDelivery?: string; 13493 deliveryDate?: string; 13494 dispatchingState?: DispatchingState | string; 13495 deliveryNotes?: string; 13496 deliveryType?: DeliveryType | string; 13497} 13498 13499/** Full sale-property payload accepted by draft create/update/complete. */ 13500export interface ExternalOrderProperties { 13501 salesChannel?: OrderSalesChannel; 13502 /** Reseller name + PO / Costco order # â free-form for now. */ 13503 channelReference?: string; 13504 showroomLocation?: string; 13505 /** Agent UUIDs or MaxIds â resolved by stampSalesAttribution. */ 13506 agentIds?: string[]; 13507 paymentMethod?: PaymentMethod; 13508 shopzenLeadId?: string; 13509 saleDetails?: SaleDetails; 13510 deliveryDetails?: DeliveryDetails; 13511} 13512 13513export interface ExternalOrderValidationError { 13514 field: string; 13515 message: string; 13516} 13517 13518export interface ExternalOrderValidationResult { 13519 valid: boolean; 13520 errors: ExternalOrderValidationError[]; 13521} 13522 13523/** Context the validator needs beyond the properties themselves. */ 13524export interface ExternalOrderValidationContext { 13525 /** Order contains a large deliverable item (chair/sauna/ice bath) â delivery survey required. */ 13526 hasLargeDeliverable: boolean; 13527 /** Order contains a floor-stock line â sale.floorModel required. */ 13528 hasFloorStockItem?: boolean; 13529} 13530`,Qe=`/** 13531 * freight.ts â multi-tenant freight-provider domain types. 13532 * 13533 * One abstraction over the freight carriers the platform books/quotes with 13534 * (Northline general freight, Winnings 3PL white-glove today; any carrier 13535 * tomorrow). Which provider handles a given shipment is TENANT CONFIG 13536 * (\`integrations.freight\` on TenantConfig â routing rules, per-tenant creds), 13537 * never code: MMC and MHC carry different rules, and a new tenant enabling a 13538 * provider is onboarding data, not a deploy. 13539 * 13540 * Implementations live in @bigm/shared/freight (NorthlineProvider, 13541 * WinningsProvider, resolveFreightProvider). These types are the contract. 13542 */ 13543 13544import type { AuState } from './lead.js'; 13545import type { SapFacilityCode } from './sap.js'; 13546 13547// ââ Provider identity ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 13548 13549export type FreightProviderKey = 'northline' | 'winnings'; 13550 13551// ââ Shared shipment shapes âââââââââââââââââââââââââââââââââââââââââââââââââââ 13552 13553/** A freight address. Suburb + postcode must be a valid Australia Post pair. */ 13554export interface FreightAddress { 13555 name: string; 13556 street1: string; 13557 street2?: string; 13558 suburb: string; 13559 postcode: string; 13560 /** Resolved AU state; providers derive it from postcode when omitted. */ 13561 state?: AuState; 13562 contactName?: string; 13563 /** Exactly 10 digits for Northline (e.g. 0400000000). */ 13564 contactPhone?: string; 13565} 13566 13567/** One physical line on a shipment. Dimensions in METRES, weight in KG. */ 13568export interface FreightItem { 13569 /** Shopify/SAP variant SKU when known â drives Winnings availability + routing. */ 13570 sku?: string; 13571 description: string; 13572 /** 13573 * Carrier package/item code (e.g. Northline "K"). Provider-specific; when 13574 * omitted the provider applies its configured default. 13575 */ 13576 packageCode?: string; 13577 weightKg: number; 13578 qty: number; 13579 lengthM: number; 13580 widthM: number; 13581 heightM: number; 13582} 13583 13584export interface FreightShipment { 13585 sender: FreightAddress; 13586 receiver: FreightAddress; 13587 items: FreightItem[]; 13588 /** Caller reference (order number etc.) stamped on the carrier record. */ 13589 reference?: string; 13590 specialInstructions?: string; 13591} 13592 13593// ââ Quoting ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 13594 13595export interface FreightQuote { 13596 provider: FreightProviderKey; 13597 serviceName: string; 13598 currency: string; 13599 /** Dollar amounts (not subunits). Undefined = provider quotes no price (e.g. included/free). */ 13600 totalExGst?: number; 13601 totalIncGst?: number; 13602 fuelLevyExGst?: number; 13603 /** Earliest/latest deliverable dates (YYYY-MM-DD) when the provider knows them. */ 13604 earliestDeliveryDate?: string; 13605 latestDeliveryDate?: string; 13606 /** Raw carrier response for audit/debug. */ 13607 raw?: unknown; 13608} 13609 13610export type FreightQuoteResult = 13611 | { status: 'ok'; quotes: FreightQuote[] } 13612 /** The provider answered and does NOT service this shipment. */ 13613 | { status: 'no-coverage' } 13614 /** Transient/config failure â caller decides fallback; never surface as no-coverage. */ 13615 | { status: 'error'; error: string }; 13616 13617// ââ Consignments (bookings) ââââââââââââââââââââââââââââââââââââââââââââââââââ 13618 13619export interface FreightConsignmentResult { 13620 provider: FreightProviderKey; 13621 /** Carrier consignment/connote number. */ 13622 consignmentNo: string; 13623 /** Date the consignment was lodged for (YYYY-MM-DD) â part of the carrier's key. */ 13624 consignmentDate: string; 13625 totalExGst?: number; 13626 totalIncGst?: number; 13627 raw?: unknown; 13628} 13629 13630/** Key identifying an existing consignment for label/tracking lookups. */ 13631export interface FreightConsignmentRef { 13632 consignmentNo: string; 13633 consignmentDate: string; 13634} 13635 13636// ââ Tracking âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 13637 13638/** 13639 * Northline's milestone enum, kept as the canonical progression; other 13640 * providers map into it (or 'unknown'). 13641 */ 13642export type FreightMilestoneStatus = 13643 | 'PreDepot' | 'PickedUp' | 'SendDepot' | 'TransDepot' | 'Loaded' 13644 | 'InTransit' | 'DestDepot' | 'OnDelivery' | 'Delivered' | 'unknown'; 13645 13646export interface FreightMilestone { 13647 status: FreightMilestoneStatus; 13648 /** Carrier-local milestone timestamp (ISO). */ 13649 at?: string; 13650 /** Estimated delivery date (YYYY-MM-DD) when the carrier provides one. */ 13651 eta?: string; 13652 raw?: unknown; 13653} 13654 13655// ââ The provider contract ââââââââââââââââââââââââââââââââââââââââââââââââââââ 13656 13657/** Thrown by providers for operations the carrier cannot do (yet). */ 13658export interface FreightCapabilities { 13659 quote: boolean; 13660 book: boolean; 13661 label: boolean; 13662 track: boolean; 13663} 13664 13665export interface FreightProvider { 13666 readonly key: FreightProviderKey; 13667 readonly capabilities: FreightCapabilities; 13668 quote(shipment: FreightShipment): Promise<FreightQuoteResult>; 13669 /** Books the shipment with the carrier. THE mutating call â dev/test gating is the caller's job. */ 13670 createConsignment(shipment: FreightShipment): Promise<FreightConsignmentResult>; 13671 /** PDF label bytes for a booked consignment. */ 13672 getLabelPdf(ref: FreightConsignmentRef): Promise<Uint8Array>; 13673 getLatestMilestone(ref: FreightConsignmentRef): Promise<FreightMilestone | null>; 13674} 13675 13676// ââ Tenant configuration (lives under TenantConfig.integrations.freight) âââââ 13677 13678export interface NorthlineIntegration { 13679 enabled: boolean; 13680 /**
13681 * SSM path to the SecureString JSON {username, password, custCode, baseUrl} 13682 * (e.g. /keys/northline/test | /keys/northline/prod). Per-tenant so another 13683 * tenant's Northline account is just a different parameter. 13684 */ 13685 credsSsm: string; 13686 /** 13687 * Default carrier item/package code for quote+connote lines when the caller 13688 * doesn't set one (rate-card specific â 'K' on the 8MAS07 account). 13689 */ 13690 defaultPackageCode?: string; 13691} 13692 13693export interface WinningsFreightIntegration { 13694 enabled: boolean; 13695 /** Connection creds come from integrations.erp.sap (shared with the rest of the SAP surface). */ 13696 /** 13697 * SAP Sold-To Party account for this tenant's bookings (e.g. MMC = '7200000102', 13698 * Lemon Wedge Pty Ltd). Source: Doco/INTEGRATIONS/WINNINGS_SAP_PORTAL_BOOKING.md. 13699 */ 13700 soldToParty?: string; 13701 /** 13702 * When true, \`sap-delivery-poller\` creates the Winnings SAP sales order itself for every paid, 13703 * bookable chair order it finds with no SAP order yet, then raises a \`delivery_review\` agent 13704 * action so a service person checks the booking. Off (or absent) = the poller only reads. 13705 * Flip per tenant; there is no cancel API on the Winnings side, so a wrong booking is a human fix. 13706 */ 13707 autoBook?: boolean; 13708 /** 13709 * Orders older than this many days are never auto-booked (default 14). Stops a first-run of 13710 * the automation sweeping the historical unbooked backlog into SAP in one go. 13711 */ 13712 autoBookMaxOrderAgeDays?: number; 13713} 13714 13715// ââ Winnings booking sheet (the manual SAP keying, field-mapped) âââââââââââââ 13716// 13717// Mirrors the Create Standard Order screens in the Winnings SAP portal exactly 13718// (Doco/INTEGRATIONS/WINNINGS_SAP_PORTAL_BOOKING.md, from Steve's SAP BOOKING 13719// GUIDE + walkthrough screenshots, 2026-08-17). Until Winnings enables the 13720// ZSD_SALES_ORDER_SRV write (404), this sheet is what an operator keys; when it 13721// opens, the same sheet is the semantic source for the create payload. 13722 13723export type WinningsBookingDocType = 'ZWSO' | 'ZISO'; 13724 13725export interface WinningsBookingShipTo { 13726 /** Customer full name (overwrites the WSADDRESS one-time ship-to). */ 13727 name: string; 13728 street: string; 13729 suburb: string; 13730 state: AuState | ''; 13731 postcode: string; 13732 /** Customer phone, else the doc's fallback 0479071199. */ 13733 phone: string; 13734 /** Customer email, else the doc's fallback [email protected]. */ 13735 email: string; 13736 phoneIsFallback: boolean; 13737 emailIsFallback: boolean; 13738} 13739 13740export interface WinningsBookingSheetLine { 13741 /** Winnings PARENT SKU â one line per product; SAP explodes cartons + prices itself. */ 13742 sku: string; 13743 qty: number; 13744 description?: string; 13745} 13746 13747export interface WinningsBookingServiceCharge { 13748 sku: string; 13749 kind: 'install' | 'warranty' | 'concierge' | 'delivery'; 13750 qty: number; 13751} 13752 13753export interface WinningsBookingSheet { 13754 /** SAP Sold-To Party (tenant-level, e.g. MMC '7200000102'). */ 13755 soldToParty: string; 13756 /** Always the literal one-time ship-to account. */ 13757 shipToParty: 'WSADDRESS'; 13758 shipTo: WinningsBookingShipTo; 13759 /** 13760 * Cust. Reference â the ONLY join back to Shopify (the delivery poller matches it). 13761 * Draft-based sales â draft name minus '#' (e.g. 'D32477'); web orders â bare 13762 * order number (e.g. '30469'). Data-proven 2026-08-17. 13763 */ 13764 custReference: string; 13765 custReferenceSource: 'draft' | 'order'; 13766 docType: WinningsBookingDocType; 13767 interstate: boolean; 13768 /** Set when interstate: the facility stock ships FROM. */ 13769 interstateSupplyingPlant?: SapFacilityCode; 13770 /** Deliver.Plant â the receiving state's facility (e.g. VIC '3000'). */ 13771 deliverPlant: SapFacilityCode; 13772 /** Operator-chosen valid date (YYYY-MM-DD); valid options ship alongside the sheet. */ 13773 reqDelivDate?: string; 13774 /** Complete Dlv. is ticked in the observed process. */ 13775 completeDelivery: true; 13776 /** Texts â Shipping instructions (header text object). */ 13777 shippingInstructionsText: string; 13778 lines: WinningsBookingSheetLine[]; 13779 /** Informational â service SKUs are not keyed as delivery lines. */ 13780 serviceCharges: WinningsBookingServiceCharge[]; 13781 /** 13782 * Order lines that are NOT keyed into Winnings (accessories: covers, cushions, pillows, 13783 * massagers). Real bookings never carry them (0 of 1,433 JunâSep 2026); they ship with the 13784 * chair or from the showroom. Listed so the reviewer sees the whole order. 13785 */ 13786 excludedLines: WinningsBookingSheetLine[]; 13787 /** Hard stops â do NOT key this booking (wrong path / not payable / no stock). */ 13788 blockers: string[]; 13789 /** Advisories â key with care. */ 13790 warnings: string[]; 13791} 13792 13793/** 13794 * One routing rule. First match wins; a shipment matches when EVERY present 13795 * condition matches. No rules matching â defaultProvider. 13796 */ 13797export interface FreightRoutingRule { 13798 provider: FreightProviderKey; 13799 /** Case-insensitive SKU globs ('*' wildcard), matched against any shipment item SKU. */ 13800 skuGlobs?: string[]; 13801 /** Receiver states this rule covers. */ 13802 states?: AuState[]; 13803 /** Receiver postcode prefixes (e.g. ["3"] = VIC-ish, ["08","09"] = NT). */ 13804 postcodePrefixes?: string[]; 13805} 13806 13807export interface FreightIntegrationsConfig { 13808 northline?: NorthlineIntegration; 13809 winnings?: WinningsFreightIntegration; 13810 routing?: FreightRoutingRule[]; 13811 /** Used when no routing rule matches. */ 13812 defaultProvider?: FreightProviderKey; 13813} 13814 13815// ââ Booking eligibility (Book delivery button gate) ââââââââââââââââââââââââââ 13816 13817/** 13818 * Why a paid order must NOT be booked through the Winnings API right now. 13819 * - no_stock: every facility has ATP 0 for a chair on the order. 13820 * - low_stock: the home facility has stock but fewer than MIN_ATP_TO_BOOK (4) units. 13821 * - interstate: the customer's home facility has ATP 0 and another facility has stock 13822 * (the sheet would book ZISO; Chris keeps interstate with Winnings accounts). 13823 * - transfer: customer state TAS or NT (7000/8000) â never booked via the API. 13824 * - blocked: any other sheet blocker (showroom / non-Winnings SKU, bad address, â¦). 13825 */ 13826export type BookingEligibilityReason = 'no_stock' | 'low_stock' | 'interstate' | 'transfer' | 'blocked'; 13827 13828export interface BookingEligibility { 13829 eligible: boolean; 13830 reason: BookingEligibilityReason | null; 13831 /** One human sentence for the button, e.g. "Interstate: VIC has 0, stock is in QLD (4000)." */ 13832 detail: string; 13833 docType: WinningsBookingDocType; 13834 homeFacility: SapFacilityCode | null; 13835 /** Facility the sheet would supply from (home when in stock, else the best other one). */ 13836 supplyingFacility: SapFacilityCode | null; 13837 /** ATP at the home facility for the scarcest chair on the order. */ 13838 atpHome: number; 13839 /** ATP by facility for the scarcest chair on the order. */ 13840 atpByFacility: Partial<Record<SapFacilityCode, number>>; 13841 /** Where the stock numbers came from. */ 13842 stockSource: 'live' | 'snapshot'; 13843 /** ISO timestamp (live) or the snapshot day. */ 13844 stockAsOf: string; 13845} 13846`,Xe=`/** 13847 * Continuous-improvement contracts. 13848 * 13849 * Shared by three writers/readers that must never drift from each other: 13850 * - the \`improvement-engine\` Lambda (writes \`improvement-runs\` and \`improvement-actions\`), 13851 * - the ShopDash dataApi (reads both, executes an approved action in-process), 13852 * - the ShopDash \`/improvement\` board (renders rows AS-IS). 13853 * 13854 * â THE ONE RULE: every finding carries a \`basis\`. \`computed\` came out of a table and 13855 * the same code run twice on the same data gives the same answer; \`inferred\` came out 13856 * of a model. The two are stored side by side and rendered differently, because seven 13857 * earlier AI-improvement attempts on this platform produced 1,213 recommendations and 13858 * had zero accepted â nobody could tell which parts were actually true. 13859 * 13860 * A "subject" is ONE form or ONE workflow â the level at which an action is taken and a 13861 * metric is measured. There is never a row for "Forms" as a group. 13862 * 13863 * Action vocabulary is NOT redeclared here: \`ChangeActionType\`, \`TargetEntity\`, 13864 * \`Evidence\` and \`ChangeLogEntry\` come from ./expertAnalysis.ts, which already carries 13865 * the universal recommendation primitives. This file adds the improvement-specific 13866 * envelopes around them. 13867 */ 13868 13869import type { 13870 ChangeActionType, 13871 ChangeLogEntry, 13872 Evidence, 13873 TargetEntity, 13874} from './expertAnalysis.js'; 13875 13876// --------------------------------------------------------------------------- 13877// Provenance + subjects 13878// --------------------------------------------------------------------------- 13879 13880export type ImprovementBasis = 'computed' | 'inferred'; 13881 13882export type ImprovementSubjectKind = 'form' | 'workflow' | 'template' | 'account'; 13883 13884/** 13885 * Every check the engine registers, in the order the board lists them. The 13886 * dataApi reads runs by \`\${tenantId}#\${checkId}\`, so it needs this list to know 13887 * which rows exist; the engine's registry asserts it matches. Add a category 13888 * here AND in the engine's \`checks/index.ts\`. 13889 */ 13890export const IMPROVEMENT_CHECK_IDS = ['forms-without-workflow', 'sms-workflow-effectiveness', 'social-presence'] as const; 13891export type ImprovementCheckId = (typeof IMPROVEMENT_CHECK_IDS)[number]; 13892 13893export const IMPROVEMENT_CHECK_LABELS: Record<ImprovementCheckId, string> = { 13894 'forms-without-workflow': 'Forms', 13895 'sms-workflow-effectiveness': 'SMS', 13896 'social-presence': 'Social', 13897}; 13898 13899/** 13900 * The ONE number a subject is being optimised on, at the moment of a scan. 13901 * 13902 * The check decides the name per subject (a workflow whose template carries a 13903 * short link is optimised on browse rate; a conversational one on reply rate). 13904 * The name travels with the value so a later change of definition is visible: 13905 * the board only compares two scans of the same subject when the names match. 13906 */ 13907export interface ImprovementMetric { 13908 name: string; 13909 value: number; 13910 unit: '%' | 'count' | 'aud'; 13911 /** Which way is better. */ 13912 direction: 'up' | 'down';
13913 /** How many records the value was computed over â shown next to it. */ 13914 sampleSize: number; 13915 /** True when \`sampleSize\` is under the check's floor for that metric. */ 13916 provisional: boolean; 13917} 13918 13919export interface ImprovementVerdict { 13920 /** Check-specific code, e.g. \`no_workflow\`, \`below_peers\`, \`healthy\`. */ 13921 code: string; 13922 /** \`true\` â the board shows PASS and offers no action. */ 13923 pass: boolean; 13924 /** Always \`computed\` â verdicts come out of joins and tallies, never a model. */ 13925 basis: ImprovementBasis; 13926 /** One plain line with the numbers in it. */ 13927 detail: string; 13928} 13929 13930/** One row on the board per scan: a single form or a single workflow. */ 13931export interface CheckSubject { 13932 subjectId: string; 13933 subjectKind: ImprovementSubjectKind; 13934 label: string; 13935 /** For workflow subjects: the SMS template the workflow sends, so the board's 13936 * Subject filter can also pivot on "this template". */ 13937 templateId?: string; 13938 verdict: ImprovementVerdict; 13939 metric: ImprovementMetric; 13940 /** The compact numbers for the cell and the drawer. Bounded â never per-lead lists. */ 13941 metrics: Record<string, number>; 13942 /** Check-specific drawer payload, capped so a run row stays small. */ 13943 evidence?: unknown; 13944 /** The same subject on the runs nearest 7 and 30 days before this one â the board's trend columns. Stamped by the engine at run time. */ 13945 history?: MetricHistory; 13946} 13947 13948/** One earlier reading of a subject: when, the optimised metric then, and the full numbers then. */ 13949export interface MetricSnapshot { 13950 runAt: string; 13951 metric?: ImprovementMetric; 13952 metrics: Record<string, number>; 13953} 13954export interface MetricHistory { 13955 d7?: MetricSnapshot; 13956 d30?: MetricSnapshot; 13957} 13958 13959/** What every check's measurement exposes, so the board and the digest are generic. */ 13960export interface CheckMeasurement { 13961 checkId: string; 13962 tenantId: string; 13963 runAt: string; 13964 windowDays: number; 13965 subjects: CheckSubject[]; 13966 /** Counts by verdict code, so nothing downstream re-derives them. */ 13967 counts: Record<string, number>; 13968} 13969 13970// --------------------------------------------------------------------------- 13971// Advice (the model's half of a run) 13972// --------------------------------------------------------------------------- 13973 13974/** One thing the model thinks should happen about one subject. Always \`inferred\`. */ 13975export interface ImprovementAdviceItem { 13976 subjectId: string; 13977 headline: string; 13978 why: string; 13979 confidence: number; 13980 basis: ImprovementBasis; 13981 /** Kept for the forms board, which reads advice by \`formId\`. Equals \`subjectId\` for form subjects. */ 13982 formId?: string; 13983 /** Forms check only. */ 13984 suggestedChannel?: string; 13985 /** Forms check only: an existing SMS template the model suggests a new workflow reuse (validated against the tenant's catalogue). */ 13986 reuseTemplateId?: string; 13987 /** 13988 * Forms check only: an EXISTING form workflow the model suggests this form be 13989 * added to (its \`check_form_id\` include list gains the form id), so the form 13990 * inherits that workflow's template and reply handling instead of getting a 13991 * workflow of its own. Validated against the tenant's live form workflows. 13992 * Takes precedence over \`reuseTemplateId\`. 13993 */ 13994 bindToCampaignId?: string; 13995} 13996 13997export interface ImprovementAdviceResult { 13998 items: ImprovementAdviceItem[]; 13999 /** Items the evaluator threw out, kept so a silent drop is never invisible. */ 14000 rejected: { item: unknown; reason: string }[]; 14001 model?: string; 14002 costUsd?: number; 14003 /** Set when no model call was made at all (no key, advice disabled, nothing to advise on). */ 14004 skipped?: string; 14005} 14006 14007/** 14008 * One parked run: \`improvement-runs\` row. 14009 * PK \`tenantCheck\` = \`\${tenantId}#\${checkId}\`, SK \`runAt\`. 14010 * The generic envelope; \`measured\` is the check's own measurement, which MUST 14011 * satisfy \`CheckMeasurement\` (the board reads \`subjects[]\` off it). 14012 */ 14013export interface ImprovementRun<M extends CheckMeasurement = CheckMeasurement> { 14014 tenantCheck: string; 14015 runAt: string; 14016 tenantId: string; 14017 checkId: string; 14018 measured: M; 14019 advice: ImprovementAdviceResult; 14020 engineVersion: string; 14021} 14022 14023// --------------------------------------------------------------------------- 14024// Actions (the decision queue) â \`improvement-actions\` 14025// --------------------------------------------------------------------------- 14026 14027export type ImprovementActionStatus = 14028 | 'proposed' // parked by the engine, awaiting a tenant admin 14029 | 'executing' // claimed by an approve request (transient, same request) 14030 | 'applied' // written and read back; waiting for its verify window 14031 | 'verified' // the optimised metric improved after the change 14032 | 'regressed' // the optimised metric got worse after the change 14033 | 'dismissed' // a tenant admin declined it
14034 | 'handed_to_development' // a development-mode recommendation handed off 14035 | 'expired' // the engine stopped raising it before anyone decided 14036 | 'failed' // the apply failed a staleness or postcondition check 14037 | 'reverted'; // applied, then undone by a tenant admin (quiet for 30 days, like dismissed) 14038 14039/** 14040 * Autonomous â a tenant admin can click Take action and the dataApi applies it to 14041 * prod in the same request. Development â the board shows "Recommended: via 14042 * development" and offers a hand-off instead. The mode is a property of the action 14043 * TYPE, decided here in code â never by the model and never by the UI. 14044 */ 14045export type ExecutionMode = 'autonomous' | 'development'; 14046 14047export const EXECUTION_MODE_BY_ACTION: Partial<Record<ChangeActionType, ExecutionMode>> = { 14048 // Straight to prod through the dataApi's own write paths. 14049 create_workflow: 'autonomous', 14050 update_workflow_config: 'autonomous', 14051 pause_workflow: 'autonomous', 14052 create_template: 'autonomous', 14053 update_template: 'autonomous', 14054 attach_media_asset: 'autonomous', 14055 // Publishes on the tenant's social account through the shared Meta publisher â but 14056 // ONLY after the owner's yes is recorded on the approval (dataApi refuses otherwise). 14057 publish_post: 'autonomous', 14058 // Needs a developer, a review and a promotion. 14059 change_form_capture: 'development', 14060 update_shopify_page: 'development', 14061 update_theme_section: 'development', 14062 update_homepage_banner: 'development', 14063 update_amplify_ui: 'development', 14064 investigate: 'development', 14065}; 14066 14067/** Unknown or unlisted types default to the SAFE mode. */ 14068export function executionModeFor(actionType: ChangeActionType): ExecutionMode { 14069 return EXECUTION_MODE_BY_ACTION[actionType] ?? 'development'; 14070} 14071 14072/** 14073 * What an approve will do, built by the engine at proposal time so the approver 14074 * sees exactly what will be written. Every mutating kind carries \`beforeHash\` â 14075 * the apply refuses to write if the target changed since the proposal. 14076 * 14077 * \`beforeHash\` contract (engine \`hashOf\` and dataApi \`improvementHash\` are the 14078 * same function: sha1 of the JSON of the value with object keys sorted 14079 * recursively, \`undefined\` â \`null\`): 14080 * - update_template hash(before.body) â the raw body string 14081 * - update_workflow_config hash(before) â the row's current 14082 * values for exactly the top-level keys in \`after\` 14083 * - pause_workflow hash({ active, status, trigger }) of the row 14084 * - attach_media_asset hash({ messages }) of the row 14085 */ 14086export type ActionProposal = 14087 | { 14088 kind: 'create_template'; 14089 channel: 'sms'; 14090 templateId: string; 14091 name: string; 14092 after: { body: string }; 14093 } 14094 | { 14095 kind: 'update_template'; 14096 channel: 'sms'; 14097 templateId: string; 14098 /** Body files that exist today and will all be rewritten. */ 14099 fileNames: string[]; 14100 before: { body: string }; 14101 after: { body: string }; 14102 beforeHash: string; 14103 /** Other active workflows that send this template â the rewrite changes them too. */ 14104 sharedWith?: { campaignId: string; name: string }[]; 14105 } 14106 | { 14107 kind: 'create_workflow'; 14108 campaignId: string; 14109 /** The full \`campaign-context\` row to write, \`active: false\`. */ 14110 row: Record<string, unknown>; 14111 /** 14112 * The SMS the workflow sends. \`create\` â written first (name.txt + 14113 * message.txt); \`reuse\` â an existing template the row already points at 14114 * (its body shown for the approver, nothing written). 14115 */ 14116 template?: { mode: 'create' | 'reuse'; templateId: string; name: string; body: string; sharedWith?: { campaignId: string; name: string }[] }; 14117 trigger: { eventTypes: string[]; appType: string }; 14118 /** 14119 * Where a reply to the new workflow's SMS will land, decided from the 14120 * tenant's inbound-SMS rows. \`ai_booking\` â the apply also adds the new 14121 * campaignId to the AI handler's include list and to the agents' 14122 * stand-down (exclude) list, so exactly one of them acts on the reply. 14123 */ 14124 replyRouting?: { 14125 mode: 'ai_booking' | 'agent' | 'none'; 14126 handlerCampaignId?: string; 14127 handlerName?: string; 14128 agentInboundCampaignId?: string; 14129 agentInboundName?: string; 14130 }; 14131 } 14132 | { 14133 kind: 'update_workflow_config'; 14134 campaignId: string; 14135 /** Whole top-level objects only â the write is a shallow merge. */ 14136 before: Record<string, unknown>; 14137 after: Record<string, unknown>; 14138 beforeHash: string; 14139 /** One plain paragraph saying what the change does â the drawer and the change row
14139show it. */ 14140 summary?: string; 14141 /** 14142 * Set when the change BINDS a form to an existing form workflow (the 14143 * workflow's include list gains the form id). Forms â workflows is 14144 * many-to-one and workflows â templates is many-to-one, so the approver 14145 * is shown everything the form inherits: the other forms the workflow 14146 * already serves, the template it sends, the other workflows that send 14147 * that same template, and where a reply lands. 14148 */ 14149 binding?: { 14150 formId: string; 14151 formLabel: string; 14152 workflowName: string; 14153 /** Other forms on the workflow's include lists, labelled when measured this run. */ 14154 otherForms: { formId: string; label?: string; submissionsWindow?: number }[]; 14155 /** The template the workflow SENDS: its appointment-slot step's opener template when it has one, else \`messages.sms.templateId\`. */ 14156 templateId?: string; 14157 /** The body as read at proposal time. */ 14158 templateBody?: string; 14159 /** Set when the appointment-slot step composes the final text from that template at send time. */ 14160 composedBy?: 'offer_appointment_slots'; 14161 /** What a customer would read: the template with the step's tokens filled as at send time (example times). */ 14162 preview?: string; 14163 /** Other active workflows that send the same template. */ 14164 templateSharedWith: { campaignId: string; name: string }[]; 14165 replyRouting: { mode: 'ai_booking' | 'agent' | 'none'; handlerName?: string; agentInboundName?: string }; 14166 }; 14167 } 14168 | { 14169 kind: 'pause_workflow'; 14170 campaignId: string; 14171 beforeHash: string; 14172 } 14173 | { 14174 kind: 'attach_media_asset'; 14175 campaignId: string; 14176 assetId: string; 14177 url: string; 14178 beforeHash: string; 14179 } 14180 | { 14181 /** 14182 * A fully built social post (14 Sep 2026). The approver sees exactly this on the 14183 * card; the dataApi publishes exactly this and nothing else. Media are the 14184 * library renditions (public CDN URLs); \`beforeHash\` covers assets + caption + 14185 * hashtags + media URLs so a changed asset or caption makes the proposal stale. 14186 */ 14187 kind: 'publish_post'; 14188 platform: 'instagram' | 'facebook'; 14189 format: 'image' | 'carousel' | 'reel' | 'story' | 'page_post' | 'page_photo'; 14190 /** media-assets ids, in order (carousel: 2â10). */ 14191 assetIds: string[]; 14192 /** Which rendition of each asset is posted. */ 14193 derivative: 'feed' | 'square' | 'story' | 'reel' | 'voiceover' | 'master'; 14194 /** Public URLs Meta will fetch, one per asset, in order. */ 14195 mediaUrls: string[]; 14196 caption: string; 14197 /** Without the leading \`#\`; appended to the caption at publish time. */ 14198 hashtags: string[]; 14199 /** Tracked link for a Page post (attached to the post, never pasted into an IG caption). */ 14200 link?: { shortUrl: string; shortCode: string; campaignId: string; destination: string }; 14201 /** Suggested publish time (ISO); informational â Approve publishes now. */ 14202 suggestedAt?: string; 14203 beforeHash: string; 14204 /** What the card renders: the rendition image, or the reel + a poster. */ 14205 preview: { imageUrl?: string; videoUrl?: string; posterUrl?: string }; 14206 /** "@deltaxcoach" or the Page name. */ 14207 accountLabel: string; 14208 /** Why this asset and angle, one line from the copy model. */ 14209 rationale?: string; 14210 /** 14211 * What the owner still has to do before this goes out (record a voice-over, add 14212 * footage, check the caption). Set at proposal time; ticked or edited from the board. 14213 * Advisory: Publish is never blocked by an unticked task. 14214 */ 14215 ownerTasks?: OwnerTask[]; 14216 /** The last edit made on the board (caption, hashtags or media); \`beforeHash\` is recomputed with it. */ 14217 edited?: ProposalEdit; 14218 /** The owner's \`voice-take\` asset mixed onto the reel (\`derivative\` is then \`voiceover\`). */ 14219 voiceTakeId?: string; 14220 } 14221 | { 14222 kind: 'development'; 14223 brief: string; 14224 }; 14225 14226/** One thing the owner does on their side before a post goes out. */ 14227export interface OwnerTask { 14228 id: string; 14229 kind: 'voiceover' | 'footage' | 'caption' | 'review' | 'other'; 14230 text: string; 14231 /** Ticked on the board. */ 14232 done?: { by: string; at: string }; 14233} 14234 14235/** A board edit to a proposal, for the audit trail. */ 14236export interface ProposalEdit { 14237 by: string; 14238 at: string; 14239 /** Which fields changed: caption, hashtags, media, ownerTasks. */ 14240 fields: string[]; 14241} 14242 14243/** The owner's recorded yes for a public action (social post). */ 14244export interface OwnerYes { 14245 /** The person who said yes (owner name). */ 14246 by: string; 14247 /** When they said it (ISO). */ 14248 at: string; 14249 channel: 'whatsapp' | 'sms' | 'email' | 'phone' | 'in_person' | 'shopdash'; 14250 /** Free text: what exactly they saw / said. */ 14251 note?: string; 14252} 14253 14254export interface ImprovementExecutionStep { 14255 name: string; 14256 ok: boolean; 14257 ms: number; 14258 detail?: string; 14259} 14260 14261export interface ImprovementAction { 14262 /** sha1(tenantId, checkId, subjectId, actionType, targetId[, supersedes]) â the 14263 * same recommendation two nights running is ONE row. */ 14264 actionId: string; 14265 tenantId: string; 14266 checkId: string; 14267 subjectId: string; 14268 subjectKind: ImprovementSubjectKind; 14269 /** GSI \`ActionsByTenantStatus\` hash key: \`\${tenantId}#\${status}\`. 14270 * â Rewritten in the SAME UpdateExpression as \`status\`, every time. */ 14271 tenantStatus: string; 14272 createdAt: string; 14273 updatedAt: string; 14274 /** The last nightly run that still raised this recommendation. */
14275 lastSeenRunAt: string; 14276 /** createdAt + 14 days. Past it the row is OVERDUE (still open) and counted in the digest. */ 14277 decideBy: string; 14278 /** The terminal action this one replaces, when the same recommendation was re-raised. */ 14279 supersedes?: string; 14280 status: ImprovementActionStatus; 14281 executionMode: ExecutionMode; 14282 actionType: ChangeActionType; 14283 target: TargetEntity; 14284 /** The advice as accepted by the evaluator â \`inferred\`. */ 14285 headline: string; 14286 why: string; 14287 /** Measured facts behind it, each with \`trust\` and a citation â \`computed\`. */ 14288 evidence: Evidence[]; 14289 /** The optimised metric at proposal time â the verify baseline. */ 14290 metricAtProposal: ImprovementMetric; 14291 proposal: ActionProposal; 14292 approval?: { 14293 by: string; 14294 at: string; 14295 /** Exactly what the approver was shown when they clicked. */ 14296 shown: { headline: string; why: string; after: unknown }; 14297 /** 14298 * For \`publish_post\`: who said yes on the tenant's side, when and where, recorded by 14299 * the operator who saw it (or b
14299y the owner themselves when they log in). Required 14300 * before Approve is accepted; part of the audit row. 14301 */ 14302 ownerYes?: OwnerYes; 14303 }; 14304 execution?: { 14305 startedAt: string; 14306 finishedAt?: string; 14307 steps: ImprovementExecutionStep[]; 14308 before: unknown; 14309 after?: unknown; 14310 error?: string; 14311 /** A tenant admin undid the applied change from the board: the same write in reverse, step by step. */ 14312 revert?: { startedAt: string; finishedAt?: string; by: string; reason: string; steps: ImprovementExecutionStep[]; error?: string }; 14313 }; 14314 /** 14315 * A development-mode action was done: either the nightly scan observed that 14316 * the finding no longer holds (\`observed\`, \`by: 'scan'\`) or a person marked it 14317 * done. Either way the row is \`applied\` and waits for its verify window like 14318 * an approved change. 14319 */ 14320 completion?: { at: string; by: string; observed: boolean; note?: string }; 14321 verification?: { 14322 /** When the engine scores the change; a tenant admin may bring it forward from the board. */ 14323 checkAfterRunAt: string; 14324 verdict?: 'improved' | 'no_change' | 'regressed'; 14325 deltas?: Record<string, { baseline: number; current: number }>; 14326 }; 14327 /** Deterministic text for development-mode actions, built at proposal time. */ 14328 developmentBrief?: string; 14329 /** 14330 * A tenant admin's standing instruction, recorded on a dismissed row: do not 14331 * raise this again. \`subject\` mutes every recommendation for the form or 14332 * workflow; \`subject_action\` mutes only this kind. The engine loads these per 14333 * tenant each night and neither asks the model about the subject nor mints a 14334 * proposal for it; the scan row still appears and shows the reason. Unmute 14335 * (dataApi) moves the row to \`expired\` so the next nightly may raise it again. 14336 */ 14337 suppress?: { 14338 permanent: boolean; 14339 scope: 'subject' | 'subject_action'; 14340 reason: string; 14341 by: string; 14342 at: string; 14343 }; 14344 changeLog: ChangeLogEntry[]; 14345} 14346 14347// --------------------------------------------------------------------------- 14348// Board rows (derived by the dataApi â never stored) 14349// --------------------------------------------------------------------------- 14350 14351export type ImprovementTimelineKind = 'scan' | 'change'; 14352 14353/** 14354 * One row on the /improvement board. Scans are flattened out of 14355 * \`improvement-runs.measured.subjects[]\`; changes out of \`improvement-actions.changeLog[]\`. 14356 */ 14357export interface ImprovementTimelineRow { 14358 id: string; 14359 at: string; 14360 kind: ImprovementTimelineKind; 14361 tenantId: string; 14362 checkId: string; 14363 subjectId: string; 14364 subjectKind: ImprovementSubjectKind; 14365 label: string; 14366 templateId?: string; 14367 basis: ImprovementBasis; 14368 /** The run this row belongs to, numbered per tenant à check from the first 14369 * stored run (1). Scans carry their own run; changes carry the run that was 14370 * in effect when the decision happened (the newest run at or before \`at\`). */ 14371 runNo?: number; 14372 // scan 14373 verdict?: ImprovementVerdict; 14374 metric?: ImprovementMetric; 14375 /** The same subject's metric on the previous scan, when its \`name\` matches â 14376 * the â²/â¼ on the board. Never compares two differently-named metrics. */ 14377 previousMetric?: ImprovementMetric; 14378 metrics?: Record<string, number>; 14379 /** The check's drawer payload â LATEST scan of a subject only, so a 90-day 14380 * timeline stays small. */ 14381 evidence?: unknown; 14382 /** The subject 7 and 30 days before this scan, as the engine stamped it. */ 14383 history?: MetricHistory; 14384 /** The model's accepted advice for this subject on this scan â \`inferred\`. 14385 * Present even when no action was minted (copy failed validation, say), 14386 * so a recommendation is never silently invisible. */ 14387 advice?: { headline: string; why: string; confidence: number }; 14388 /** Present on the LATEST scan of a subject that a tenant admin has muted. */ 14389 muted?: { 14390 actionId: string; 14391 scope: 'subject' | 'subject_action'; 14392 actionType: ChangeActionType; 14393 reason: string; 14394 by: string; 14395 at: string; 14396 }; 14397 /** Present on the LATEST scan of a subject when an open action exists. */ 14398 action?: { 14399 actionId: string; 14400 status: ImprovementActionStatus; 14401 executionMode: ExecutionMode; 14402 actionType: ChangeActionType; 14403 headline: string; 14404 overdue: boolean; 14405 }; 14406 // change 14407 actionId?: string; 14408 transition?: ImprovementActionStatus | 'approved' | 'completed'; 14409 by?: string; 14410 summary?: string; 14411 metricDelta?: { name: string; before: number; after: number }; 14412} 14413`,Ze=`/** 14414 * @bigm/types - Shared TypeScript types for BigM DynamoDB tables 14415 * 14416 * Exports types for Lead, EventCustomer, and EventEmail tables 14417 * based on Terraform schemas in terraform/SHARED/ 14418 */ 14419 14420// Lead types 14421export type { 14422 Lead, 14423 AppointmentOffer, 14424 LeadCategory, 14425 DncStatus, 14426 LockType, 14427 OwnershipReason, 14428 LeadGSIAttributes, 14429 CreateLeadInput, 14430 UpdateLeadInput, 14431 // Master Profile types 14432 PiiFieldSource, 14433 PiiArrayFieldSources, 14434 MasterProfileSources, 14435 NormalizedAddress, 14436 AddressVerification, 14437 AddressVerificationVerdict, 14438 AddressValidationGranularity, 14439 MasterProfile, 14440 // PII extraction types (call analysis) 14441 CallAnalysisSource, 14442 PiiSourcesTracking, 14443 OtherPiiItem, 14444 TextExtractionChannel, 14445 TextExtractionSource, 14446 // Ineligibility reasons (SME-reviewable PII extractions) 14447 IneligibilityCategory, 14448 IneligibilityStatus, 14449 IneligibilityReason, 14450 // Generic per-field SME review state (side-channel) 14451 FieldReviewState, 14452 // Health / pain context (massage-chair sales qualifier) 14453 HealthProfile, 14454 HouseholdMember, 14455 HouseholdRelation, 14456 HouseholdFact, 14457 // Contact preferences + sales context + demographics + lifestyle + consent (extraction targets) 14458 ContactPreferences, 14459 PreferredChannel, 14460 SalesContext, 14461 InterestLevel, 14462 BuyingFor, 14463 PurchaseTimeline, 14464 PrimaryHurdle, 14465 DecisionMaker, 14466 BuyingStage, 14467 Demographics, 14468 AgeBand, 14469 LifeStage, 14470 OccupationCategory, 14471 Lifestyle, 14472 LivingArrangement, 14473 ConsentFlags, 14474 // Finance profile (for Humm / Payright / Zip pre-fill) 14475 FinanceProfile, 14476 AuState, 14477 DriversLicence, 14478 MedicareCard, 14479 Passport, 14480 EmploymentStatus, 14481 PayFrequency, 14482 Employment, 14483 BankAccount, 14484 MaritalStatus, 14485 HousingStatus, 14486 ResidencyStatus, 14487 AddressHistoryEntry, 14488 // Finance Applications (denormalised per-application records) 14489 FinanceProvider, 14490 FinanceApplicationRecordType, 14491 FinanceApplicationStatus, 14492 FinanceApplication, 14493 // Lead Source Attribution types 14494 LeadSourceChannel, 14495 LeadSource, 14496 TriggerInfo, 14497 LastCampaignAttribution, 14498 FacebookAttribution, 14499 GoogleAttribution, 14500 AgentActionStatus, 14501 AgentActionType, 14502 AgentActionState, 14503 FacebookAdType, 14504 LeadSourceDisplay, 14505 TriggerTypeGroup, 14506 // Sales Cycle types 14507 SalesCycleStage, 14508 AiSaleStage, 14509 AiSaleLadder, 14510 AiSaleAssessment, 14511 LeadAiSale, 14512 BuyerReadiness, 14513 LeadSalesCycle,
14514 SalesCycleConfig, 14515 // Best contact times per lead (piece D) 14516 ContactTiming, 14517 ContactChannelTiming, 14518 // Conversation Disposition types 14519 DispositionStageCode, 14520 DispositionExitCode, 14521 ConversationDispositionCode, 14522 DispositionCodeInfo, 14523 ConversationDisposition, 14524 DispositionReviewStatus, 14525 DispositionReviewAction, 14526 DispositionReview, 14527 // Closed Lost types 14528 ClosedLostReason, 14529 ClosedLostReasonInfo, 14530} from './lead.js'; 14531 14532export { 14533 CHANNEL_INFO, AD_TYPE_INFO, SOURCE_DISPLAY_INFO, TRIGGER_TYPE_INFO, isDispositionOwnership, 14534 // Conversation Disposition registry & helpers 14535 DISPOSITION_CODE_INFO, DISPOSITION_CODE_MAP, getDispositionScore, 14536 // Closed Lost registry 14537 CLOSED_LOST_REASON_INFO, 14538} from './lead.js'; 14539 14540// Report-aggregates row types (report-aggregates DDB table + meta-ads-cache) 14541export type { 14542 AggregateBucket, 14543 AggregateRow, 14544 MetaAdsCacheRow, 14545} from './report-aggregates.js'; 14546 14547// Event Customer types 14548export type { 14549 EventCustomer, 14550 EventLeadLink, 14551 EventCustomerType, 14552 EventCustomerGSIAttributes, 14553 CreateEventCustomerInput, 14554 UpdateEventCustomerInput, 14555 CampaignAttribution, 14556 // EventData type map and utilities 14557 EventDataMap, 14558 EventDataForType, 14559 GenericEventData, 14560 // Event-specific data interfaces 14561 ShopifyAddress, 14562 ShopifyCustomerNested, 14563 ShopifyOrderEventData, 14564 DeliveryEventData, 14565 DraftOrderEventData, 14566 PersistedDraftOrderLineItem, 14567 SapOrderEventData, 14568 ShopifyCustomerEventData, 14569 EmailEventData, 14570 SmsEventData, 14571 CallEventData, 14572 VoicemailEventData, 14573 MessengerEventData, 14574 MessengerAttachment, 14575 ChatMessageEventData, 14576 FormEventData, 14577 CheckoutEventData, 14578 CampaignClickEventData, 14579 SocialPlatform, 14580 SocialPostFormat, 14581 SocialPostPublishedEventData, 14582 SocialClickEventData, 14583 // WebPixel event types (accurate nested payload structure) 14584 WebPixelDeviceContext, 14585 WebPixelCustomerData, 14586 WebPixelNestedPayload, 14587 WebPixelEventData, 14588 // Fan-form event types 14589 FanFormEventData, 14590 // Form detection and modal event types 14591 FormsDetectedData, 14592 ModalEventData, 14593 // Type-safe EventCustomer variants 14594 OrderConfirmedEvent, 14595 BackfillShopifyCustomerEvent, 14596 StorefrontEvent, 14597 CheckoutEvent, 14598 SmsEvent, 14599 EmailEvent, 14600 CallEvent, 14601 FormEvent, 14602 CampaignEvent, 14603 MessengerEvent, 14604 // Event type unions (for type guards) 14605 StorefrontEventType, 14606 CheckoutEventType, 14607 SmsEventType, 14608 EmailEventType, 14609 CallEventType, 14610 FormEventType, 14611 CampaignEventType, 14612 MessengerEventType, 14613 ChatMessageEventType, 14614 ShopifyOrderEventType, 14615 ShopifyCustomerEventType, 14616 DraftOrderEventType, 14617 LeadCreationEventType, 14618 EngagementEventType, 14619 LeadQuality, 14620 // Actor type for outbound events 14621 EventActor, 14622 // How an outbound SMS body was produced (model / deterministic / template) 14623 SmsGeneration, 14624 SmsGenerationKind, 14625 SmsGenerationSource, 14626 // Channel-agnostic aliases of the same provenance object (20 Sep 2026) 14627 Generation, 14628 GenerationKind, 14629 GenerationSource, 14630 // Call-now event data 14631 CallNowActionedEventData, 14632 // Agent review event data 14633 AgentReviewEventData, 14634 // Agent-authored note event data (agent_note + backfill_zoho_note) 14635 AgentNoteEventData, 14636 AiSaleStageChangedEventData, 14637 ZohoStageChangedEventData, 14638 // Zoho org full-record backfill event data (zoho_contact + zo
14638ho_lead) 14639 ZohoStructuredEventData, 14640 // Appointment lifecycle event data (appointment.created|updated|cancelled) 14641 AppointmentEventData, 14642 ShowroomEventData, 14643 // Appointment reminder event data (appointment.reminder_soon|reminder_day_before) 14644 AppointmentReminderEventData, 14645} from './event-customer.js'; 14646 14647export { 14648 EVENT_ACTORS, 14649 EVENT_CUSTOMER_TYPES, 14650 // Event type category arrays 14651 STOREFRONT_EVENT_TYPES, 14652 CHECKOUT_EVENT_TYPES, 14653 SMS_EVENT_TYPES, 14654 EMAIL_EVENT_TYPES, 14655 CALL_EVENT_TYPES, 14656 INTERACTION_EVENT_TYPES, 14657 isInteractionEventType, 14658 FORM_EVENT_TYPES, 14659 CAMPAIGN_EVENT_TYPES, 14660 SOCIAL_EVENT_TYPES, 14661 MESSENGER_EVENT_TYPES, 14662 CHAT_MESSAGE_EVENT_TYPES, 14663 SHOPIFY_ORDER_EVENT_TYPES, 14664 SHOPIFY_CUSTOMER_EVENT_TYPES, 14665 DRAFT_ORDER_EVENT_TYPES, 14666 LEAD_CREATION_EVENT_TYPES, 14667 LEAD_CREATION_EVENT_QUALITY, 14668 ENGAGEMENT_EVENT_TYPES, 14669 getLeadQuality, 14670 // Type guards 14671 isStorefrontEvent, 14672 isEngagementEvent, 14673 isCheckoutEvent, 14674 isSmsEvent, 14675 isEmailEvent, 14676 isCallEvent, 14677 isFormEvent, 14678 isCampaignEvent, 14679 isMessengerEvent, 14680 isChatMessageEvent, 14681 isShopifyOrderEvent, 14682 isShopifyCustomerEvent, 14683 isDraftOrderEvent, 14684 isLeadCreationEvent, 14685 // Device context helper functions 14686 getDeviceContext, 14687 getDeviceLabel, 14688} from './event-customer.js'; 14689 14690// Storefront Event types 14691export type { 14692 IdentityLevel, 14693 CustomerData, 14694 PageData, 14695 ProductData, 14696 AttributionData, 14697 ContextData, 14698 DeviceContext, 14699 DeviceType, 14700 OsFamily, 14701 IdentityData, 14702 IntentData, 14703 StorefrontEventPayload, 14704 StorefrontEventData, 14705 EngagementData, 14706 FrustrationData, 14707} from './event-customer.js'; 14708 14709// Event Email types 14710export type { 14711 EventEmail, 14712 EmailDirection, 14713 EmailStatus, 14714 EmailType, 14715 EmailOpenDetail, 14716 EventEmailGSIAttributes, 14717 CreateEventEmailInput, 14718 UpdateEventEmailInput, 14719} from './event-email.js'; 14720 14721// Reporting types 14722export type { 14723 ReportingRecord, 14724 ReportType, 14725 CreateReportingRecordInput, 14726} from './reporting.js'; 14727 14728export { 14729 generateReportId, 14730 generateReportTypeAndTenant, 14731 generateReportTypeAndShop, // Deprecated, use generateReportTypeAndTenant 14732 createReportingRecord, 14733} from './reporting.js'; 14734 14735// Tenant types 14736export type { 14737 CustomDomain, 14738 ToolParameter, 14739 ToolDefinition, 14740 TenantTools, 14741 SmsContactConfig, 14742 EmailContactConfig, 14743 ContactInfo, 14744 ProductSummaryMode, 14745 VideoCategory, 14746 AssetLink, 14747 AssetLinkCategory, 14748 TenantConfig, 14749 TenantRecord, 14750 // Lead categorisation (re-exported from tenant) 14751 EventMatchMode, 14752 PriorEventOperator, 14753 PriorEventEntry, 14754 EventCategoryRule, 14755 LeadCategorisationRules, 14756 TenantLeadRules, 14757 // Integration types 14758 IntegrationsConfig, 14759 StorageConfig, 14760 BillingConfig, 14761 BillingStatus, 14762 AutoTopUpConfig, 14763 LastTopUpInfo, 14764 TenantPricing, 14765 TokenPricing, 14766 InternalCostConfig, 14767 ShopifyIntegration, 14768 ShopifyOnboarding, 14769 ZohoIntegration, 14770 WickedIntegration, 14771 MaxIntegration, 14772 AircallIntegration, 14773 MetaIntegration, 14774 SocialPublishingConfig, 14775 OpenAIIntegration, 14776 TavilyIntegration, 14777 HummIntegration, 14778 GoogleAdsIntegration, 14779 KlaviyoIntegration, 14780 StripePaymentsIntegration, 14781 XeroIntegration, 14782 AnthropicIntegration, 14783 GoogleAIIntegration, 14784 SapIntegration, 14785 // Xero sales-accounting automation profile 14786 XeroAccountingProfile, 14787 XeroPostMode, 14788 XeroInvoiceMode, 14789 // Twilio integration types 14790 TwilioIntegration, 14791 TwilioPhoneNumber, 14792 // Missive shared-inbox integration (Happiness Team, 15 Sep 2026) 14793 MissiveIntegration, 14794 // App override types (Template + Override pattern) 14795 AppOverride, 14796 AppOverridesMap, 14797 // Feature gates on the tenant row 14798 TenantFeatures, 14799 TenantFeatureKey, 14800} from './tenant.js'; 14801 14802export { 14803 VALID_PRODUCT_SUMMARY_MODE, 14804 VALID_BILLING_STATUS, 14805 // Integration helper functions 14806 isIntegrationEnabled, 14807 getShopifyConfig, 14808 getZohoConfig, 14809 getOpenAIConfig, 14810 getTavilyConfig, 14811 getMetaConfig, 14812 getSocialPublishingConfig, 14813 getMaxConfig, 14814 getWickedConfig, 14815 getHummConfig, 14816 getTwilioConfig, 14817 getMissiveConfig, 14818 getPrimaryTwilioNumber, 14819 getMoonshotConfig, 14820 getAircallConfig, 14821 getGoogleAdsConfig, 14822 getKlaviyoConfig, 14823 getXeroConfig, 14824 getAnthropicConfig, 14825 getSapConfig, 14826 getFreightConfig, 14827} from './tenant.js'; 14828 14829// Freight provider domain types (multi-tenant carrier abstraction) 14830export type { 14831 FreightProviderKey, 14832 FreightAddress, 14833 FreightItem, 14834 FreightShipment, 14835 FreightQuote, 14836 FreightQuoteResult, 14837 FreightConsignmentResult, 14838 FreightConsignmentRef, 14839 FreightMilestoneStatus, 14840 FreightMilestone, 14841 FreightCapabilities, 14842 FreightProvider, 14843 NorthlineIntegration, 14844 WinningsFreightIntegration, 14845 FreightRoutingRule, 14846 FreightIntegrationsConfig, 14847 WinningsBookingDocType, 14848 WinningsBookingShipTo, 14849 WinningsBookingSheetLine, 14850 WinningsBookingServiceCharge, 14851 WinningsBookingSheet, 14852 BookingEligibility, 14853 BookingEligibilityReason, 14854} from './freight.js'; 14855 14856// One set of delivery-booking rules for the Book button, the server and the AI. 14857export type { 14858 DeliveryGateReason, DeliveryBlockReason, DeliveryHoldKind, 14859 DeliveryDateOutcome, DeliveryButtonGateInput, DeliveryDateOutcomeInput, 14860} from './delivery-booking.js'; 14861export { 14862 assessDeliveryButtonGate, assessDeliveryDate, deliveryHoldingLine, isOrderPaidForDelivery, 14863 DELIVERY_HOLDING_LINES, DEFAULT_DELIVERY_TEAM_NAME, 14864 REBOOKABLE_DELIVERY_STAGES, PAID_FINANCIAL_STATUSES, 14865} from './delivery-booking.js'; 14866 14867// SAP OData types 14868export type { 14869 SapFacilityCode, 14870 SapInventoryStock, 14871 SapSalesOrder, 14872 SapDeliveryCalendarRequest, 14873 SapBlockedDate, 14874 SapPurchaseOrderItem, 14875 SapPurchaseOrder, 14876 SapODataCollectionResponse, 14877 SapODataSingleResponse, 14878} from './sap.js'; 14879 14880export { 14881 SAP_FACILITY_LABELS, 14882} from './sap.js'; 14883 14884// Twilio types 14885export type { 14886 TwilioInboundSmsWebhook, 14887 TwilioOutboundSmsResponse, 14888 SendSmsRequest, 14889 // Twilio Management API types 14890 TwilioCountryCode, 14891 TwilioNumberType, 14892 TwilioNumberPricingKey, 14893 TwilioCapabilitiesFilter, 14894 CreateSubaccountRequest, 14895 CreateSubaccountResponse, 14896 UpdateSubaccountStatusRequest, 14897 SearchAvailableNumbersRequest, 14898 AvailablePhoneNumber, 14899 SearchAvailableNumbersResponse, 14900 ProvisionPhoneNumberRequest, 14901 ProvisionPhoneNumberResponse, 14902 ReleasePhoneNumberRequest, 14903 SetPrimaryPhoneNumberRequest, 14904 GetTwilioConfigRequest, 14905 GetTwilioConfigResponse, 14906 SetForwardingNumberRequest, 14907 SetForwardingNumberResponse, 14908} from './twilio.js'; 14909 14910// Message Placeholder types 14911export type { 14912 PlaceholderAvailability, 14913 PlaceholderDefinition, 14914 MessagePlaceholderValues, 14915} from './message-placeholders.js'; 14916 14917export { 14918 MESSAGE_PLACEHOLDER_DEFINITIONS, 14919 getPlaceholderDefinition, 14920 getPlaceholderKeys, 14921 getPlaceholdersByAvailability, 14922 DEFAULT_PLACEHOLDER_VALUES, 14923} from './message-placeholders.js'; 14924 14925// Placeholder utility functions 14926export { 14927 resolvePlaceholders, 14928 resolvePlaceholdersHtml, 14929 getDefaultValue, 14930 buildPlaceholderValues, 14931} from './placeholder-utils.js'; 14932 14933// Workflow types 14934export type { 14935 CampaignStep, 14936 CreateCallNowStep, 14937 CreateAiCallStep, 14938 ResolveAgentVideoStep, 14939 CheckVideoSentStep, 14940 EvaluateEligibilityStep, 14941 AssignAgentOwnershipStep, 14942 DistributeToPoolStep, 14943 DismissAndCloseLeadStep, 14944 MarkActionInProgressStep, 14945 GenerateAiReplyStep, 14946 RunAiWorkloadStep, 14947 ManageAppointmentStep, 14948 OfferAppointmentSlotsStep, 14949 SendResellerDetailsStep, 14950 SendResellerSmsStep, 14951 PostToXeroStep, 14952 SetZohoStageStep, 14953 RunServiceTurnStep, 14954 ResolveWelcomeContentStep, 14955 StepCondition, 14956 Workflow, 14957 ExecutionContext, 14958 ExecutionOptions, 14959 PreGeneratedMessages, 14960} from './workflow.js'; 14961 14962// AI agent registry â drives the \`run_ai_workload\` step's agent picker + dispatch 14963export type { AiAgent } from './ai-agents.js'; 14964export { AI_AGENTS, getAiAgent } from './ai-agents.js'; 14965 14966// AI Workload Run â typed view over \`expert-analysis-results\` rows produced by 14967// site-review and marketing-package agents (and the legacy expert-analysis kinds). 14968export type { 14969 AiWorkloadKind, 14970 AiWorkloadRun, 14971 AiWorkloadRunCommon, 14972 AiWorkloadRunStatus, 14973 SiteReviewInput, 14974 SiteReviewOutput, 14975 SiteReviewRecommendation, 14976 SiteReviewRun, 14977 MarketingPackageInput, 14978 MarketingPackageOutput, 14979 MarketingPackageRun, 14980 LegacyExpertAnalysisRun, 14981} from './ai-workload-run.js'; 14982export { 14983 buildAnalysisKey, 14984 isSiteReviewRun, 14985 isMarketingPackageRun, 14986 AGENT_ID_TO_WORKLOAD_KIND, 14987} from './ai-workload-run.js'; 14988 14989// Campaign Audience types (backbook campaigns) 14990export type { 14991 CampaignAudienceFilter, 14992 EventCohortFilter, 14993 AudiencePreviewResult, 14994 CampaignExecutionStatus, 14995 CampaignExecutionProgress, 14996 CampaignBatchExecuteRequest, 14997 CampaignExecutionHistoryRow, 14998 BackbookCampaignTemplate, 14999} from './campaign-audience.js'; 15000 15001// Cohorts â persisted, reusable audience definitions referenced by workflows 15002// via CampaignContext.cohortId (replaces inline audienceFilter). 15003export type { 15004 Cohort, 15005 CohortCategory, 15006 CreateCohortInput,
15007 UpdateCohortInput, 15008} from './cohorts.js'; 15009 15010export { 15011 VALID_COHORT_CATEGORIES, 15012 generateCohortId, 15013 computeFilterHash, 15014 validateCohort, 15015} from './cohorts.js'; 15016 15017// Campaign send rows (campaign-sends table â completed sends + in-flight 15018// engine claims; see the CampaignSend doc for the two row shapes) 15019export type { 15020 CampaignSend, 15021 CreateCampaignSendInput, 15022 UpdateCampaignSendInput, 15023 CampaignSendGSIAttributes, 15024} from './campaign-send.js'; 15025 15026// Campaign Context types 15027export type { 15028 LinkType, 15029 CampaignLink, 15030 DeriveLinkTypeInput, 15031 LandingPage, 15032 SmsMessage, 15033 EmailMessage, 15034 SiteMessage, 15035 SiteModalType, 15036 SiteModalOnSubmitAction, 15037 CallNowMessage, 15038 CampaignMessages, 15039 DiscountApplicableTo, 15040 CustomerEligibility, 15041 DiscountCategory, 15042 CodeDeliveryMethod, 15043 DraftOrderLevel, 15044 DiscountValueSource, 15045 LineItemDiscount, 15046 FreeItemType, 15047 FreeItemConfig, 15048 DraftOrderConfig, 15049 CampaignDiscount, 15050 CampaignShipping, 15051 FreeDiscountConfig, 15052 WarrantyConfig, 15053 ConciergeConfig, 15054 OfferContext, 15055 OfferExplanation, 15056 CampaignChannel, 15057 ChannelMode, 15058 CampaignType, 15059 CampaignSchedule, 15060 EligibilityCheckType, 15061 ChannelMatchMode, 15062 TriggerCheck, 15063 RequireLeadIdCheck, 15064 ExcludeDraftOrderSourceCheck, 15065 CheckAlreadySentCheck, 15066 CheckDelayCheck, 15067 CheckRecoveryCheck, 15068 CheckAnyCampaignSentCheck, 15069 CheckNoOutboundCallCheck, 15070 CheckNoOutboundSmsCheck, 15071 CheckNoPriorOrderCheck, 15072 CheckNoSuccessfulContactCheck, 15073 CheckNoRecentEventCheck, 15074 CheckNoAgentEngagementCheck, 15075 CheckFirstLeadSourceCheck, 15076 CheckLastLeadSourceCheck, 15077 CheckFirstEventTypeCheck, 15078 CheckFormIdCheck, 15079 CheckMaxListIdCheck, 15080 CheckMaxDispositionCheck, 15081 CheckLastOutboundSmsCampaignCheck, 15082 EligibilityCheck, 15083 ChannelEligibilityConfig, 15084 EligibilityConfig, 15085 CampaignTrigger, 15086 CampaignScheduleTrigger, 15087 CampaignContext, 15088 CampaignVideoConfig, 15089 VideoMode, 15090 CreateCampaignContextInput, 15091 UpdateCampaignContextInput, 15092 SiteEligibleTrigger, 15093} from './campaign-context.js'; 15094 15095export { 15096 deriveLinkType, 15097 CAMPAIGN_MANDATORY_FIELDS, 15098 CAMPAIGN_MANDATORY_FIELD_PATHS, 15099 isCampaignFieldMandatory, 15100 SITE_ELIGIBLE_TRIGGERS, 15101 isSiteEligibleTrigger, 15102} from './campaign-context.js'; 15103 15104// Campaign Approval types 15105export type { 15106 CampaignApprovalStatus, 15107 EligibilityReasonCode, 15108 ApprovalCartLineItem, 15109 ApprovalCart, 15110 ApprovalDiscount, 15111 ApprovalDraftOrder, 15112 ApprovalCustomer, 15113 ApprovalMessages, 15114 ApprovalSmsMessage, 15115 ApprovalEmailMessage, 15116 CampaignApproval, 15117 CreateCampaignApprovalInput, 15118 UpdateCampaignApprovalInput, 15119 CampaignApprovalGSIAttributes, 15120} from './campaign-approval.js'; 15121 15122// Backfill Checkpoint types 15123export type { 15124 BackfillProvider, 15125 BackfillDataType, 15126 BackfillStatus, 15127 BackfillCheckpoint, 15128 BackfillCheckpointInput, 15129 BackfillCheckpointKey, 15130} from './backfill-checkpoint.js'; 15131 15132export { 15133 buildCheckpointPk, 15134 buildCheckpointSk, 15135 parseCheckpointPk, 15136} from './backfill-checkpoint.js'; 15137 15138// Global Pricing types 15139export type { 15140 GlobalPricing, 15141 TokenPricing as GlobalTokenPricing, 15142 // Shared pricing interfaces 15143 DirectionalPricing, 15144 PhoneNumberPricing, 15145 ComputePricingConfig, 15146 // Cost item types (what we pay) 15147 TwilioSmsPricing, 15148 TwilioVoicePricing, 15149 TwilioPhoneNumberPricing, 15150 ComputePricing, 15151 OpenAIPricing, 15152 MoonshotPricing, 15153 TavilyPricing, 15154 EmailPricing, 15155 // Customer price item types (what we charge) 15156 TwilioSmsPricingPrice, 15157 TwilioVoicePricingPrice, 15158 TwilioPhoneNumberPricingPrice, 15159 ComputePricingPrice, 15160 OpenAIPricingPrice, 15161 MoonshotPricingPrice, 15162 TavilyPricingPrice, 15163 EmailPricingPrice, 15164 PricingItem, 15165} from './pricing.js'; 15166 15167// App Config types (v2.0 Unified Config) 15168export type { 15169 // Schema 15170 SchemaVersion, 15171 // App Types 15172 AppType, 15173 // Tool types 15174 AppToolParameter, 15175 AppToolDefinition, 15176 // Display / Persona 15177 AppDisplayInfo, 15178 // Common config types 15179 LlmConfig, 15180 PromptsConfig, 15181 KnowledgeConfig, 15182 AppToolsConfig, 15183 MediaConfig, 15184 // Metadata 15185 MetadataCategory, 15186 MetadataCriticality, 15187 MetadataChannel, 15188 AppMetadata, 15189 // Extension types 15190 AnalysisRunMode, 15191 AnalysisExtension, 15192 AgentExtension, 15193 ExpertAnalysisExtension, 15194 AppExtensions, 15195 // Main unified config type 15196 UnifiedAppConfig, 15197 // App type aliases 15198 AiCallAnalysisConfig, 15199 ConversationalAgentConfig, 15200 MarketingExecutorConfig, 15201 TransactionalExecutorConfig, 15202 ExpertAnalysisConfig, 15203 // Resolved config (with runtime fields) 15204 ResolvedAppConfig, 15205 // Legacy types (deprecated, for backward compatibility) 15206 AnalysisTimeouts, 15207 AnalysisConfig, 15208 AppConfigBase, 15209 AiCallAnalysisAppConfig, 15210 ConversationalAgentAppConfig, 15211 ExecutorAppConfig, 15212 AppConfig, 15213 AppConfigRecord, 15214} from './app-config.js'; 15215 15216export { 15217 CURRENT_SCHEMA_VERSION, 15218 VALID_APP_TYPES, 15219 DEFAULT_APP_PERSONAS, 15220 VALID_ANALYSIS_RUN_MODES, 15221 VALID_METADATA_CATEGORIES, 15222 VALID_METADATA_CRITICALITIES, 15223 VALID_METADATA_CHANNELS, 15224 isUnifiedConfig, 15225 isAiCallAnalysisConfig, 15226 isExecutorConfig, 15227 isConversationalConfig, 15228 hasAnalysisExtension, 15229 hasAgentExtension, 15230 isLeadAnalysisConfig, 15231 hasLeadAnalysisExtension, 15232 isExpertAnalysisConfig, 15233 hasExpertAnalysisExtension, 15234} from './app-config.js'; 15235 15236// Template Generator types 15237export type { 15238 TemplateTone, 15239 TemplateLength, 15240 TemplateChannelType, 15241 GenerateTemplateRequest, 15242 GeneratedTemplateContent, 15243 TemplateGenerationTokenUsage, 15244 GenerateTemplateResponse, 15245 TemplateVariableInfo, 15246 ModifyTemplateRequest, 15247 ModifyTemplateResponse, 15248} from './template-generator.js'; 15249 15250export { 15251 VALID_TEMPLATE_TONES, 15252 VALID_TEMPLATE_LENGTHS, 15253 VALID_TEMPLATE_CHANNELS, 15254 CHECKOUT_CAMPAIGN_TYPES, 15255 getAvailableVariables, 15256 isValidTemplateTone, 15257 isValidTemplateLength, 15258 isValidTemplateChannel, 15259 isModifyTemplateRequest, 15260} from './template-generator.js'; 15261 15262// Cart Permalink URL generation 15263export type { 15264 CartPermalinkProduct, 15265 CartPermalinkOptions,
15266 CartPermalinkResult, 15267} from './cart-permalink.js'; 15268 15269export { 15270 extractVariantId, 15271 normalizeShopDomain, 15272 generateCartPermalinkUrl, 15273} from './cart-permalink.js'; 15274 15275// Cart Attributes - TYPE-SAFE attribute keys using branded types 15276// CRITICAL: Use these constants and helpers - raw strings WON'T COMPILE 15277export type { 15278 CartAttrKey, 15279 CartAttrParam, 15280 CartAttributes, 15281} from './cart-attributes.js'; 15282 15283export { 15284 // Canonical attribute keys (branded types) 15285 CART_ATTR_CAMPAIGN_ID, 15286 CART_ATTR_LEAD_ID, 15287 CART_ATTR_LEAD_ID_LEGACY, 15288 CART_ATTR_CLICK_ID, 15289 CART_ATTR_PHONE, 15290 CART_ATTR_EMAIL, 15291 CART_ATTR_DRAFT_ORDER, 15292 // Pre-built URL parameter format: attributes[key] 15293 CART_ATTR_PARAMS, 15294 // Type-safe helper functions 15295 setCartAttrOnUrl, 15296 setCartAttrsOnUrl, 15297 buildCartAttrQueryParams, 15298 getLeadIdFromCartAttrs, 15299 getCampaignIdFromCartAttrs, 15300} from './cart-attributes.js'; 15301 15302// Draft Order Preview - shared extraction for cart/draft order display 15303export type { 15304 DraftOrderLineItem, 15305 DraftOrderPricing, 15306 DraftOrderCustomer, 15307 DraftOrderInfo, 15308 DraftOrderPreview, 15309 ExtractDraftOrderPreviewOptions, 15310} from './draft-order-preview.js'; 15311 15312export { 15313 extractDraftOrderPreview, 15314 extractDraftOrderPreviewFromDraftOrder, 15315 hasCartData, 15316 hasDraftOrderData, 15317 getCartTotalFormatted, 15318 getDraftOrderTotalFormatted, 15319} from './draft-order-preview.js'; 15320 15321// Draft Order Builder - shared logic for draft order creation 15322export type { 15323 AppliedDiscount, 15324 ShopifyDraftOrderLineItem, 15325 FreeItemAction, 15326 EffectiveDiscountResult, 15327 CartLineItem, 15328} from './draft-order-builder.js'; 15329 15330export { 15331 // Note: extractVariantId is already exported from cart-permalink.ts above 15332 // Eligibility checking 15333 isShopifyDiscountCode, 15334 isVariantEligible, 15335 isFreeItemVariant, 15336 // Discount calculation 15337 findLineItemDiscountConfig, 15338 calculateCheckoutDiscountPercent, 15339 calculateEffectiveDiscount, 15340 // Free items logic 15341 findCartItemByVariant, 15342 getCartItemPrice, 15343 determineFreeItemAction, 15344 // Line item builders 15345 buildAppliedDiscount, 15346 buildDraftOrderLineItem, 15347 buildFreeItemLineItem, 15348} from './draft-order-builder.js'; 15349 15350// Meta Ad Config types (per-ad popup/modal configurations) 15351export type { 15352 MetaAdConfigSk, 15353 MetaAdMetadata, 15354 MetaAdConfig, 15355 MetaDiscountConfigResponse, 15356 MetaDiscountSmsRequest, 15357 MetaDiscountSmsResponse, 15358 SyncMetaAdsInput, 15359 SyncMetaAdResult, 15360 SyncMetaAdsResponse, 15361 MetaAdConfigListItem, 15362 MetaAdConfigInput, 15363} from './meta-ad-config.js'; 15364 15365export { 15366 parseAdIdFromSk, 15367 buildSkFromAdId, 15368 isDefaultConfig, 15369 isAdSpecificConfig, 15370} from './meta-ad-config.js'; 15371 15372// USA Project Tracker types 15373export type { 15374 ProjectTaskStatus, 15375 ProjectTaskPriority, 15376 ProjectWorkType, 15377 SourceArtifactType, 15378 SourceArtifact, 15379 UsaProjectTask, 15380 CreateUsaProjectTaskInput, 15381 UpdateUsaProjectTaskInput, 15382 KeyDecision, 15383 RiskLevel, 15384 RiskItem, 15385 MetricTimeframe, 15386 SuccessMetric, 15387 UsaProjectStrategy, 15388} from './usa-project-tracker.js'; 15389 15390export { 15391 USA_PROJECT_ID, 15392 VALID_PROJECT_TASK_STATUS, 15393 VALID_PROJECT_TASK_PRIORITY, 15394 VALID_SOURCE_ARTIFACT_TYPES, 15395 buildProjectTaskSk, 15396 parseProjectTaskSk, 15397 isValidProjectTaskStatus, 15398 isValidProjectTaskPriority, 15399} from './usa-project-tracker.js'; 15400 15401// Revenue Ideas types (tenant-scoped revenue growth strategies) 15402export type { 15403 RevenueIdeaStatus, 15404 RevenueIdeaPriority, 15405 RevenueIdeaImpact, 15406 RevenueCategory, 15407 RevenueIdea, 15408 CreateRevenueIdeaInput, 15409 UpdateRevenueIdeaInput, 15410} from './revenue-ideas.js'; 15411 15412export { 15413 VALID_REVENUE_IDEA_STATUS, 15414 VALID_REVENUE_IDEA_PRIORITY, 15415 VALID_REVENUE_IDEA_IMPACT, 15416 isValidRevenueIdeaStatus, 15417 isValidRevenueIdeaPriority, 15418 isValidRevenueIdeaImpact, 15419} from './revenue-ideas.js'; 15420 15421// Knowledge System types (AI knowledge management) 15422export type { 15423 KnowledgeCategory, 15424 KnowledgeSourceType, 15425 KnowledgeFile, 15426 KnowledgeManifest, 15427 KnowledgeBucket, 15428 KnowledgeSourceDisplayType, 15429 KnowledgeSource, 15430} from './knowledge.js'; 15431 15432export { 15433 VALID_KNOWLEDGE_CATEGORIES, 15434 DEFAULT_CATEGORY_SUBSCRIPTIONS, 15435 isValidKnowledgeCategory, 15436} from './knowledge.js'; 15437 15438// Discount Rules types (tenant-wide reusable discount configurations) 15439export type { 15440 DiscountRule, 15441 CreateDiscountRuleInput, 15442 UpdateDiscountRuleInput, 15443 DiscountRuleReference, 15444 ListDiscountRulesParams, 15445 ListDiscountRulesResponse, 15446} from './discount-rules.js'; 15447 15448export { 15449 generateRuleId, 15450 validateDiscountRule, 15451} from './discount-rules.js'; 15452 15453// Partner Referral types 15454export type { 15455 Partner, 15456 PartnerType, 15457 PartnerStatus, 15458 CreatePartnerInput, 15459 UpdatePartnerInput, 15460 PartnerOffer, 15461 PartnerOfferStatus, 15462 CreatePartnerOfferInput, 15463 UpdatePartnerOfferInput, 15464} from './partners.js'; 15465 15466export { 15467 generatePartnerId, 15468 generateOfferId, 15469 generatePartnerSlug, 15470 buildPartnerDestinationUrl, 15471 validatePartner, 15472 validatePartnerOffer, 15473} from './partners.js'; 15474 15475// Promotional Offer types (centralized offer creation service) 15476export type { 15477 LineItemSourceType, 15478 ResolvedLineItem, 15479 LineItemSourceConfig, 15480 CreatePromotionalOfferInput, 15481 PromotionalOfferType, 15482 DraftOrderResult, 15483 PromotionalOfferCartPermalink, 15484 FreeItemResult, 15485 DiscountSummary, 15486 PromotionalOfferResult, 15487 CheckoutEventData as PromotionalOfferCheckoutEventData, 15488 ProductViewedEventData, 15489} from './promotional-offer.js'; 15490 15491export { 15492 isCheckoutEventData, 15493 isProductViewedEventData, 15494} from './promotional-offer.js'; 15495 15496// Meta Asset Metadata types 15497export type { 15498 AssetCampaignAssociation, 15499 AssetPerformanceMetrics, 15500 AssetPerformanceTier, 15501 MetaAssetType, 15502 MetaAssetMetadata, 15503} from './meta-asset-metadata.js'; 15504 15505export { 15506 buildMetaAssetPk, 15507 buildMetaAssetSk, 15508 getPerformanceTier, 15509 defaultAssetMetrics, 15510} from './meta-asset-metadata.js'; 15511 15512// Media Asset Registry types (unified media-assets table) 15513export type { 15514 MediaAssetType, 15515 MediaAssetStatus, 15516 MediaAssetRole, 15517 MediaChannel, 15518 MediaSourceKind, 15519 MediaSource, 15520 MediaStorage, 15521 MediaDims, 15522 MediaDerivativeKind, 15523 MediaDerivative, 15524 MediaRecording, 15525 MediaAppliesTo, 15526 MediaTestimonial, 15527 MediaAward, 15528 MediaGenerated, 15529 MediaAiEnrichment, 15530 MediaCompliance, 15531 MediaUsageChannel, 15532 MediaUsage, 15533 MediaAsset, 15534} from './media-asset.js'; 15535 15536export { 15537 BRAND_LEVEL_PRODUCT_SENTINEL, 15538 buildMediaResolverGsi1Pk, 15539 buildMediaResolverGsi1Sk, 15540 buildMediaBrowseGsi2Pk, 15541 buildMediaBrowseGsi2Sk, 15542 buildMediaSyncGsi3Pk, 15543 buildMediaSyncGsi3Sk, 15544 applyMediaGsiKeys, 15545 contentHashAssetId, 15546 composedAssetId, 15547} from './media-asset.js'; 15548 15549// Agent Alarm types (configurable agent effort monitoring) 15550export type { 15551 AgentAlarmMetric, 15552 AgentAlarmOperator, 15553 AgentAlarmRule, 15554 CreateAgentAlarmRuleInput, 15555 UpdateAgentAlarmRuleInput, 15556 AgentAlarmEvaluationResult, 15557 AlarmHistoryRecord, 15558} from './agent-alarm.js'; 15559 15560export {
15561 ALARM_METRIC_LABELS, 15562 ALARM_METRIC_DESCRIPTIONS, 15563 isTimeBasedMetric, 15564 isParameterizedMetric, 15565 isMultiParamMetric, 15566} from './agent-alarm.js'; 15567 15568// Sales Order types 15569export type { 15570 SalesOrder, 15571 SalesChannel, 15572 SalesProductType, 15573} from './sales-order.js'; 15574 15575// MaxContact Result Code types 15576export type { 15577 MaxResultCode, 15578 ResultCodeCategory, 15579 DispositionSentiment, 15580 DispositionSalesCycleResult, 15581 OwnershipClearCode, 15582 BcAutoRemoveCode, 15583 BcEntryCode, 15584} from './max-result-codes.js'; 15585 15586export { 15587 MAX_RESULT_CODES, 15588 getMaxResultCodeInfo, 15589 isAgentOwnershipCode, 15590 BC_ENTRY_CODES, 15591 isBcEntryCode, 15592 isSaleCode, 15593 isLockDispositionCode, 15594 BC_AUTO_REMOVE_CODES, 15595 isBcAutoRemoveCode, 15596 OWNERSHIP_CLEAR_CODES, 15597 clearsAgentOwnership, 15598 isAgentDispositionCode, 15599 isSuccessCode, 15600 getResultCodeCategory, 15601 getDispositionSentiment, 15602 DISPOSITION_SENTIMENT_CONFIG, 15603 REVIEWABLE_DISPOSITION_CODES, 15604 isReviewableDispositionCode, 15605 ANSWERING_MACHINE_CODES, 15606 isAnsweringMachineCode, 15607 CONNECTED_CONVERSATION_CODES, 15608 isConnectedConversationCode, 15609 dispositionToSalesCycle, 15610} from './max-result-codes.js'; 15611 15612// Chat types (site chat / digital clienteling) 15613export type { 15614 ChatSessionStatus, 15615 ChatSessionMetadata, 15616 ChatSession, 15617 ChatMessageSender, 15618 ChatMessageType, 15619 ChatQuickReplyOption, 15620 ChatMessage, 15621 AgentQuickReply, 15622 ChatBusinessHours, 15623 SiteChatConfig, 15624} from './chat.js'; 15625 15626// MaxContact Agent Status types 15627export type { 15628 MaxAgentStatus, 15629 MaxAgentStatusCode, 15630 AgentUnavailableReason, 15631 AgentDistributionStatus, 15632 AgentDistributionInput, 15633} from './max-agent-status.js'; 15634 15635export { 15636 MAX_AGENT_STATUSES, 15637 AGENT_STATUS_CODES, 15638 getAgentStatusInfo, 15639 getStatusBadgeClass, 15640 getStatusBarColor, 15641 isProductiveStatus, 15642 isAgentAvailableForDistribution, 15643 getAgentDistributionStatus, 15644} from './max-agent-status.js'; 15645 15646// Agent Distribution types 15647export type { 15648 DistributionStrategy, 15649 AgentPool, 15650 AgentRole, 15651 DistributionConfig, 15652 DistributionPoolState, 15653 DistributionState, 15654} from './agent-distribution.js'; 15655 15656// Alarm Topic types (generalized alarm monitoring) 15657export type { 15658 AlarmTopicId, 15659 AlarmSeverity, 15660 ThresholdOperator, 15661 AlarmTopicConfig, 15662} from './alarm-topic.js'; 15663 15664export { DEFAULT_ALARM_TOPIC_CONFIGS } from './alarm-topic.js'; 15665 15666// Lead Prioritisation types (sequence-aware intent scoring) 15667export type { 15668 IntentSignalType, 15669 IntentStage, 15670 IntentTrailEntry, 15671 PriorityTier, 15672 SupersededAction, 15673 ScoreBreakdown, 15674 SequenceBoost, 15675 LeadPrioritisationConfig, 15676} from './lead-prioritisation.js'; 15677 15678export { 15679 EVENT_TO_SIGNAL_MAP, 15680 SIGNAL_TO_STAGE, 15681 SIGNAL_CATEGORIES, 15682 DEFAULT_LEAD_PRIORITISATION_CONFIG, 15683 computePriorityScore, 15684 appendToIntentTrail, 15685 trimIntentTrail, 15686} from './lead-prioritisation.js'; 15687 15688// Sales Attribution â metafield-stamping utility types (stampSalesAttribution in @bigm/shared) 15689export type { 15690 SalesAttributionMetafieldKey, 15691 SalesAttributionOwnerType, 15692 SalesAttributionSkip, 15693 StampSalesAttributionResult, 15694} from './sales-attribution.js'; 15695 15696// Training Example types (SMS AI training corpus builder + eval/shadow pipelines) 15697export type { 15698 TrainingChannel, 15699 GoldnessScore, 15700 ReviewStatus, 15701 TrainingOutcome, 15702 TrainingActor, 15703 SmsConversationTurn, 15704 TrainingProvenance, 15705 TrainingExample, 15706 CreateTrainingExampleInput, 15707 PromptEvalJudgeScores, 15708 PromptEvalVerdict, 15709 PromptEvalHumanVerdict, 15710 PromptEvalRecord, 15711 CreatePromptEvalInput, 15712 ShadowComparisonRecord, 15713 CreateShadowComparisonInput, 15714} from './training-example.js'; 15715 15716export { 15717 buildTrainingExamplePk, 15718 buildTrainingExampleSk, 15719 buildShadowComparisonSk, 15720} from './training-example.js'; 15721 15722// PII Feedback (SME review labels for PII extraction prompt eval loop) 15723export type { 15724 PiiReviewAction, 15725 PiiFeedbackRow, 15726 CreatePiiFeedbackInput, 15727 PiiEvalMetrics, 15728} from './pii-feedback.js'; 15729 15730export type { PromptId as PiiPromptId } from './pii-prompt-ids.js'; 15731 15732// AI turn feedback (expert verdicts on appointment-AI SMS turns, /ai-agent page) 15733export type { 15734 AiTurnFeedbackVerdict, 15735 AiTurnFeedbackReason, 15736 AiFeedbackKind, 15737 AiTurnFeedbackRow, 15738 CreateAiTurnFeedbackInput, 15739 AiTurnFeedbackSummary, 15740} from './ai-turn-feedback.js'; 15741export { 15742 AI_TURN_FEEDBACK_REASONS, AI_BLIND_SPOT_REASONS, AI_BLIND_SPOT_PROVIDER, 15743 AI_TURN_FEEDBACK_REASON_LABELS, 15744 AI_AGENT_EXTRA_REVIEWERS, 15745 canViewAiAgentPage, 15746 NBA_FEEDBACK_REASONS, 15747 SERVICE_FEEDBACK_REASONS, 15748 ALL_AI_FEEDBACK_REASONS, 15749 AI_TURN_FEEDBACK_NOTE_MAX, 15750 AI_TURN_FEEDBACK_REWRITE_MAX, 15751} from './ai-turn-feedback.js'; 15752 15753// Expert Analysis Lifecycle (CEO Dashboard "Change" entity) 15754export type { 15755 // Universal recommendation primitives (Phase 1 v2 schema) 15756 CitationSource, 15757 Citation, 15758 Evidence, 15759 TargetEntityKind, 15760 TargetEntity, 15761 RecommendationMetric, 15762 Guardrail, 15763 MetricWindow, 15764 RecommendationKind, 15765 // Lifecycle status 15766 ChangeStatus, 15767 ClosedReason, 15768 ChangeTrigger, 15769 ChangeActionType, 15770 ChannelType as ExpertAnalysisChannelType, 15771 SmsTemplateSnapshot, 15772 EmailTemplateSnapshot, 15773 SiteTemplateSnapshot, 15774 TemplateSnapshot, 15775 WorkflowConfigSnapshot, 15776 ProposedEntityDiff, 15777 ProposedChange, 15778 RecommendationSnapshot, 15779 SmsBaseline, 15780 EmailBaseline, 15781 WorkflowConfigBaseline, 15782 MetricsBaseline, 15783 SmsSuccessThreshold, 15784 EmailSuccessThreshold, 15785 WorkflowSuccessThreshold, 15786 SuccessThreshold, 15787 MonitoringVerdict, 15788 MetricsDelta, 15789 MonitoringCheck, 15790 MonitoringState, 15791 ChangeLogActor, 15792 ChangeLogAction, 15793 ChangeLogEntry, 15794 Change, 15795 ProposeChangeInput, 15796 CloseChangeInput, 15797} from './expertAnalysis.js'; 15798 15799// Continuous-improvement board â run envelope, subjects, the decision queue row 15800// and the derived timeline row. Action vocabulary reuses expertAnalysis above. 15801export type { 15802 ImprovementBasis, 15803 ImprovementSubjectKind, 15804 ImprovementMetric, 15805 ImprovementVerdict, 15806 CheckSubject, 15807 CheckMeasurement, 15808 ImprovementAdviceItem, 15809 ImprovementAdviceResult, 15810 ImprovementRun, 15811 ImprovementActionStatus, 15812 ExecutionMode, 15813 ActionProposal, 15814 ImprovementExecutionStep, 15815 ImprovementAction, 15816 OwnerYes, 15817 OwnerTask, 15818 ProposalEdit, 15819 ImprovementTimelineKind, 15820 ImprovementTimelineRow, 15821} from './improvement.js'; 15822export { 15823 EXECUTION_MODE_BY_ACTION, 15824 executionModeFor, 15825 IMPROVEMENT_CHECK_IDS, 15826 IMPROVEMENT_CHECK_LABELS, 15827} from './improvement.js'; 15828export type { ImprovementCheckId } from './improvement.js'; 15829 15830// Everyday Assist (EDA) consumer-app domain types 15831export type { 15832 EdaAccountBilling, 15833 EdaAccountBillingStatus, 15834 EdaBillingEvent, 15835 EdaBillingEventType,
15836 EdaCarePlan, 15837 EdaCarePlanNotifications, 15838 EdaCallSchedule, 15839 EdaScheduleDay, 15840 EdaPatientRelationship, 15841 EdaPricing, 15842 LeadToRelative, 15843 CreateEdaCarePlanInput, 15844 UpdateEdaCarePlanInput, 15845 CreateEdaAccountBillingInput, 15846 CreateEdaBillingEventInput, 15847} from './eda.js'; 15848 15849export { 15850 EDA_DEFAULT_RATE_PER_MINUTE_CENTS, 15851 EDA_DEFAULT_CURRENCY, 15852 EDA_PRICING_ID, 15853 EDA_DEFAULT_AGENT_ID, 15854 EDA_TENANT_NAME, 15855} from './eda.js'; 15856 15857// Business Events â system-signal substrate 15858export type { 15859 BusinessEvent, 15860 BusinessEventSource, 15861 BusinessEventSignalKind, 15862 BusinessEventSeverity, 15863 SubjectKind, 15864 TenantRole, 15865 BusinessEventAckStatus, 15866 BusinessEventMetric, 15867 BusinessEventRef, 15868 BusinessEventSummary, 15869 BusinessWorkflowRule, 15870 BusinessWorkflowStep, 15871 BusinessWorkflowTrigger, 15872 PushTarget, 15873 PushTargetRole, 15874 PushTargetSpecificUsers, 15875 UserSelection, 15876 PushPayload, 15877 PushNotificationStep, 15878 LogStep, 15879 CreateBusinessEventInput, 15880 CreateBusinessWorkflowRuleInput, 15881 UpdateBusinessWorkflowRuleInput, 15882} from './business-event.js'; 15883 15884export { 15885 isPushTargetRole, 15886 isPushTargetSpecificUsers, 15887 isPushNotificationStep, 15888 isLogStep, 15889 isChangeLifecycleSignal, 15890 isCloudWatchSignal, 15891 PLATFORM_TENANT_ID, 15892 BUSINESS_EVENT_ACTOR, 15893 BUSINESS_EVENT_TTL_SECONDS, 15894 BUSINESS_EVENT_SEVERITIES, 15895 TENANT_ROLES, 15896 BUSINESS_EVENT_SOURCES, 15897} from './business-event.js'; 15898 15899// Operator Change Log types (timeline entries on /dashboard/change-log). 15900// Disambiguated from expertAnalysis.ChangeLogEntry above. 15901export type { 15902 OperatorChangeLogEntry, 15903 CreateOperatorChangeLogEntryInput, 15904} from './change-log.js'; 15905export { 15906 CHANGE_LOG_PLATFORM_TENANT, 15907} from './change-log.js'; 15908 15909// MMS card template manifest â single source of truth across the workflow 15910// builder dropdown, the /templates catalog tab, and the renderer Lambda 15911// dispatch. See ./mms-card-templates.ts docblock for the variant-add procedure. 15912export { 15913 MMS_CARD_TEMPLATES, 15914 getMmsCardTemplate, 15915 type MmsCardTemplateDef, 15916 type MmsCardTemplateId, 15917 type MmsCardRequirement, 15918 type MmsCardContent, 15919} from './mms-card-templates.js'; 15920 15921// Per-tenant brand profile (design tokens + voice + taxonomy). Stored in S3 as 15922// markdown w/ YAML frontmatter; this is the dependency-free type contract. 15923export type { 15924 BrandProfile, 15925 BrandPalette, 15926 BrandFonts, 15927 BrandTaxonomy, 15928 BrandExtractionConfidence, 15929} from './brand-profile.js'; 15930 15931// Appointment types (SMS-booked consultations; \`appointments\` DDB table) 15932export type { 15933 Appointment, 15934 AppointmentCreatedBy, 15935 CreateAppointmentInput, 15936 UpdateAppointmentInput, 15937 AppointmentTimeValidation, 15938 DayHours, 15939 BusinessHours, 15940 AppointmentHoursConfig, 15941 ResellerDurationSource, 15942 SlotCapacityBand, 15943 AppointmentCapacityConfig, 15944 AppointmentMode, 15945 AppointmentReminderKind, 15946 AppointmentRemindersSent, 15947 ShowroomConfig, 15948 SlotOccupancyAppointment, 15949} from './appointment.js'; 15950 15951export { 15952 APPOINTMENT_TZ, 15953 APPOINTMENT_SLOT_MINUTES, 15954 AU_STATE_TIMEZONES, 15955 auStateToTimezone, 15956 generateAppointmentId, 15957 melbourneFields, 15958 zonedWeekdayMinutes, 15959 validateAppointmentStart, 15960 computeAppointmentEndUtc, 15961 computeEarliestBookableSlot, 15962 snapToValidSlot, 15963 generateValidSlots, 15964 selectSpreadSlots, 15965 DEFAULT_BUSINESS_HOURS, 15966 businessHoursFromConfig, 15967 capacityForSlotStart, 15968 DEFAULT_SHOWROOM_DURATION_MINUTES, 15969 DEFAULT_RESELLER_DURATION_MINUTES, 15970 appointmentDurationMinutes, 15971 formatShowroomAddress, 15972 intervalsOverlap, 15973 appointmentOccupiesSlot, 15974 overlapQueryFromUtc, 15975 maxAppointmentMinutes, 15976} from './appointment.js'; 15977 15978// Conversational AI agent per-tenant config vocabulary (context providers + capabilities + 15979// runtime selection / persona / per-surface profiles, plan A \`unified-sales-agent-runtime.md\`) 15980export type { 15981 AiContextProviderId, 15982 AiCapabilityId, 15983 AiSaleConfig, 15984 AiSaleGift, 15985 AiSaleGiftKey, 15986 AiSaleGiftOption, 15987 AiSaleMode, 15988 AiAgentConfig, 15989 AiServiceConfig, 15990 AiSalesPlaybook, 15991 AiQualifyingMove, 15992 AiSurface, 15993 AiPersonaConfig, 15994 AiSurfaceProfile, 15995 AiRuntimePolicies, 15996 // Next best action (piece D) 15997 NbaChannel, 15998 NbaOwner, 15999 NbaSlotPolicy, 16000 NbaBinding, 16001 NbaEntryRequirements, 16002 NbaCatalogueEntry, 16003 NbaTakeoverRule, 16004 NbaSendWindow, 16005 NbaConfig, 16006} from './ai-agent.js'; 16007export type { 16008 NbaComputedEventData, 16009 NbaActionExecutedEventData, 16010 AgentActionExpiredEventData, 16011} from './event-customer.js'; 16012 16013// Product pricing bands (RRP / floor), floor units and the AI catalog snapshot (piece B, 20 Sep 2026) 16014export type { 16015 ProductCatalogPricing, 16016 FloorUnit, 16017 FloorUnitStatus, 16018 CatalogSnapshot, 16019 CatalogSnapshotProduct, 16020 SoldCatalog, 16021 CatalogSnapshotVariant, 16022} from './product-catalog.js'; 16023export { AI_SALE_STAGE_ORDER, AI_SALE_TERMINAL_STAGES } from './lead.js'; 16024 16025export { FLOOR_UNIT_ID_RE, 16026 parseSalesFacts, renderSalesFacts, SALES_FACTS_METAFIELD, SALES_FACTS_KEY_POINTS_MAX, SALES_FACTS_KEY_POINT_CHARS, type ProductSalesFacts, 16027 // AI sale mode: whether the AI may sell a product by text and what rides along. 16028 parseOfferConfig, attachedComponents, OFFER_CONFIG_METAFIELD, 16029 type ProductOfferConfig, type OfferComponent, type ProductPackagePrice, type ProductFinancePrice, type ProductDeliveryAreas, 16030} from './product-catalog.js'; 16031 16032// Delivery "good to go" confirmations (app-internal; \`delivery-confirmations\` DDB table) 16033export type { 16034 DeliveryConfirmation, 16035 SetDeliveryConfirmationInput, 16036} from './delivery-confirmation.js'; 16037 16038// Inventory snapshot types (Winnings SAP daily inventory; \`inventory-snapshots\` DDB table) 16039export type { 16040 Facility, 16041 SapEnvironment, 16042 InventorySnapshotRow, 16043 InventorySnapshotManifest, 16044} from './inventory-snapshot.js'; 16045 16046export { 16047 FACILITY_INFO, 16048 ALL_FACILITIES, 16049 WINNINGS_INVENTORY_TENANTS, 16050 INVENTORY_MANIFEST_PK, 16051} from './inventory-snapshot.js'; 16052 16053// Supplier reconciliation registry + persisted overlay types 16054export type { 16055 CanonicalRules, 16056 SupplierReconciliation, 16057 InventoryReconciliationMatch, 16058 InventoryReconciliationSummary, 16059 InventoryReconciliationShopifyOnly, 16060 InventoryReconciliationOverlay, 16061} from './supplier-reconciliation.js'; 16062 16063export { 16064 SUPPLIER_RECONCILIATION, 16065 RECONCILIATION_RULES_VERSION, 16066 RECON_PK_PREFIX, 16067} from './supplier-reconciliation.js'; 16068 16069// External-channel orders (EXTERNAL_CHANNEL_ORDERS_PLAN.md) 16070export type { 16071 OrderSalesChannel, 16072 PaymentMethod, 16073 PaymentType, 16074 DispatchingState, 16075 DeliveryType, 16076 EntryPoint, 16077 ResellerName, 16078 SaleDetails, 16079 DeliveryDetails, 16080 ExternalOrderProperties, 16081 ExternalOrderValidationError, 16082 ExternalOrderValidationResult, 16083 ExternalOrderValidationContext, 16084} from './external-orders.js'; 16085 16086export type { Reseller, ResellerLocation, ResellerPerson, ResellerStatus, ResellerLocationBookingStatus, ChairProductRef, ResellerBookingEligibility, ResellerBookingBlockCode } from './resellers.js'; 16087export { RESELLER_REGISTRY, RESELLER_EDIT_USER_GRANTS, WHOLESALE_CHAIR_PRICES, CHAIR_FAMILY_PRODUCT_TITLES, CHAIR_SHOPIFY_MAP, chairToS
16087hopify, stockedModelsFor, type StockedModel, sortResellersForMatching, hasResellerEditGrant, deriveResellerContacts, applyResellerContactDerivation, resellerPeopleFromContacts, resellerAppointmentBlock, resellerBookingEligibility, resellerAppointmentContact } from './resellers.js'; 16088 16089export { 16090 SALES_CHANNELS, 16091 PAYMENT_METHODS, 16092 PAYMENT_TYPES, 16093 PAYMENT_COMPLETION_MODE, 16094 DISPATCHING_STATES, 16095 DELIVERY_TYPES, 16096 ENTRY_POINTS, 16097 RESELLERS, 16098 matchReseller, 16099 LEAD_CHANNEL_TO_SALES_CHANNEL, 16100} from './external-orders.js'; 16101 16102export type { 16103 CourseCategory, 16104 QuizQuestionType, 16105 QuizQuestion, 16106 CourseSection, 16107 Course, 16108 SubmittedAnswer, 16109 CourseAttempt, 16110 QuestionOutcome, 16111 ScoreResult, 16112} from './sales-training.js'; 16113 16114export type { 16115 AgentRequirementScope, 16116 AgentRequirementKind, 16117 AgentRequirementStatus, 16118 AgentPersona, 16119 AgentRequirementResult, 16120 AgentCourseCompletion, 16121 AgentReadiness, 16122 AgentTrainingAllocation, 16123} from './agent-readiness.js'; 16124 16125// Legal entities â DynamoDB \`legal-entities\` (Business Entities page, app_admin). 16126export type { 16127 LegalEntity, 16128 LegalEntityType, 16129 LegalEntityJurisdiction, 16130 LegalEntityRole, 16131 LegalEntityAddress, 16132 LegalEntityAbnStatus, 16133 LegalEntityContacts, 16134 LegalEntityTrade, 16135 LegalEntityPlatformAccounts, 16136 LegalEntityFinance, 16137 LegalEntityVerification, 16138 LegalEntityVerificationLapse, 16139 LegalEntityKyc, 16140 LegalEntityDocStore, 16141 LegalEntityAuditEntry, 16142 LegalEntityArtefactCheck, 16143 LegalEntityArtefactExtraction, 16144 LegalEntityOfficeholder, 16145 LegalEntityShareholder, 16146 LegalEntityShareClass, 16147 ArtefactCheckStatus, 16148 KycDocumentEntry, 16149 KycBankPayout, 16150 KycShopifyDocType, 16151 GstBasis, 16152 GstReportingCycle, 16153 KnownLegalEntityId, 16154} from './legal-entity.js'; 16155 16156export { 16157 parseAiBusinessRules, parseAiBusinessFacts, canEditPricing, PRICING_EXTRA_EDITORS, DEFAULT_PRICE_HOLD, DEFAULT_MAX_UNITS_BY_AI, 16158 type AiBusinessRules, type AiFinanceDefaults, type AiPriceHold, type AiBusinessFacts, type AiReturnPolicy, 16159} from './ai-business-rules.js'; 16160`,Je=`/** 16161 * Inventory Snapshot Type Definitions 16162 * 16163 * Based on Terraform schema: terraform/SHARED/create-dynamo-inventory-snapshots 16164 * 16165 * inventory-snapshots table â daily point-in-time snapshots of Winnings SAP 16166 * inventory (ZSD_INVENTORY_STOCK_SRV/InventoryStockSet), one row per 16167 * SKU à facility à storage-location per snapshot day. History is retained via 16168 * a 365-day TTL. 16169 * 16170 * PRIMARY KEY: 16171 * - PK \`snapshotDate\` (S) â Melbourne \`YYYY-MM-DD\` 16172 * - SK \`itemKey\` (S) â \`\${facility}#\${customerSku}#\${storageLocation}\` 16173 * 16174 * A per-run manifest row (PK \`__manifest__\`, SK \`snapshotDate\`) records each 16175 * run so the latest snapshot + available dates can be resolved without a Scan. 16176 * 16177 * The dataset is GLOBAL (one Winnings SAP user). Product images + SKU 16178 * reconciliation are joined per-viewing-tenant at the frontend against that 16179 * tenant's Shopify catalog â never baked into these rows. 16180 */ 16181 16182/** 16183 * SAP plant/facility code â Australian state. Facility is a mandatory filter on 16184 * every InventoryStockSet request. 16185 */ 16186export type Facility = '2000' | '3000' | '4000' | '5000' | '6000' | '7000' | '8000'; 16187 16188export const FACILITY_INFO: Record<Facility, { state: string; desc: string }> = { 16189 '2000': { state: 'NSW', desc: 'New South Wales' }, 16190 '3000': { state: 'VIC', desc: 'Victoria' }, 16191 '4000': { state: 'QLD', desc: 'Queensland' }, 16192 '5000': { state: 'SA', desc: 'South Australia' }, 16193 '6000': { state: 'WA', desc: 'Western Australia' }, 16194 '7000': { state: 'TAS', desc: 'Tasmania' }, 16195 '8000': { state: 'NT', desc: 'Northern Territory' }, 16196}; 16197 16198export const ALL_FACILITIES: Facility[] = ['2000', '3000', '4000', '5000', '6000', '7000', '8000']; 16199 16200/** Gateway environment the snapshot was pulled from (provenance). */ 16201export type SapEnvironment = 'qas' | 'prod'; 16202 16203/** 16204 * Tenants permitted to view the Winnings inventory listing. The dataset is 16205 * global; this gates which tenants' operators see it (server-enforced in 16206 * dataApi, and used to gate the ShopDash menu). Lists the Winnings brand tenants 16207 * (MMC + MHC) across BOTH prod and dev/sandbox environments so operators see it 16208 * in either; app admins always qualify regardless. 16209 */ 16210export const WINNINGS_INVENTORY_TENANTS: string[] = [ 16211 // prod 16212 'masseuse-massage-store.myshopify.com', 16213 'bb7a96-71.myshopify.com', 16214 // dev / sandbox 16215 'dev-masseuse-massage-store.myshopify.com', 16216 'dev-bb7a96-71.myshopify.com', 16217 'dev-mmc.myshopify.com', 16218]; 16219 16220/** 16221 * One inventory line: a SKU at a facility + storage location, as of a snapshot 16222 * day. Stock quantities are parsed once from the SAP decimal strings to numbers. 16223 */ 16224export interface InventorySnapshotRow { 16225 /** PK â Melbourne \`YYYY-MM-DD\`. */ 16226 snapshotDate: string; 16227 /** SK â \`\${facility}#\${customerSku}#\${storageLocation}\`. */ 16228 itemKey: string; 16229 16230 // --- Identity (from SAP, as-is) --- 16231 /** SAP material number (18-char format). */ 16232 material: string; 16233 /** Customer-facing SKU â the join key to Shopify variant.sku. */ 16234 customerSku: string; 16235 skuDescription: string; 16236 facility: Facility; 16237 facilityDesc: string; 16238 storageLocation: string; 16239 division: string; 16240 baseUnit: string; 16241 installLeadTime: number; 16242 /** SAP material creation date, as returned (e.g. \`/Date(â¦)/\` or \`YYYYMMDD\`). */ 16243 createdOn: string; 16244 16245 // --- Stock quantities (parsed to numbers) ---
16246 unrestrictedUse: number; 16247 qualityInspection: number; 16248 blocked: number; 16249 restricted: number; 16250 returns: number; 16251 soInTransit: number; 16252 stoInTransit: number; 16253 reservedDelivery: number; 16254 onPurchaseOrder: number; 16255 /** SAP-computed total (excludes onPurchaseOrder). */ 16256 totalStock: number; 16257 /** 16258 * Winnings' own **ATP** (Available To Promise) â the number their team quotes 16259 * and the one operators should see. SAP nets unrestricted stock against open 16260 * sales-order requirements, so it is ALWAYS <= unrestrictedUse and cannot be 16261 * re-derived from the other fields (a 2026-08-19 prod probe of all 994 rows: 16262 * 97 rows sat below unrestricted, and only 57 of those equalled 16263 * \`unrestricted - reservedDelivery\` â SAP alone knows the rest). 16264 * 16265 * Only populated when the feed is pulled with \`IncludeAtp eq 'X'\`; rows 16266 * captured before 2026-08-19 have no value at all, so readers must fall back 16267 * to \`unrestrictedUse\` for historical snapshot days. 16268 */ 16269 atpqty?: number; 16270 16271 // --- Provenance + lifecycle --- 16272 /** 16273 * \`winnings_sap\` = the hourly poller. \`winnings_sap_live_after_booking\` = the same SAP 16274 * numbers re-read for the booked SKUs the moment a ShopZen booking succeeds (the next 16275 * poll overwrites the row with identical values). 16276 */ 16277 source: 'winnings_sap' | 'winnings_sap_live_after_booking'; 16278 environment: SapEnvironment; 16279 /** ISO-8601 UTC timestamp the snapshot run captured this row. */ 16280 snapshotAtUtc: string; 16281 /** DynamoDB TTL â epoch seconds (snapshotAt + 365d). */ 16282 ttl: number; 16283} 16284 16285/** Sentinel partition-key value (\`snapshotDate\`) shared by all manifest rows. */ 16286export const INVENTORY_MANIFEST_PK = '__manifest__'; 16287 16288/** 16289 * Per-run manifest row. Lives in the same table, reusing its key schema: 16290 * PK \`snapshotDate\` = \`__manifest__\`, SK \`itemKey\` = the snapshot day. Lets readers 16291 * find the latest snapshot (Query PK=\`__manifest__\` desc, Limit 1) and list available 16292 * dates (Query PK=\`__manifest__\`) without a Scan. 16293 */ 16294export interface InventorySnapshotManifest { 16295 /** PK â the \`__manifest__\` sentinel for every manifest row. */ 16296 snapshotDate: '__manifest__'; 16297 /** SK (\`itemKey\`) â the snapshot day this manifest describes (\`YYYY-MM-DD\`). */ 16298 itemKey: string; 16299 /** Same value as \`itemKey\`, surfaced explicitly for readers. */ 16300 date: string; 16301 /** Total data rows written for the day. */ 16302 rowCount: number; 16303 /** Rows written per facility code. */ 16304 facilityCounts: Record<string, number>; 16305 environment: SapEnvironment; 16306 snapshotAtUtc: string; 16307 status: 'complete' | 'partial'; 16308 ttl: number; 16309} 16310`,en=`/** 16311 * Knowledge System Types 16312 * 16313 * Types for the AI knowledge management system including: 16314 * - Knowledge categories for organizing content 16315 * - Manifest structure for tracking generated knowledge files 16316 * - Knowledge source tracking for frontend viewer 16317 */ 16318 16319// ============================================================================ 16320// Knowledge Categories 16321// ============================================================================ 16322 16323/** 16324 * Categories for organizing AI knowledge files. 16325 * Apps subscribe to categories they need via KnowledgeConfig.categories. 16326 */ 16327export type KnowledgeCategory = 16328 | 'products' 16329 | 'collections' 16330 | 'shop' 16331 | 'policies' 16332 | 'faq' 16333 | 'brand' 16334 | 'discounts' 16335 | 'popular'; 16336 16337/** 16338 * All valid knowledge categories. 16339 */ 16340export const VALID_KNOWLEDGE_CATEGORIES: KnowledgeCategory[] = [ 16341 'products', 16342 'collections', 16343 'shop', 16344 'policies', 16345 'faq', 16346 'brand', 16347 'discounts', 16348 'popular', 16349]; 16350 16351/** 16352 * Source type indicating how the knowledge was generated. 16353 */ 16354export type KnowledgeSourceType = 'shopify' | 'dynamodb' | 'ai-generated' | 'manual'; 16355 16356// ============================================================================ 16357// Knowledge Manifest (S3 Index) 16358// ============================================================================ 16359 16360/** 16361 * Represents a single knowledge file in the manifest. 16362 */ 16363export interface KnowledgeFile { 16364 /** Relative S3 key path within tenant folder: "products/catalog.md" */ 16365 path: string;
16366 /** Knowledge category for subscription matching */ 16367 category: KnowledgeCategory; 16368 /** ISO timestamp when the file was generated */ 16369 generatedAt: string; 16370 /** How the knowledge was generated */ 16371 source: KnowledgeSourceType; 16372 /** Version string for cache invalidation (e.g., "2026-02-02") */ 16373 version: string; 16374} 16375 16376/** 16377 * Manifest file structure stored at tenants/{tenantId}/_manifest.json. 16378 * Used by loadKnowledgeForApp() to find files by category subscription. 16379 */ 16380export interface KnowledgeManifest { 16381 /** Tenant identifier */ 16382 tenantId: string; 16383 /** ISO timestamp of last manifest update */ 16384 lastUpdated: string; 16385 /** Manifest schema version */ 16386 version: string; 16387 /** List of knowledge files with category metadata */ 16388 files: KnowledgeFile[]; 16389} 16390 16391// ============================================================================ 16392// Knowledge Source (Frontend Viewer) 16393// ============================================================================ 16394 16395/** 16396 * S3 bucket names used for knowledge and prompts. 16397 */ 16398export type KnowledgeBucket = 'bigm-prompts' | 'bigm-knowledge'; 16399 16400/** 16401 * Type classification for knowledge sources in the UI. 16402 */ 16403export type KnowledgeSourceDisplayType = 'prompt' | 'knowledge' | 'tenant-knowledge'; 16404 16405/** 16406 * Represents a knowledge source used in AI generation. 16407 * Returned from AI lambdas for the frontend viewer component. 16408 */ 16409export interface KnowledgeSource { 16410 /** Full S3 key for retrieval: "tenants/acme/products/catalog.md" */ 16411 path: string; 16412 /** Display name for UI: "catalog.md" */ 16413 name: string; 16414 /** Type for UI styling and icons */ 16415 type: KnowledgeSourceDisplayType; 16416 /** S3 bucket containing the file */ 16417 bucket: KnowledgeBucket; 16418 /** Category if from manifest (for badge display) */ 16419 category?: KnowledgeCategory; 16420} 16421 16422// ============================================================================ 16423// Default Category Subscriptions 16424// ============================================================================ 16425 16426/** 16427 * Default category subscriptions per app type. 16428 * Used when KnowledgeConfig.categories is not explicitly set. 16429 */ 16430export const DEFAULT_CATEGORY_SUBSCRIPTIONS: Record<string, KnowledgeCategory[]> = { 16431 'template-generator': ['products', 'collections', 'faq'], 16432 'lead-analysis': ['products', 'policies', 'faq'], 16433 'conversational-agent': ['products', 'shop', 'policies', 'faq'], 16434 'marketing-executor': ['products', 'collections', 'discounts'], 16435 'transactional-executor': ['shop', 'policies'], 16436}; 16437 16438// ============================================================================ 16439// Helper Functions 16440// ============================================================================ 16441 16442/** 16443 * Check if a string is a valid knowledge category. 16444 */ 16445export function isValidKnowledgeCategory(value: string): value is KnowledgeCategory { 16446 return VALID_KNOWLEDGE_CATEGORIES.includes(value as KnowledgeCategory); 16447} 16448`,nn=`/** 16449 * Lead Prioritisation Types 16450 * 16451 * Sequence-aware, purchase-intent-based lead priority scoring. 16452 * Scores are computed from an ordered intent trail (what events happened 16453 * and in what order) combined with tenant-configurable weights. 16454 * 16455 * Core principle: signals that come AFTER high-intent actions amplify 16456 * priority. A missed call after an abandoned checkout is far more 16457 * urgent than the same missed call with no shopping context. 16458 */ 16459 16460// ============================================================================ 16461// Intent Signal Types 16462// ============================================================================ 16463 16464/** 16465 * Normalized signal types derived from EventCustomerType. 16466 * These collapse multiple event types into purchase-intent categories. 16467 */ 16468export type IntentSignalType = 16469 // AWARENESS stage (lowest intent) 16470 | 'product_viewed' 16471 | 'collection_viewed' 16472 | 'search_performed' 16473 | 'page_viewed' 16474 // CONSIDERATION stage 16475 | 'cart_added' 16476 | 'cart_viewed' 16477 | 'cart_abandoned' 16478 | 'form_submitted' 16479 | 'form_sighted' 16480 // DECISION stage 16481 | 'checkout_started' 16482 | 'checkout_progressed' 16483 | 'checkout_abandoned' 16484 // COMMUNICATION stage (active engagement) 16485 | 'sms_inbound' 16486 | 'sms_outbound' 16487 | 'email_inbound' 16488 | 'email_outbound' 16489 | 'call_inbound' 16490 | 'call_outbound' 16491 | 'call_missed'; 16492 16493export type IntentStage = 'awareness' | 'consideration' | 'decision' | 'communication'; 16494 16495export interface IntentTrailEntry { 16496 /** Normalized signal type */ 16497 signal: IntentSignalType;
16498 /** ISO 8601 timestamp of when this signal occurred */ 16499 at: string; 16500} 16501 16502// ============================================================================ 16503// Priority Tiers 16504// ============================================================================ 16505 16506export type PriorityTier = 'routine' | 'elevated' | 'high' | 'critical'; 16507 16508// ============================================================================ 16509// Superseded Action (when a higher-priority action replaces a pending one) 16510// ============================================================================ 16511 16512export interface SupersededAction { 16513 previousActionType: string; 16514 previousPriority: number; 16515 previousTriggerEventId: string; 16516 supersededAt: string; 16517} 16518 16519// ============================================================================ 16520// Score Breakdown (for observability) 16521// ============================================================================ 16522 16523export interface ScoreBreakdown { 16524 baseWeight: number; 16525 contextBoostTotal: number; 16526 sequenceBoostTotal: number; 16527 recencyMultiplier: number; 16528 trailLength: number; 16529} 16530 16531// ============================================================================ 16532// Tenant Configuration 16533// ============================================================================ 16534 16535export interface SequenceBoost { 16536 /** Signal that must appear in the trail before the trigger */ 16537 priorSignal: IntentSignalType; 16538 /** Category of the triggering action */ 16539 triggerCategory: 'communication' | 'shopping'; 16540 /** Points to add when this sequence is detected */ 16541 boost: number; 16542} 16543 16544export interface LeadPrioritisationConfig { 16545 enabled: boolean; 16546 16547 /** Base weight per AgentActionType when an agent action is triggered */ 16548 triggerWeights: Record<string, number>; 16549 16550 /** Boost added when a signal appears in the trail before the trigger */ 16551 contextBoosts: Record<string, number>; 16552 16553 /** Extra boost when specific signal sequences are detected */ 16554 sequenceBoosts: SequenceBoost[]; 16555 16556 /** Score thresholds for priority tiers */ 16557 tierThresholds: { 16558 critical: number; 16559 high: number; 16560 elevated: number; 16561 }; 16562 16563 /** Max number of signals to keep in intentTrail (default 10) */ 16564 trailMaxLength: number; 16565 16566 /** Drop signals older than this many hours (default 72) */ 16567 trailMaxAgeHours: number; 16568} 16569 16570// ============================================================================ 16571// Mapping Constants 16572// ============================================================================ 16573 16574/** Maps EventCustomerType strings to IntentSignalType */ 16575export const EVENT_TO_SIGNAL_MAP: Record<string, IntentSignalType> = { 16576 'product.viewed': 'product_viewed', 16577 'collection.viewed': 'collection_viewed', 16578 'search.performed': 'search_performed', 16579 'page.viewed': 'page_viewed', 16580 'cart.added': 'cart_added', 16581 'cart.viewed': 'cart_viewed', 16582 'cart.abandoned': 'cart_abandoned', 16583 'form.submitted': 'form_submitted', 16584 'form.sighted': 'form_sighted', 16585 'metaform.leads': 'form_submitted', 16586 'checkout.started': 'checkout_started', 16587 'checkout.contact_info_submitted': 'checkout_progressed', 16588 'checkout.address_info_submitted': 'checkout_progressed', 16589 'checkout.shipping_info_submitted': 'checkout_progressed', 16590 'checkout.payment_info_submitted': 'checkout_progressed', 16591 'checkout.abandoned': 'checkout_abandoned', 16592 'inbound_sms': 'sms_inbound', 16593 'outbound_sms': 'sms_outbound', 16594 'inbound_email': 'email_inbound', 16595 'outbound_email': 'email_outbound', 16596 'max_inbound_call': 'call_inbound', 16597 'max_outbound_call': 'call_outbound', 16598 'inbound.call': 'call_inbound', 16599 'outbound_call': 'call_outbound', 16600 'aircall_inbound_call': 'call_inbound', 16601 'aircall_outbound_call': 'call_outbound', 16602}; 16603 16604/** Maps each signal to its purchase funnel stage */ 16605export const SIGNAL_TO_STAGE: Record<IntentSignalType, IntentStage> = { 16606 product_viewed: 'awareness', 16607 collection_viewed: 'awareness', 16608 search_performed: 'awareness', 16609 page_viewed: 'awareness', 16610 cart_added: 'consideration', 16611 cart_viewed: 'consideration', 16612 cart_abandoned: 'consideration', 16613 form_submitted: 'consideration', 16614 form_sighted: 'consideration', 16615 checkout_started: 'decision', 16616 checkout_progressed: 'decision', 16617 checkout_abandoned: 'decision', 16618 sms_inbound: 'communication', 16619 sms_outbound: 'communication', 16620 email_inbound: 'communication', 16621 email_outbound: 'communication', 16622 call_inbound: 'communication', 16623 call_outbound: 'communication', 16624 call_missed: 'communication', 16625}; 16626 16627/** Maps each signal to communication or shopping category */ 16628export const SIGNAL_CATEGORIES: Record<IntentSignalType, 'communication' | 'shopping'> = { 16629 product_viewed: 'shopping', 16630 collection_viewed: 'shopping', 16631 search_performed: 'shopping', 16632 page_viewed: 'shopping', 16633 cart_added: 'shopping', 16634 cart_viewed: 'shopping', 16635 cart_abandoned: 'shopping', 16636 form_submitted: 'shopping', 16637 form_sighted: 'shopping', 16638 checkout_started: 'shopping', 16639 checkout_progressed: 'shopping', 16640 checkout_abandoned: 'shopping', 16641 sms_inbound: 'communication', 16642 sms_outbound: 'communication', 16643 email_inbound: 'communication', 16644 email_outbound: 'communication', 16645 call_inbound: 'communication', 16646 call_outbound: 'communication', 16647 call_missed: 'communication', 16648}; 16649 16650/** Maps AgentActionType to its trigger category for sequence boost matching */ 16651const ACTION_TYPE_CATEGORIES: Record<string, 'communication' | 'shopping'> = { 16652 ai_escalated: 'communication', 16653 reply_sms: 'communication', 16654 reply_email: 'communication', 16655 follow_up_call: 'communication', 16656 review_form: 'communication', 16657 review_lead: 'shopping', 16658 review_facebook: 'shopping', 16659 reseller_reply: 'communication', 16660 reseller_booking_review: 'communication', 16661 delivery_review: 'shopping', 16662 service_email_reply: 'communication', 16663 delivery_exception: 'shopping', 16664 welcome_hold: 'shopping', 16665 service_sms_reply: 'communication', 16666}; 16667 16668// ============================================================================ 16669// Default Configuration 16670// ============================================================================ 16671 16672export const DEFAULT_LEAD_PRIORITISATION_CONFIG: LeadPrioritisationConfig = { 16673 enabled: true, 16674 triggerWeights: { 16675 // ai_escalated outranks everything: the AI has already triaged this thread and 16676 // decided a human must take over â it must sort above plain reply_sms. 16677 ai_escalated: 60, 16678 follow_up_call: 40, 16679 review_lead: 40, 16680 // A third party (reseller) is talking about a live booking; nobody else 16681 // will action it. Above plain reply_sms, below ai_escalated. 16682 reseller_reply: 38, 16683 // A reseller visit was just booked and the reseller manager must review it 16684 // (Chris, 17 Sep 2026). Deliberately BELOW reseller_reply (38): the lead has 16685 // ONE action slot, and when the reseller texts back about that booking their 16686 // words must replace the plain "please review" (dev suite 17 Sep: at 60 the 16687 // reply was silently swallowed). Above reply_sms so a customer text does not 16688 // bury it; a missed call (40) or an AI hand-off (60) still outranks it. 16689 reseller_booking_review: 37, 16690 reply_sms: 35, 16691 review_form: 30, 16692 reply_email: 20, 16693 review_facebook: 15, 16694 // Service-class post-booking check. Deliberately BELOW reply_sms so a customer 16695 // reply can still supersede it on the lead's single action slot; the poller 16696 // re-raises an unreviewed booking on a later run if it was superseded. 16697 delivery_review: 30, 16698 // The other SERVICE-class actions sit in the same band: below reply_sms so a 16699 // customer text still wins the lead's single action slot, above review_facebook. 16700 // A customer writing to service@ is a customer reply, so it matches reply_email. 16701 service_email_reply: 20, 16702 delivery_exception: 30, 16703 welcome_hold: 30, 16704 // A customer texting the service team is a customer text: same weight as reply_sms, 16705 // so it takes the lead's single action slot from a stale sales reply (15 Sep 2026: 16706 // a June reply_sms at 52 blocked the first service_sms_reply at the default 15). 16707 service_sms_reply: 35, 16708 }, 16709 contextBoosts: { 16710 checkout_abandoned: 25, 16711 checkout_started: 20, 16712 checkout_progressed: 15, 16713 cart_abandoned: 15, 16714 cart_added: 10, 16715 form_submitted: 10, 16716 form_sighted: 5, 16717 call_missed: 20, 16718 call_inbound: 5, 16719 sms_inbound: 8, 16720 email_inbound: 5, 16721 product_viewed: 3, 16722 collection_viewed: 2, 16723 search_performed: 2, 16724 cart_viewed: 3, 16725 page_viewed: 1, 16726 // Outbound signals don't boost (we initiated them) 16727 sms_outbound: 0, 16728 email_outbound: 0, 16729 call_outbound: 0, 16730 }, 16731 sequenceBoosts: [ 16732 // Communication after checkout = highest urgency 16733 { priorSignal: 'checkout_abandoned', triggerCategory: 'communication', boost: 20 }, 16734 // Communication after cart = high urgency 16735 { priorSignal: 'cart_abandoned', triggerCategory: 'communication', boost: 15 }, 16736 // Communication after form = elevated urgency 16737 { priorSignal: 'form_submitted', triggerCategory: 'communication', boost: 10 }, 16738 // Checkout after communication = interested buyer 16739 { priorSignal: 'checkout_started', triggerCategory: 'shopping', boost: 8 }, 16740 ], 16741 tierThresholds: { 16742 critical: 80, 16743 high: 55, 16744 elevated: 35, 16745 }, 16746 trailMaxLength: 10, 16747 trailMaxAgeHours: 72, 16748}; 16749 16750// ============================================================================ 16751// Scoring Functions 16752// ============================================================================ 16753 16754/** 16755 * Compute composite priority score from intent trail + trigger action. 16756 * 16757 * Algorithm: (baseWeight + contextBoosts + sequenceBoosts) * recencyMultiplier 16758 */ 16759export function computePriorityScore( 16760 intentTrail: readonly IntentTrailEntry[], 16761 actionType: string, 16762 config: LeadPrioritisationConfig, 16763 now: Date = new Date() 16764): { score: number; tier: PriorityTier; breakdown: ScoreBreakdown } { 16765 const maxAgeMs = config.trailMaxAgeHours * 60 * 60 * 1000; 16766 const cutoff = now.getTime() - maxAgeMs; 16767 16768 // Filter trail to recent signals only 16769 const recentTrail = intentTrail.filter(e => new Date(e.at).getTime() >= cutoff); 16770 16771 // 1. Base weight from trigger action type 16772 const baseWeight = config.triggerWeights[actionType] ?? 15; 16773 16774 // 2. Context boosts: sum boosts for each unique signal in the trail 16775 const seenSignals = new Set<string>(); 16776 let contextBoostTotal = 0; 16777 for (const entry of recentTrail) { 16778 if (!seenSignals.has(entry.signal)) { 16779 seenSignals.add(entry.signal); 16780 contextBoostTotal += config.contextBoosts[entry.signal] ?? 0; 16781 } 16782 } 16783 16784 // 3. Sequence boosts: check if priorSignal appears in trail 16785 // and the current trigger falls into the matching triggerCategory 16786 const triggerCat = ACTION_TYPE_CATEGORIES[actionType] ?? 'communication'; 16787 let sequenceBoostTotal = 0; 16788 for (const rule of config.sequenceBoosts) { 16789 if (rule.triggerCategory === triggerCat && seenSignals.has(rule.priorSignal)) { 16790 sequenceBoostTotal += rule.boost; 16791 } 16792 } 16793 16794 // 4. Recency multiplier based on most recent trail entry 16795 let recencyMultiplier = 1.0; 16796 if (recentTrail.length > 0) { 16797 const mostRecent = recentTrail[recentTrail.length - 1]; 16798 const ageMs = now.getTime() - new Date(mostRecent.at).getTime(); 16799 if (ageMs < 60 * 60 * 1000) { 16800 recencyMultiplier = 1.2; // within 1 hour 16801 } else if (ageMs < 4 * 60 * 60 * 1000) { 16802 recencyMultiplier = 1.1; // within 4 hours 16803 } 16804 } 16805 16806 const rawScore = (baseWeight + contextBoostTotal + sequenceBoostTotal) * re
16806cencyMultiplier; 16807 const score = Math.round(rawScore); 16808 16809 // Determine tier 16810 let tier: PriorityTier = 'routine'; 16811 if (score >= config.tierThresholds.critical) tier = 'critical'; 16812 else if (score >= config.tierThresholds.high) tier = 'high'; 16813 else if (score >= config.tierThresholds.elevated) tier = 'elevated'; 16814 16815 return { 16816 score, 16817 tier, 16818 breakdown: { 16819 baseWeight, 16820 contextBoostTotal, 16821 sequenceBoostTotal, 16822 recencyMultiplier, 16823 trailLength: recentTrail.length, 16824 }, 16825 }; 16826} 16827 16828// ============================================================================ 16829// Trail Utilities 16830// ============================================================================ 16831 16832/** Append a signal to the trail, enforcing max length */ 16833export function appendToIntentTrail( 16834 trail: readonly IntentTrailEntry[], 16835 signal: IntentSignalType, 16836 at: string, 16837 maxLength: number = 10 16838): IntentTrailEntry[] { 16839 const newTrail = [...trail, { signal, at }]; 16840 if (newTrail.length > maxLength) { 16841 return newTrail.slice(newTrail.length - maxLength); 16842 } 16843 return newTrail; 16844} 16845 16846/** Trim trail by age and length */ 16847export function trimIntentTrail( 16848 trail: readonly IntentTrailEntry[], 16849 maxLength: number, 16850 maxAgeHours: number, 16851 now: Date = new Date() 16852): IntentTrailEntry[] { 16853 const cutoff = now.getTime() - maxAgeHours * 60 * 60 * 1000; 16854 const filtered = trail.filter(e => new Date(e.at).getTime() >= cutoff); 16855 if (filtered.length > maxLength) { 16856 return filtered.slice(filtered.length - maxLength); 16857 } 16858 return filtered; 16859} 16860`,tn=`/** 16861 * Lead Categorisation Rules Types 16862 * 16863 * Defines the structure for tenant lead categorisation rules 16864 * stored in DynamoDB tenants table. 16865 * 16866 * Used by: 16867 * - terraform/SHARED/create-dynamo-tenant 16868 * - ShopDash leadRulesService 16869 * - lambda-deployed-lead-categorisation 16870 */ 16871 16872/** 16873 * Match mode for event-to-category mapping 16874 * 16875 * Controls when an event's category mapping applies AND whether it can lower the lead score: 16876 * 16877 * - "any": Category applies if this event exists ANYWHERE in the lead's history. 16878 * Used for permanent state changes (e.g., order.confirmed marks customer as "repeat" forever). 16879 * Can only UPGRADE lead score (monotonic - score can only go up). 16880 * 16881 * - "latest": Category ONLY applies if this is the lead's most recent event. 16882 * Used for current state tracking (e.g., page.viewed, cart.abandoned). 16883 * Can UPGRADE or DOWNGRADE lead score (allows cycling through states). 16884 * 16885 * Example: A repeat customer who abandons cart (score 4) then browses again (page.viewed 16886 * with matchMode: "latest") will move back to Inbound_Repeat_Follow_Up (score 2). 16887 */ 16888export type EventMatchMode = 'any' | 'latest'; 16889 16890/** 16891 * Prior event operator for combining conditions 16892 * - "OR": This event OR the previous condition 16893 * - "AND": This event AND the previous condition 16894 */ 16895export type PriorEventOperator = 'OR' | 'AND'; 16896 16897/** 16898 * Prior event entry - an event with its combining operator 16899 */ 16900export interface PriorEventEntry { 16901 /** The event type to check for */ 16902 event: string; 16903 /** How to combine with previous condition (not needed for first event) */ 16904 operator?: PriorEventOperator; 16905} 16906 16907/** 16908 * Event Category Rule - defines how an event type maps to a lead category 16909 */ 16910export interface EventCategoryRule { 16911 /** Default category for this event */ 16912 category: string; 16913 /** Explanation of why this mapping exists */ 16914 rationale?: string; 16915 /** 16916 * When this mapping applies and whether it can lower lead score: 16917 * - "any" (default): Event exists anywhere in history. Can only UPGRADE score. 16918 * - "latest": Event is most recent. Can UPGRADE or DOWNGRADE score. 16919 */ 16920 matchMode?: EventMatchMode; 16921 /** Prior events to check for with per-event AND/OR logic */ 16922 priorEventTypes?: PriorEventEntry[]; 16923 /** Category to use if lead has the prior events (based on operators) */ 16924 categoryIfPriorEvent?: string; 16925 /** For call events: minimum talk time in ms for call to count (shorter = missed) */ 16926 minTalkTimeMs?: number; 16927} 16928 16929/** 16930 * Lead Categorisation Rules - full configuration for categorising leads 16931 */ 16932export interface LeadCategorisationRules { 16933 /** 16934 * Map of category name to priority score. 16935 * Higher number = higher priority. 16936 * Used to resolve conflicts when multiple events match. 16937 */ 16938 categoryPriorities: Record<string, number>; 16939 /** Map of event type to categorisation rule */ 16940 eventToCategoryMap: Record<string, EventCategoryRule>; 16941} 16942 16943/** 16944 * Tenant Lead Rules - subset of tenant config for lead categorisation 16945 */ 16946export interface TenantLeadRules { 16947 tenantId: string; 16948 nickname?: string; 16949 leadCategorisationRules?: LeadCategorisationRules; 16950 updatedAt?: string; 16951} 16952`,an=`/** 16953 * Lead Type Definitions 16954 * 16955 * Based on Terraform schema: terraform/SHARED/create-dynamo-lead 16956 * 16957 * Lead table - Customer lead table with lock mechanism and categorization 16958 * PRIMARY KEY = id (UUID) 16959 * TIMESTAMPS = createdAt, updatedAt 16960 * 16961 * GSIs: 16962 * - leadsByPhone: Query by phone number (deduplication)
16963 * - leadsByEmail: Query by email address (deduplication) 16964 * - leadsByLockType: Active leads tracking (on call, being researched) 16965 * - leadsByCategory: Priority-based lead categorization 16966 * - leadsByOwner: Agent ownership tracking (sorted by ownershipAssignedUtc) 16967 * - leadsByPhoneError: Query leads with phone validation errors 16968 * - leadsByEmailError: Query leads with email validation errors 16969 * - leadsByShopifyCustomerId: Query by tenant + Shopify customer ID 16970 * - leadsByWickedId: Query by tenant + Wicked contact ID 16971 * - leadsByCognitoSub: SPARSE - Query by tenant + Cognito user sub (PK=tenantName, SK=cognitoSub). Consumer-app tenants only. 16972 * - leadsByStripeCustomerId: SPARSE - Query by tenant + Stripe customer id (PK=tenantName, SK=stripeCustomerId). Consumer-app tenants only. 16973 * - leadsByAgentActionAssigned: SPARSE - pending agent actions per agent (PK=agentActionAssignedTo, SK=agentActionQueuedAt) 16974 */ 16975 16976import type { EventCustomerType } from './event-customer.js'; 16977 16978// ============================================================================ 16979// Lead Source Attribution Types 16980// ============================================================================ 16981 16982/** 16983 * Lead source channel â the resolved attribution source stamped on the lead. 16984 * These are the definitive source values stamped by buildLeadSource() in the pipeline. 16985 * The frontend reads leadSource.channel directly â no mapping or re-derivation needed. 16986 * 16987 * Resolution order in buildLeadSource: 16988 * 1. Paid signals: fbclid/gclid/utm_source match â facebook, google, paid_ad_click 16989 * 2. UTM source present (non-paid) â organic_non_direct 16990 * 3. Referrer domain matching â google_organic, ai_referral, facebook_organic, etc. 16991 * 4. No signals at all â organic_direct 16992 */ 16993export type LeadSourceChannel = 16994 // --- Paid acquisition (tier 2) --- 16995 | 'facebook' // Meta lead ads (metaform.leads) or ad clicks (fbclid/utm_source=facebook) 16996 | 'google' // Google ad clicks (gclid/utm_source=google) 16997 | 'max_list' // Inbound calls (MaxContact, Aircall) 16998 | 'paid_ad_click' // Non-Meta/Google paid ad click (utm_source + paid medium) 16999 // --- Interaction channels (tier 1) --- 17000 | 'sms_inbound' // Inbound SMS 17001 | 'email_inbound' // Inbound email 17002 | 'messenger_inbound' // Facebook Messenger conversation 17003 | 'chat_inbound' // Site chat widget conversation 17004 // --- Organic with referrer signal (tier 0) --- 17005 | 'google_organic' // Google organic search (referrer from google.*, no gclid) 17006 | 'google_shopping' // Google Shopping free listing (srsltid parameter present) 17007 | 'google_display' // Google Display Network (referrer from googlesyndication/doubleclick) 17008 | 'facebook_organic' // Facebook organic (referrer from facebook.com, no fbclid) 17009 | 'instagram_organic' // Instagram organic (referrer from instagram.com) 17010 | 'youtube_organic' // YouTube organic (referrer from youtube.com) 17011 | 'bing_organic' // Bing organic search (referrer from bing.com) 17012 | 'ai_referral' // AI platform referral (ChatGPT, Gemini, Claude, Perplexity, Copilot) 17013 | 'shop_app' // Shopify Shop app (referrer from shop.app) 17014 // --- Organic without referrer signal (tier 0) --- 17015 | 'organic_non_direct' // Has utmSource but no paid/platform match (referred traffic) 17016 | 'organic_direct' // No utmSource, no paid signals, no known referrer (direct traffic) 17017 | 'unknown' // No leadSource data at all 17018 | 'staff_created' // Lead created manually by staff via the Sales Portal "+" (showroom/reseller/costco entry) 17019 // --- Backfill --- 17020 | 'zoho_backfill' // Imported from Zoho CRM via the 2026-04 backfill â workflows are gated until manually opted in 17021 | 'passion_backfill'; // Imported from passion.io via the 2026-08 Delta X Coach migration â workflows gated until manually opted in 17022 17023/** 17024 * Display labels and descriptions for each lead source channel. 17025 * Used in the frontend for column labels and info icon tooltips. 17026 */ 17027export const CHANNEL_INFO: Record<LeadSourceChannel, { label: string; description: string }> = { 17028 // Paid acquisition 17029 facebook: { 17030 label: 'Facebook', 17031 description: 'Lead came from a Meta (Facebook/Instagram) lead ad or ad click', 17032 }, 17033 google: { 17034 label: 'Google', 17035 description: 'Lead came from a Google ad click (detected via gclid or utm_source)', 17036 }, 17037 max_list: { 17038 label: 'Max List',
17039 description: 'Lead came from an inbound phone call (MaxContact or Aircall)', 17040 }, 17041 paid_ad_click: { 17042 label: 'Paid Ad', 17043 description: 'Lead came from a paid ad click on a non-Meta/Google platform', 17044 }, 17045 // Interaction channels 17046 sms_inbound: { 17047 label: 'SMS Inbound', 17048 description: 'Lead first contacted us via inbound SMS', 17049 }, 17050 email_inbound: { 17051 label: 'Email Inbound', 17052 description: 'Lead first contacted us via inbound email', 17053 }, 17054 messenger_inbound: { 17055 label: 'Messenger', 17056 description: 'Lead first contacted us via Facebook Messenger', 17057 }, 17058 chat_inbound: { 17059 label: 'Site Chat', 17060 description: 'Lead first contacted us via the website chat widget', 17061 }, 17062 staff_created: { 17063 label: 'Staff Created', 17064 description: 'Lead was created manually by staff via the Sales Portal (showroom, reseller, or Costco order entry)', 17065 }, 17066 // Organic with referrer signal 17067 google_organic: { 17068 label: 'Google Organic', 17069 description: 'Lead arrived via Google organic search (no gclid, referrer from google.*)', 17070 }, 17071 google_shopping: { 17072 label: 'Google Shopping', 17073 description: 'Lead arrived via Google Shopping free listing (srsltid parameter detected)', 17074 }, 17075 google_display: { 17076 label: 'Google Display', 17077 description: 'Lead arrived via Google Display Network (referrer from googlesyndication/doubleclick)', 17078 }, 17079 facebook_organic: { 17080 label: 'Facebook Organic', 17081 description: 'Lead arrived via Facebook organic traffic (no fbclid, referrer from facebook.com)', 17082 }, 17083 instagram_organic: { 17084 label: 'Instagram Organic', 17085 description: 'Lead arrived via Instagram organic traffic (referrer from instagram.com)', 17086 }, 17087 youtube_organic: { 17088 label: 'YouTube', 17089 description: 'Lead arrived via YouTube referral (referrer from youtube.com)', 17090 }, 17091 bing_organic: { 17092 label: 'Bing', 17093 description: 'Lead arrived via Bing organic search (referrer from bing.com)', 17094 }, 17095 ai_referral: { 17096 label: 'AI Referral', 17097 description: 'Lead arrived via an AI platform (ChatGPT, Gemini, Claude, Perplexity, Copilot)', 17098 }, 17099 shop_app: { 17100 label: 'Shop App', 17101 description: 'Lead arrived via the Shopify Shop app (referrer from shop.app)', 17102 }, 17103 // Organic without referrer signal 17104 organic_non_direct: { 17105 label: 'Organic (Referred)', 17106 description: 'Lead arrived via referred traffic (has utm_source but no paid platform match)', 17107 }, 17108 organic_direct: { 17109 label: 'Organic (Direct)', 17110 description: 'Lead arrived via direct traffic (no utm_source, no paid signals, no known referrer)', 17111 }, 17112 unknown: { 17113 label: 'Unknown', 17114 description: 'Lead source could not be determined', 17115 }, 17116 // Backfill 17117 zoho_backfill: { 17118 label: 'Zoho Backfill', 17119 description: 'Imported from Zoho CRM via the 2026-04 backfill â workflows gated until opted in', 17120 }, 17121 passion_backfill: { 17122 label: 'Passion Backfill', 17123 description: 'Lead imported from passion.io (Delta X Coach migration); outreach workflows are gated until manually opted in', 17124 }, 17125}; 17126 17127/** 17128 * Grouped trigger event types for eligibility filtering. 17129 * Each group represents a genuine PII capture moment â the point at which 17130 * we first learn a customer's identity (name, email, phone). 17131 * Groups map a UI key to one or more underlying EventCustomerType values. 17132 */ 17133export interface TriggerTypeGroup { 17134 label: string; 17135 description: string; 17136 eventTypes: string[]; 17137} 17138 17139export const TRIGGER_TYPE_INFO: Record<string, TriggerTypeGroup> = { 17140 'web_form': { label: 'Web Form', description: 'Website form submission', eventTypes: ['form.submitted'] }, 17141 'meta_lead_ad': { label: 'Meta Lead Ad', description: 'Facebook/Instagram lead ad form', eventTypes: ['metaform.leads'] }, 17142 'inbound_call': { label: 'Inbound Call', description: 'Inbound phone call (any provider)', eventTypes: ['max_inbound_call', 'aircall_inbound_call', 'inbound.call'] }, 17143 'inbound_sms': { label: 'Inbound SMS', description: 'Customer sent a text message', eventTypes: ['inbound_sms'] }, 17144 'inbound_email': { label: 'Inbound Email', description: 'Customer sent an email', eventTypes: ['inbound_email'] }, 17145 'messenger': { label: 'Messenger', description: 'Facebook Messenger conversation', eventTypes: ['messenger.inbound'] }, 17146 'checkout': { label: 'Checkout', description: 'Contact info submitted at checkout', eventTypes: ['checkout.contact_info_submitted'] }, 17147 'site_chat': { label: 'Site Chat', description: 'Customer initiated a chat on the website', eventTypes: ['chat.initiated'] }, 17148 'shopify_customer': { label: 'Shopify Customer', description: 'Shopify customer account created', eventTypes: ['customer.created'] }, 17149}; 17150 17151/** 17152 * Facebook ad type â derived from the creative cache (facebook-creative-cache table). 17153 * Determined by the facebook-ad-preview Lambda when fetching ad creatives from the Marketing API. 17154 */ 17155export type FacebookAdType = 'lead_form' | 'website' | 'dynamic'; 17156 17157/** Display labels and badge styles for each Facebook ad type. */ 17158export const AD_TYPE_INFO: Record<FacebookAdType, { label: string; badgeClass: string }> = { 17159 lead_form: { label: 'Meta Lead', badgeClass: 'bg-purple-100 text-purple-700' }, 17160 website: { label: 'Web Form', badgeClass: 'bg-primary-100 text-primary-700' }, 17161 dynamic: { label: 'Dynamic', badgeClass: 'bg-amber-100 text-amber-700' }, 17162}; 17163 17164/** 17165 * Display source grouping â now identical to LeadSourceChannel since channels are stamped 17166 * as final source values. Kept as alias for backwards compatibility with frontend code. 17167 */ 17168export type LeadSourceDisplay = LeadSourceChannel; 17169 17170/** Display labels and badge styles for each source. */ 17171export const SOURCE_DISPLAY_INFO: Record<LeadSourceDisplay, { label: string; badgeClass: string }> = { 17172 // Paid acquisition 17173 facebook: { label: 'Facebook', badgeClass: 'bg-blue-100 text-blue-700' }, 17174 google: { label: 'Google', badgeClass: 'bg-green-100 text-green-700' }, 17175 max_list: { label: 'Max List', badgeClass: 'bg-orange-100 text-orange-700' }, 17176 paid_ad_click: { label: 'Paid Ad', badgeClass: 'bg-purple-100 text-purple-700' }, 17177 // Interaction channels 17178 sms_inbound: { label: 'SMS Inbound', badgeClass: 'bg-yellow-100 text-yellow-700' }, 17179 email_inbound: { label: 'Email Inbound', badgeClass: 'bg-pink-100 text-pink-700' }, 17180 messenger_inbound: { label: 'Messenger', badgeClass: 'bg-blue-100 text-blue-700' }, 17181 chat_inbound: { label: 'Site Chat', badgeClass: 'bg-cyan-100 text-cyan-700' }, 17182 // Organic with referrer signal 17183 google_organic: { label: 'Google Organic', badgeClass: 'bg-emerald-100 text-emerald-700' }, 17184 google_shopping: { label: 'Google Shopping', badgeClass: 'bg-lime-100 text-lime-700' }, 17185 google_display: { label: 'Google Display', badgeClass: 'bg-amber-100 text-amber-700' }, 17186 facebook_organic: { label: 'Facebook Organic', badgeClass: 'bg-sky-100 text-sky-700' }, 17187 instagram_organic: { label: 'Instagram Organic', badgeClass: 'bg-fuchsia-100 text-fuchsia-700' }, 17188 youtube_organic: { label: 'YouTube', badgeClass: 'bg-red-100 text-red-700' }, 17189 bing_organic: { label: 'Bing', badgeClass: 'bg-slate-100 text-slate-700' }, 17190 ai_referral: { label: 'AI Referral', badgeClass: 'bg-violet-100 text-violet-700' }, 17191 shop_app: { label: 'Shop App', badgeClass: 'bg-emerald-100 text-emerald-700' }, 17192 // Organic without referrer signal 17193 organic_non_direct: { label: 'Organic (Referred)', badgeClass: 'bg-teal-100 text-teal-700' }, 17194 organic_direct: { label: 'Organic (Direct)', badgeClass: 'bg-indigo-100 text-indigo-700' }, 17195 unknown: { label: 'Unknown', badgeClass: 'bg-gray-100 text-gray-700' }, 17196 staff_created: { label: 'Staff Created', badgeClass: 'bg-stone-100 text-stone-700' }, 17197 // Backfill 17198 zoho_backfill: { label: 'Zoho Backfill', badgeClass: 'bg-amber-100 text-amber-700' }, 17199 passion_backfill: { label: 'Passion Backfill', badgeClass: 'bg-amber-100 text-amber-700' }, 17200}; 17201 17202/** 17203 * First-touch attribution â stamped once when the lead is created. 17204 * Records the channel, event, and campaign context that brought this lead into the system. 17205 * Immutable after creation (conditional write: attribute_not_exists). 17206 */ 17207export interface LeadSource { 17208 /** What channel created this lead */ 17209 channel: LeadSourceChannel; 17210 17211 /** The event that triggered lead creation */ 17212 firstEventId: string; 17213 firstEventType: EventCustomerType; 17214 firstEventAt: string; // ISO 8601 17215 17216 // --- Platform campaign attribution (if the creating event had campaign context) --- 17217 campaignId?: string; 17218 campaignMedium?: string; 17219 17220 // --- MaxContact list attribution (from call events) --- 17221 listId?: number; 17222 17223 // --- Facebook/Meta attribution (from metaform.leads events) --- 17224 facebookCampaignId?: string; 17225 facebookAdsetId?: string; 17226 facebookAdId?: string; 17227 facebookPlacement?: string; // Legacy: raw platform code ("fb", "ig") â kept for backwards compat 17228 publisherPlatform?: string; // "facebook" | "instagram" | "audience_network" | "messenger" 17229 platformPosition?: string; // "feed" | "instagram_reels" | "facebook_stories" | etc. 17230 17231 // --- UTM parameters (captured by web pixel, form-fanout, or Wicked Reports firstClick) --- 17232 utmSource?: string; 17233 utmMedium?: string; 17234 utmCampaign?: string; 17235 utmContent?: string; // Ad creative name (e.g., from Wicked Reports firstClick.content) 17236 utmTerm?: string; // Ad set/targeting (e.g., from Wicked Reports firstClick.term) 17237 17238 // --- Click identifiers --- 17239 fbclid?: string; 17240 gclid?: string; 17241
17242 // --- Extra URL parameters (not covered by standard UTM/click fields) --- 17243 /** Additional URL params like site_source_name, WickedSource, WickedID, etc. */ 17244 customParams?: Record<string, string>; 17245 17246 // --- Page context (from the lead-creating event) --- 17247 /** URL of the landing page where the referral/ad sent the customer */ 17248 landingUrl?: string; 17249 /** URL of the page where the lead-creating event occurred (form submission, checkout, etc.) */ 17250 pageUrl?: string; 17251 /** HTTP referrer from the browser at the time of the lead-creating event */ 17252 referrer?: string; 17253 17254 /** ISO 8601 timestamp when this attribution was recorded */ 17255 attributedAt: string; 17256} 17257 17258/** 17259 * Last-campaign attribution â updated on every campaign-attributed event. 17260 * "Most recent campaign wins" model for sales attribution. 17261 * Conditional write: only updates if new event is more recent than existing. 17262 */ 17263export interface LastCampaignAttribution { 17264 campaignId: string; 17265 medium?: string; 17266 variant?: string; 17267 17268 /** The event that carried this campaign attribution */ 17269 eventId: string; 17270 eventType: EventCustomerType; 17271 17272 /** Facebook campaign context (if the event was from a Meta campaign) */ 17273 facebookCampaignId?: string; 17274 facebookAdsetId?: string; 17275 facebookAdId?: string; 17276 17277 /** ISO 8601 timestamp of the attributed event */ 17278 attributedAt: string; 17279} 17280 17281/** 17282 * Facebook platform attribution â stamped on first metaform.leads event. 17283 * Overrides leadSource for source categorization when present. 17284 * Immutable after creation (conditional write: attribute_not_exists). 17285 */ 17286export interface FacebookAttribution { 17287 /** Facebook campaign ID (from eventData.facebook.campaign_id) */ 17288 campaignId: string; 17289 /** Facebook adset ID */ 17290 adsetId?: string; 17291 /** Facebook ad ID */ 17292 adId?: string; 17293 /** Facebook placement (e.g. "Facebook_Mobile_Feed", "Instagram_Stories") */ 17294 placement?: string; 17295 /** Facebook form ID */ 17296 formId?: string; 17297 /** The metaform.leads event ID that carried this attribution */ 17298 eventId: string; 17299 /** ISO 8601 timestamp of the attributed event */ 17300 attributedAt: string; 17301} 17302 17303/** 17304 * Google platform attribution â stamped on first event carrying a gclid. 17305 * Overrides leadSource for source categorization when present. 17306 * Immutable after creation (conditional write: attribute_not_exists). 17307 */ 17308export interface GoogleAttribution { 17309 /** Google Click ID */ 17310 gclid: string; 17311 /** UTM campaign value */ 17312 utmCampaign?: string; 17313 /** UTM source value */ 17314 utmSource?: string; 17315 /** The event ID that carried this gclid */ 17316 eventId: string; 17317 /** ISO 8601 timestamp of the attributed event */ 17318 attributedAt: string; 17319 /** Google Ads campaign ID (from click_view backfill) */ 17320 campaignId?: string; 17321 /** Google Ads campaign name (from click_view backfill) */ 17322 campaignName?: string; 17323 /** Google Ads ad group ID (from click_view backfill) */ 17324 adGroupId?: string; 17325 /** Google Ads ad group name (from click_view backfill) */ 17326 adGroupName?: string; 17327} 17328 17329/** 17330 * Ephemeral trigger info computed per-lead in Speed to Call. 17331 * NOT stored on the Lead record â computed fresh from event history each page load. 17332 * Used for real-time operations (what to act on now), not attribution tracking. 17333 */ 17334export interface TriggerInfo { 17335 eventType: string 17336 dnis?: string 17337 list?: string 17338 createdAt?: string 17339 formId?: string 17340 formHtmlPath?: string 17341 eventData?: Record<string, unknown> 17342 /** Full raw EventCustomer record (for inspect modal) */ 17343 rawEvent?: Record<string, unknown> 17344 utmSource?: string 17345 utmMedium?: string 17346 utmCampaign?: string 17347 gclid?: string 17348 fbclid?: string 17349 /** Google Ads campaign ID from gad_campaignid URL param */ 17350 gadCampaignId?: string 17351 /** Google ad network/placement type (Search, Shopping, Performance Max, etc.) */ 17352 googleAdType?: string 17353 /** URL of the page where the form was submitted */ 17354 pageUrl?: string 17355 /** HTTP referrer URL from the browser at form submission time */ 17356 referrer?: string 17357 formSourceName?: string 17358 formStepName?: string 17359 provider?: string 17360} 17361 17362/** 17363 * Lead Category Enum 17364 * 17365 * New Customer Categories (Priority 1-3): 17366 * - Inbound_New_Follow_Up (1): New customer browsing/inquiry 17367 * - Recover_New_Customer (2): New customer abandoned cart 17368 * - Support_New_Customer (3): New customer with support issue 17369 * 17370 * Repeat Customer Categories (Priority 4-6): 17371 * - Inbound_Repeat_Follow_Up (4): Repeat customer browsing/inquiry 17372 * - Recover_Repeat_Customer (5): Repeat customer abandoned cart 17373 * - Support_Repeat_Customer (6): Repeat customer with support issue 17374 */ 17375export type LeadCategory = 17376 | 'uncategorized' 17377 // New customer journey categories 17378 | 'Inbound_New_Follow_Up' 17379 | 'Recover_New_Customer' 17380 | 'Support_New_Customer' 17381 // Repeat customer journey categories 17382 | 'Inbound_Repeat_Follow_Up' 17383 | 'Recover_Repeat_Customer' 17384 | 'Support_Repeat_Customer'; 17385 17386/** 17387 * DNC Status Enum 17388 */ 17389export type DncStatus = 'current_dnc' | 'previous_dnc' | 'none'; 17390 17391/** 17392 * Lock Type Enum 17393 */ 17394export type LockType = 'call' | 'review' | 'none' | 'BUYHOT' | 'BUYCLO' | 'AGAM-BC' | 'BCCB' | 'BCSR' | 'BCTB' | 'Abandon_Cart' | 'agent_hold' | 'SALEFIN' | 'SALEFIN-BC' | 'SALEOUT' | 'SALEOUT-BC' | 'SOLDFNANCE' | 'SOLDOUT'; 17395 17396/** 17397 * Ownership Reason - why a lead is owned by an agent. 17398 * - BUYHOT / BUYCLO: MaxContact disposition codes (set by lead-webhook-call-record) 17399 * - workflow_assigned: Default for workflow assign_agent_ownership step 17400 * Extensible: known values get autocomplete, arbitrary strings still accepted. 17401 */ 17402export type OwnershipReason = 'BUYHOT' | 'BUYCLO' | 'workflow_assigned' | 'pool_distributed' | (string & {}); 17403 17404/** Check if an ownership reason is a MaxContact disposition code 17405 * (2026-08-05: BC follow-up codes BCCB/BCSR/BCTB stamp ownership too) */ 17406export function isDispositionOwnership(reason: string | undefined | null): reason is 'BUYHOT' | 'BUYCLO' | 'AGAM-BC' | 'BCCB' | 'BCSR' | 'BCTB' { 17407 return reason === 'BUYHOT' || reason === 'BUYCLO' || reason === 'AGAM-BC' 17408 || reason === 'BCCB' || reason === 'BCSR' || reason === 'BCTB'; 17409} 17410 17411// ============================================================================ 17412// Master Profile Types 17413// ============================================================================ 17414 17415/** 17416 * Source tracking for a single PII field 17417 * Records which event first provided this value 17418 */ 17419export interface PiiFieldSource { 17420 /** Event type that first provided this value */ 17421 eventType: string; 17422 /** Event ID (EventCustomer.id) that first provided this value */ 17423 eventId: string; 17424 /** ISO 8601 timestamp when this value was first seen */ 17425 firstSeenAt: string; 17426 /** 17427 * Authority rank of the source that provided this value â LOWER = higher 17428 * authority (0 = operator/verified, then e-commerce order/checkout/draft, 17429 * customer webhook, inbound text, call transcript last). Drives the 17430 * "authority, then recency" ordering that floats the primary value to 17431 * index 0 of emails[]/phones[]. Optional for backward compatibility with 17432 * rows written before the unified merge. See SOURCE_TIER in pii-merge. 17433 */ 17434 tier?: number; 17435} 17436 17437/** 17438 * Source tracking for array fields (emails, phones, addresses) 17439 * Key is the normalized value (email, phone, or address hash) 17440 */ 17441export type PiiArrayFieldSources = Record<string, PiiFieldSource>; 17442 17443/** 17444 * Complete source tracking for masterProfile 17445 * Tracks which event first provided each PII field 17446 */ 17447export interface MasterProfileSources { 17448 firstName?: PiiFieldSource; 17449 lastName?: PiiFieldSource; 17450 emails?: PiiArrayFieldSources; 17451 phones?: PiiArrayFieldSources; 17452 // Phase 8d (2026-05-09): \`addresses\` removed alongside the array field. 17453 tags?: PiiArrayFieldSources; 17454 shopifyCustomerId?: PiiFieldSource; 17455 wickedContactId?: PiiFieldSource; 17456 ltv?: PiiFieldSource; 17457 verifiedEmail?: PiiFieldSource; 17458 /** 17459 * Provenance for the billing/shipping address singletons (2026-07-31). 17460 * \`tier: 0\` (operator) makes the value STICKY: automated merges (order/ 17461 * checkout/draft events, customer webhooks, backfills) never overwrite an 17462 * operator-set address â only another operator edit replaces it. Absent on 17463 * rows written before this change (legacy behaviour applies). 17464 */ 17465 billingAddress?: PiiFieldSource; 17466 shippingAddress?: PiiFieldSource; 17467} 17468 17469/** 17470 * Normalized address structure for masterProfile 17471 * Standardized field names regardless of source (Shopify, forms, etc.) 17472 */ 17473export interface NormalizedAddress { 17474 address1?: string; 17475 address2?: string; 17476 city?: string; 17477 province?: string; 17478 provinceCode?: string; 17479 zip?: string; 17480 country?: string; 17481 countryCode?: string; 17482 phone?: string; 17483 firstName?: string; 17484 lastName?: string; 17485 company?: string; 17486} 17487 17488// ============================================================================ 17489// Address Verification (Google Address Validation API) 17490// ============================================================================ 17491 17492/** 17493 * verdict.possibleNextAction values returned by Google Address Validation API. 17494 * Mapped 1:1 from the API response â we don't collapse them. 17495 * 17496 * NOTE (Phase 0 finding 2026-05-12): \`ACCEPT\` alone is NOT a reliable 17497 * "real address" signal. Google returns ACCEPT for any address whose 17498 * city/state/postcode match, even when the street number is fabricated. 17499 * UI verdict must combine this with \`validationGranularity\` and 17500 * \`hasUnconfirmedComponents\`. 17501 */ 17502export type AddressVerificationVerdict = 17503 | 'ACCEPT' 17504 | 'CONFIRM'
17505 | 'CONFIRM_ADD_SUBPREMISES' 17506 | 'FIX'; 17507 17508/** 17509 * verdict.validationGranularity values from Google. The discriminator 17510 * for "is this a real, deliverable premise": 17511 * - PREMISE / SUB_PREMISE â confirmed real premise 17512 * - PREMISE_PROXIMITY â near a real premise, exact number unconfirmed 17513 * - BLOCK / ROUTE â street area exists, premise doesn't 17514 * - OTHER â couldn't validate even at a route level 17515 */ 17516export type AddressValidationGranularity = 17517 | 'SUB_PREMISE' 17518 | 'PREMISE' 17519 | 'PREMISE_PROXIMITY' 17520 | 'BLOCK' 17521 | 'ROUTE' 17522 | 'OTHER'; 17523 17524/** 17525 * Verification verdict stamped onto MasterProfile.{billing,shipping}Address 17526 * by the address-validator Lambda (subscribed to the Lead DDB stream). 17527 * 17528 * The \`addressHash\` field is the loop guard: the Lambda's UpdateItem 17529 * fires the stream again, and the next invocation short-circuits when 17530 * the hash of the current address matches the hash on the verification. 17531 */ 17532export interface AddressVerification { 17533 verifiedAt: string; 17534 provider: 'google-address-validation'; 17535 /** sha256(JSON.stringify({a1, a2, city, province, zip, countryCode})) truncated to 16 hex chars. */ 17536 addressHash: string; 17537 verdict: AddressVerificationVerdict; 17538 validationGranularity: AddressValidationGranularity; 17539 addressComplete: boolean; 17540 hasInferredComponents: boolean; 17541 hasReplacedComponents: boolean; 17542 hasUnconfirmedComponents: boolean; 17543 /** result.address.unconfirmedComponentTypes â populates the amber/red tooltip. */ 17544 unconfirmedComponentTypes?: readonly string[] | string[]; 17545 /** 17546 * Per Google docs, AU/NZ/US/CA/MX/ES support residential/commercial 17547 * classification. Phase 0 testing 2026-05-12 returned empty metadata for 17548 * every AU address â keep optional, don't depend on these in UI logic. 17549 */ 17550 residential?: boolean; 17551 business?: boolean; 17552 geocode?: { latitude: number; longitude: number }; 17553 /** result.address.formattedAddress â Google's standardized form. */ 17554 formattedAddress?: string; 17555} 17556 17557/** 17558 * Canonical unified identity profile 17559 * Aggregates PII from all customer events with source tracking 17560 * Stored as a MAP in DynamoDB 17561 */ 17562/** 17563 * Tracks a single call analysis extraction event 17564 */ 17565export interface CallAnalysisSource { 17566 callId: string; 17567 extractedAt: string; 17568 version: string; 17569 /** MasterProfile field paths added by this extraction (e.g. "financeProfile.driversLicence"). */ 17570 fieldsAdded?: readonly string[] | string[]; 17571} 17572 17573/** 17574 * Channel of a text-based PII extraction. 17575 * \`call-live\` covers the per-turn extractor running against call-transcript-turns. 17576 */ 17577export type TextExtractionChannel = 17578 | 'sms' 17579 | 'email' 17580 | 'messenger' 17581 | 'chat' 17582 | 'form' 17583 | 'call-live'; 17584 17585/** 17586 * Tracks a single text-channel PII extraction event (from text-pii-extractor 17587 * Lambda, covering inbound SMS/email/Messenger/chat/form and live call turns). 17588 */ 17589export interface TextExtractionSource { 17590 eventId: string; 17591 eventType: string; 17592 channel: TextExtractionChannel; 17593 extractedAt: string; 17594 version: string; 17595 extractor: 'regex' | 'llm' | 'hybrid'; 17596 /** MasterProfile field paths added by this extraction. */ 17597 fieldsAdded?: readonly string[] | string[]; 17598 /** Present when channel === 'call-live'. */ 17599 callId?: string; 17600 turnIndex?: number; 17601} 17602 17603/** 17604 * Tracks sources of PII data for audit/compliance purposes 17605 */ 17606export interface PiiSourcesTracking { 17607 callAnalysis?: readonly CallAnalysisSource[] | CallAnalysisSource[]; 17608 textExtraction?: readonly TextExtractionSource[] | TextExtractionSource[]; 17609} 17610 17611/** 17612 * Other PII item (catch-all for unstructured PII types) 17613 */ 17614export interface OtherPiiItem { 17615 type: string; 17616 value: string; 17617} 17618 17619// ============================================================================ 17620// Ineligibility Reasons (sales / finance disqualifiers extracted by PII workload) 17621// ============================================================================ 17622 17623/** 17624 * Category of ineligibility â drives UI tone (red for hard disqualifiers, 17625 * amber for softer ones) and downstream filtering by workflow rules. 17626 * 17627 * Distinct from \`PrimaryHurdle\` â a hurdle is a current obstacle ("budget", 17628 * "timing"), an ineligibility is a hard disqualifier ("Pacemaker", 17629 * "Unemployed cannot get finance"). 17630 */ 17631export type IneligibilityCategory = 'health' | 'finance' | 'mobility' | 'age' | 'other'; 17632 17633/** 17634 * SME review state. Items default to \`proposed\` on extraction; an SME flips 17635 * the status via the ProfilePane review UI. \`rejected\` items are sticky â 17636 * the merge helper (in @bigm/shared) refuses to resurrect them on subsequent 17637 * extractions, so the SME's decision survives re-runs of the PII workload. 17638 */ 17639export type IneligibilityStatus = 'proposed' | 'accepted' | 'rejected'; 17640 17641/** 17642 * A single ineligibility reason with provenance + review state. 17643 * 17644 * Source quote + rationale are stored on the row so an SME can validate the 17645 * extraction without leaving the Messages Profile tab. PromptVersion is 17646 * pinned at extraction time so the eval pipeline can attribute feedback 17647 * back to the specific prompt version that produced it. 17648 */ 17649export interface IneligibilityReason { 17650 category: IneligibilityCategory; 17651 /** Short verbatim/normalised label, e.g. "Pacemaker", "Unemployed". */ 17652 reason: string; 17653 /** Verbatim transcript span supporting the extraction. */ 17654 sourceQuote?: string; 17655 /** One-sentence model explanation of why this is disqualifying. */ 17656 rationale?: string; 17657 /** Call ID (when extracted from a call) â links back to call-transcript-turns. */ 17658 sourceCallId?: string; 17659 /** ISO timestamp of when this item was first proposed. */ 17660 detectedAt: string; 17661 /** Prompt version that produced this â load-bearing for eval attribution. */ 17662 promptVersion: string; 17663 /** SME review state. */ 17664 status: IneligibilityStatus; 17665 /** Cognito sub of the reviewing SME. */ 17666 reviewedBy?: string; 17667 /** ISO timestamp of the review. */ 17668 reviewedAt?: string; 17669 /** Free-text SME note (e.g. "model conflated arthritis with osteoporosis"). */ 17670 reviewNote?: string; 17671} 17672 17673// ============================================================================ 17674// Generic field review state (side-channel for non-ineligibility PII fields) 17675// ============================================================================ 17676 17677/** 17678 * SME review state for any reviewable PII field. 17679 * 17680 * \`IneligibilityReason\` carries status inline because it's a fresh object type. 17681 * For most other PII (\`conditions: string[]\`, \`primaryHurdle: PrimaryHurdle\`, 17682 * \`ageBand: AgeBand\`, ...) the field shape is bare â there's nowhere to 17683 * attach status. Instead we store review state in a side-channel 17684 * \`MasterProfile.reviewState\` map keyed by canonical field path: 17685 * 17686 * - Scalar fields: \`'masterProfile.salesContext.primaryHurdle'\` 17687 * - String-array elements: \`'masterProfile.healthProfile.conditions:Osteoporosis'\` 17688 * 17689 * The value-keyed shape for array elements survives index-shifting reorders 17690 * across re-extractions. 17691 */ 17692export interface FieldReviewState { 17693 status: 'proposed' | 'accepted' | 'rejected'; 17694 /** Cognito sub of the reviewing SME. */ 17695 reviewedBy?: string; 17696 reviewedAt?: string; 17697 reviewNote?: string; 17698 /** Prompt version that produced the value being reviewed â load-bearing for eval attribution. */ 17699 promptVersion?: string; 17700 17701 /** 17702 * Set to \`true\` when an SME has overwritten the value via the edit UI. 17703 * Distinguishes agent-authored values from AI-extracted ones for the next 17704 * review pass: 17705 * - false / undefined â field is still AI-sourced; UI will require a 17706 * mandatory error-description note on next overwrite. 17707 * - true â field has been explicitly edited by an agent; subsequent 17708 * overwrites don't require the AI-error explanation. 17709 */ 17710 agentEdited?: boolean; 17711 17712 /** 17713 * The value that was overwritten on the most-recent agent edit, kept for 17714 * audit / undo. The current value lives at the field path itself â this is 17715 * just a breadcrumb. 17716 */ 17717 previousValue?: unknown; 17718} 17719 17720// ============================================================================ 17721// Finance Profile (for Humm / Payright / Zip finance application pre-fill) 17722// ============================================================================ 17723 17724export type AuState = 'NSW' | 'VIC' | 'QLD' | 'SA' | 'WA' | 'TAS' | 'ACT' | 'NT'; 17725 17726export interface DriversLicence { 17727 number: string; 17728 state?: AuState; 17729 expiry?: string; // YYYY-MM-DD 17730 cardNumber?: string; // back-of-card ID 17731} 17732 17733export interface MedicareCard { 17734 number: string; // 10 digits 17735 reference?: string; // IRN (1-9) 17736 expiry?: string; // MM/YYYY 17737} 17738 17739export interface Passport { 17740 number: string; 17741 country?: string; // ISO2 17742 expiry?: string; // YYYY-MM-DD 17743} 17744 17745export type EmploymentStatus = 17746 | 'full_time' 17747 | 'part_time' 17748 | 'casual' 17749 | 'self_employed' 17750 | 'contractor' 17751 | 'unemployed' 17752 | 'retired' 17753 | 'student' 17754 | 'pensioner'; 17755 17756export type PayFrequency = 'weekly' | 'fortnightly' | 'monthly' | 'annually'; 17757 17758export interface Employment { 17759 status?: EmploymentStatus; 17760 employerName?: string; 17761 occupation?: string; 17762 industry?: string; 17763 payFrequency?: PayFrequency; 17764 grossIncome?: number; // AUD per payFrequency unit 17765 netIncome?: number; 17766 employedSince?: string; // YYYY-MM 17767} 17768 17769export interface BankAccount { 17770 bsb?: string; // NNN-NNN or NNNNNN 17771 accountNumber?: string; 17772 accountName?: string; 17773 bankName?: string; 17774} 17775 17776export type MaritalStatus = 17777 | 'single' 17778 | 'married' 17779 | 'de_facto' 17780 | 'separated' 17781 | 'divorced' 17782 | 'widowed'; 17783 17784export type HousingStatus = 17785 | 'own_outright' 17786 | 'mortgage' 17787 | 'renting' 17788 | 'boarding' 17789 | 'living_with_parents' 17790 | 'other'; 17791 17792export type ResidencyStatus = 17793 | 'citizen' 17794 | 'permanent_resident' 17795 | 'temporary_visa' 17796 | 'other'; 17797 17798export interface AddressHistoryEntry { 17799 address: NormalizedAddress; 17800 movedInDate?: string; // YYYY-MM 17801 movedOutDate?: string; // YYYY-MM; undefined = current 17802 source?: string; // eventId 17803} 17804 17805/** 17806 * FinanceProfile â PII required to pre-fill Humm / Payright / Zip 17807 * finance applications. All fields optional. Arrays deduplicated. 17808 */ 17809/** 17810 * Health / medical context â used for massage-chair sales (the customer's 17811 * injury / pain / condition history is THE primary qualifier for this tenant). 17812 * 17813 * All fields are free-form string arrays, deduped case-insensitively at merge 17814 * time. The extraction prompt emits short phrases (e.g. "lower back", "sciatica", 17815 * "physio 3x/week") â this isn't a structured medical record. 17816 * 17817 * Treat every string here as sensitive PII â do not log values, only counts. 17818 */ 17819/** Who a household member is to the customer. \`self\` = the customer. */ 17820export type HouseholdRelation = 'self' | 'partner' | 'spouse' | 'hu
17820sband' | 'wife' | 'mother' | 'father' | 'son' | 'daughter' | 'friend' | 'other'; 17821 17822/** One open fact about a person: a short snake_case key and the stated value. */ 17823export interface HouseholdFact { key: string; value: string; updatedAt?: string } 17824 17825/** One person the customer told us about (see \`MasterProfile.household\`). Product-agnostic: facts about people. */ 17826export interface HouseholdMember { 17827 relation: HouseholdRelation; 17828 name?: string; 17829 heightCm?: number; 17830 weightKg?: number; 17831 /** "lower back", "shoulders", "legs" â short phrases, deduped. */ 17832 painAreas?: readonly string[] | string[]; 17833 conditions?: readonly string[] | string[]; 17834 occupation?: string; 17835 /** Stated age in years (any age, children included). */ 17836 age?: number; 17837 /** Adult band, derived from \`age\` when it is 18+ (the bands start at 18). */ 17838 ageBand?: AgeBand; 17839 /** Anything else stated about this person, product-agnostic (shoe_size, allergy, mobility â¦): newest value per key. */ 17840 facts?: readonly HouseholdFact[] | HouseholdFact[]; 17841 /** The customer's own words each fact came from (most recent last, capped). */ 17842 sourceQuotes?: readonly string[] | string[]; 17843 sourceEventIds?: readonly string[] | string[]; 17844 updatedAt?: string; 17845} 17846 17847export interface HealthProfile { 17848 /** Specific injuries mentioned ("slipped disc 2019", "broken wrist"). */ 17849 injuries?: readonly string[] | string[]; 17850 /** Body areas where pain is mentioned ("lower back", "right shoulder", "neck"). */ 17851 painAreas?: readonly string[] | string[]; 17852 /** Ongoing / chronic conditions ("arthritis", "sciatica", "fibromyalgia"). */ 17853 conditions?: readonly string[] | string[]; 17854 /** Treatments, procedures, or practitioners mentioned ("physio weekly", "seeing a chiropractor"). */ 17855 notes?: readonly string[] | string[]; 17856 /** Mobility aids mentioned ("walking stick", "wheelchair", "walker"). Cross-sell: mobility retailers. */ 17857 mobilityAids?: readonly string[] | string[]; 17858 /** Health insurance / funding scheme mentioned ("BUPA", "Medibank", "DVA", "NDIS"). Cross-sell: insurance partners. */ 17859 healthInsurance?: string; 17860 /** Medications mentioned ("endone", "targin", "panadol osteo"). Cross-sell: pharmacy / pain-management. */ 17861 medications?: readonly string[] | string[]; 17862} 17863 17864// âââ Contact preferences (opt-outs + channel preferences) âââââââââââââââââ 17865// Populated by text-pii-extractor from opt-out signals in inbound SMS / emails 17866// ("stop", "please don't call", "text me don't ring"). Booleans are 17867// first-TRUE-wins â once a customer has asked not to be contacted we never
17868// automatically flip it back to false; only agent action via UI can do that. 17869export type PreferredChannel = 'sms' | 'email' | 'call' | 'none'; 17870 17871export interface ContactPreferences { 17872 /** True if customer asked to stop SMS ("stop", "unsubscribe", "don't text"). */ 17873 smsOptOut?: boolean; 17874 /** True if customer asked to stop calls ("don't call me", "no phone calls"). */ 17875 callOptOut?: boolean; 17876 /** True if customer asked to stop emails. */ 17877 emailOptOut?: boolean; 17878 /** Hard stop across all channels ("leave me alone", "don't contact me"). */ 17879 doNotContact?: boolean; 17880 /** Explicitly stated preferred channel when customer gives one. */ 17881 preferredChannel?: PreferredChannel; 17882 /** Free-form best-time phrase ("after 5pm", "weekends", "mornings only"). */ 17883 bestContactTime?: string; 17884 /** Calls suppressed until this ISO time (piece D; C2 sets 48h at a close 17885 * attempt). Honoured by \`resolveContactability(lead, 'call')\` and the NBA 17886 * channel choice; the MAX dialler lists are outside the platform. */ 17887 callPausedUntil?: string; 17888 /** What set \`callPausedUntil\` (e.g. \`decline:sms:ai_review\`) â audit only. */ 17889 callPauseSource?: string; 17890 /** Verbatim quotes providing context for any preference above. Audit + agent reference. */ 17891 channelNotes?: readonly string[] | string[]; 17892} 17893 17894// âââ AI sale (plan \`sales-ai-mhc-ai-sale-mode.md\`) âââââââââââââââââââââââââââ 17895 17896/** 17897 * Exactly one stage at a time, forward only. Entry conditions are written by code, 17898 * never by the model (the shape of \`updateSuggestedSalesCycle\`, applied to the AI's 17899 * own funnel). 17900 * 17901 * assessing the NBA picked this lead for an AI sale 17902 * qualifying the tenant's qualifying question has been asked 17903 * product_identified the customer picked from the options we listed 17904 * draft_order_sent the draft exists AND the card went out 17905 * sold \`order.confirmed\` for THAT draft, financialStatus paid 17906 * delivery_booked the delivery booking succeeded 17907 * handed_off a person owns it now (any hand-off trigger) 17908 * closed_lost declined, DNC, or idle 14 days after the draft 17909 */ 17910export type AiSaleStage = 17911 | 'assessing' | 'qualifying' | 'product_identified' | 'draft_order_sent' 17912 | 'sold' | 'delivery_booked' | 'handed_off' | 'closed_lost'; 17913 17914/** Forward-only order. A stage may never move left. */ 17915export const AI_SALE_STAGE_ORDER: readonly AiSaleStage[] = [ 17916 'assessing', 'qualifying', 'product_identified', 'draft_order_sent', 'sold', 'delivery_booked', 17917] as const; 17918 17919/** Terminal stages: nothing follows them, and neither may be re-entered. */ 17920export const AI_SALE_TERMINAL_STAGES: readonly AiSaleStage[] = ['handed_off', 'closed_lost'] as const; 17921 17922/** 17923 * The offer ladder: a phone consultation first, then the nearest showroom, and the 17924 * text sale ONLY when the customer has made both unwanted (or has asked to buy by 17925 * text). Stamped by code alone â the close tools are not even built for the model 17926 * until \`isLadderSatisfied\` is true, so no reading of the thread can skip a rung. 17927 * 17928 * The ladder gates the CLOSE, never information. Prices, options, facts, links and 17929 * emails are answered at any rung: the 22 Sep review found the AI pushing a call on 17930 * 18 of 44 turns that asked for exactly those things. 17931 */ 17932export interface AiSaleLadder { 17933 /** A phone consultation was offered (opener slots or a propose_slots turn). */ 17934 phoneOfferedAt?: string; 17935 /** The customer refused the CALL specifically (review layer + the model agreeing). */ 17936 phoneDeclinedAt?: string; 17937 /** A showroom visit was offered (a tenant reseller was in range). */ 17938 visitOfferedAt?: string; 17939 visitDeclinedAt?: string; 17940 /** No reseller of this tenant within range: the rung cannot be offered, so it is satisfied. */ 17941 visitSkipped?: 'no_reseller_in_range'; 17942 /** "just send me the link", "how do I pay" â short-circuits the whole ladder. */ 17943 buyByTextAskedAt?: string; 17944} 17945 17946/** What the NBA decided about pursuing this lead as an AI sale, and why. */ 17947export interface AiSaleAssessment { 17948 at: string; 17949 eligible: boolean;
17950 /** Machine-readable reasons, e.g. \`['holdout']\`, \`['recent_answered_call','agent_hold']\`. */ 17951 reasons: string[]; 17952 /** The mode the tenant was in when this was computed. */ 17953 mode?: 'shadow' | 'live'; 17954} 17955 17956export interface LeadAiSale { 17957 stage: AiSaleStage; 17958 stageEnteredAt: string; 17959 ladder?: AiSaleLadder; 17960 assessment?: AiSaleAssessment; 17961 /** The Shopify draft order the AI raised for this lead. */ 17962 draftOrderId?: string; 17963 /** The paid order that draft became. */ 17964 orderId?: string; 17965 /** The product the customer picked. */ 17966 variantId?: string; 17967 /** The open draft's payment link, for "send it again" and the nudges (29 Sep 2026). */ 17968 invoiceUrl?: string; 17969 /** Which free gift is on the open draft (\`aiSale.gift\` options), when the tenant has one. */ 17970 gift?: 'eye' | 'neck'; 17971 /** Prompt version + model tier that ran the turn which moved the stage last. */ 17972 promptVersion?: string; 17973 modelTier?: string; 17974 /** Why the AI stopped, when the stage is terminal. */ 17975 endedReason?: string; 17976} 17977 17978 17979// âââ Sales context (sales-process signals, BANT-aligned) ââââââââââââââââââ 17980// BANT = Budget / Authority / Need / Timing. 17981// Budget â budgetMax + primaryHurdle='budget' 17982// Authorityâ decisionMaker 17983// Need â tied to healthProfile (pain drives need for this tenant) 17984// Timing â purchaseTimeline 17985// \`primaryHurdle\` is the ONE headline blocker preventing purchase today. 17986// \`objections[]\` is the running list of every concern raised across all events. 17987export type InterestLevel = 'hot' | 'warm' | 'cold' | 'not_interested'; 17988export type BuyingFor = 'self' | 'spouse' | 'parent' | 'child' | 'gift' | 'other'; 17989export type PurchaseTimeline = 'now' | 'within_month' | 'within_quarter' | 'within_year' | 'browsing_only'; 17990export type DecisionMaker = 'self' | 'joint_with_spouse' | 'family_decision' | 'dependent_on_other'; 17991export type BuyingStage = 'awareness' | 'consideration' | 'decision' | 'post_purchase'; 17992export type PrimaryHurdle = 17993 | 'budget' 17994 | 'space' 17995 | 'size_fit' 17996 | 'spouse_approval' 17997 | 'timing' 17998 | 'trust' 17999 | 'product_fit' 18000 | 'health_concern' 18001 | 'research_phase' 18002 | 'other'; 18003 18004export interface SalesContext { 18005 /** Interest signal from language â hot ("ready to buy"), warm ("thinking"), cold ("maybe later"), not_interested. */ 18006 interestLevel?: InterestLevel; 18007 /** The headline blocker preventing purchase (first-value-wins â early signal usually correct). */ 18008 primaryHurdle?: PrimaryHurdle; 18009 /** Verbatim quote explaining the hurdle ("wife doesn't think we need one", "no space for it"). */ 18010 primaryHurdleDetail?: string; 18011 /** Running list of every objection raised across all events (dedupe-append). */ 18012 objections?: readonly string[] | string[]; 18013 /** Latest-wins: timeline to purchase as the customer states it. */ 18014 purchaseTimeline?: PurchaseTimeline; 18015 /** Who the purchase is for. */ 18016 buyingFor?: BuyingFor; 18017 /** Already owns a massage chair (service requests, repeat consideration). */ 18018 alreadyOwnsChair?: boolean; 18019 /** True if this inbound is asking for support on an existing chair, not a new purchase. */ 18020 isServiceRequest?: boolean; 18021 /** True if customer indicated this was a wrong number / contact error. */ 18022 wrongNumber?: boolean; 18023 /** True when the customer explicitly asked to book a demo / showroom visit. */ 18024 demoRequested?: boolean; 18025 /** True when the customer asked about finance / payment plans. */ 18026 paymentPlanInterest?: boolean; 18027 /** Competitor brands or retailers mentioned (for positioning + market intel). */ 18028 competitorsConsidered?: readonly string[] | string[]; 18029 /** Parsed budget ceiling in AUD. "not more than $5k" â 5000. */ 18030 budgetMax?: number; 18031 /** Delivery suburb for logistics + regional segmentation. */ 18032 deliverySuburb?: string; 18033 /** 4-digit AU postcode for delivery / regional targeting. */ 18034 deliveryPostcode?: string; 18035 /** BANT Authority â who makes this decision. First-value-wins. */ 18036 decisionMaker?: DecisionMaker; 18037 /** CRM pipeline stage â latest-wins (unlike most scalars; moves forward with interactions). */ 18038 buyingStage?: BuyingStage; 18039} 18040 18041// âââ Demographics (cross-sell segmentation) ââââââââââââââââââââââââââââââââ 18042// Age + occupation are the TWO most valuable cross-sell dimensions â partner 18043// products (pharmacy, insurance, aged-care) segment primarily on these. 18044export type AgeBand = '18-29' | '30-44' | '45-59' | '60-74' | '75+'; 18045export type LifeStage = 'young_adult' | 'family' | 'empty_nester' | 'retiree'; 18046export type OccupationCategory = 18047 | 'tradie' 18048 | 'office_worker' 18049 | 'healthcare' 18050 | 'education'
18051 | 'retail_hospitality' 18052 | 'retired' 18053 | 'student' 18054 | 'homemaker' 18055 | 'unemployed' 18056 | 'other'; 18057 18058export interface Demographics { 18059 /** Age bucket â from explicit age ("I'm 85") or parsed from DOB year. */ 18060 ageBand?: AgeBand; 18061 /** Life-stage inferred from explicit context (married+kids â family; retiree by age+employment). */ 18062 lifeStage?: LifeStage; 18063 /** Occupation bucket â ties to employment.occupation but collapses to a partner-friendly enum. */ 18064 occupationCategory?: OccupationCategory; 18065 /** Veteran / DVA signals â "DVA card", "ex-service", "war veteran", "RSL member". HUGE cross-sell segment in AU. */ 18066 isVeteran?: boolean; 18067} 18068 18069// âââ Lifestyle (cross-sell â interests + residence for targeting) ââââââââââ 18070// LivingArrangement is what partner products (aged care, home modifications, 18071// insurance) actually segment on â more useful than the older 18072// HouseholdComposition enum which mixed social state with residence type. 18073export type LivingArrangement = 18074 | 'own_home' 18075 | 'with_family' 18076 | 'retirement_village' 18077 | 'aged_care' 18078 | 'assisted_living' 18079 | 'rental' 18080 | 'other'; 18081 18082export interface Lifestyle { 18083 /** Interests / hobbies mentioned. Cross-sell: targeted marketing. */ 18084 interests?: readonly string[] | string[]; 18085 /** Residence type â partner-product segmentation (aged-care providers, home mods, insurance). */ 18086 livingArrangement?: LivingArrangement; 18087 /** Household size â number of people in the home. "I live with my wife and 3 kids" â 5. */ 18088 householdSize?: number; 18089 /** Pets mentioned. Cross-sell: pet insurance / food / vet partners. */ 18090 pets?: readonly string[] | string[]; 18091} 18092 18093// âââ Consent (privacy + cross-sell legal basis) âââââââââââââââââââââââââââ 18094// Required for any data-sharing to partner businesses (APP 6 / GDPR Art 6). 18095// dataSharingConsent must be TRUE before cross-sell exports or partner-lead 18096// sales can include this customer's data. 18097export interface ConsentFlags { 18098 /** Explicit consent to share PII with partner businesses for cross-sell / lead-sharing. */ 18099 dataSharingConsent?: boolean; 18100 /** Marketing-communication consent (usually implied at signup, track explicit opt-in/out). */ 18101 marketingConsent?: boolean; 18102 /** ISO timestamp of last consent state change. */ 18103 consentUpdatedAt?: string; 18104 /** Where the consent was given. "signup form", "sms reply 2026-03-15", "verbal on call abc123". */ 18105 consentSource?: string; 18106} 18107 18108export interface FinanceProfile { 18109 // Identity documents 18110 driversLicence?: readonly DriversLicence[] | DriversLicence[]; 18111 medicare?: readonly MedicareCard[] | MedicareCard[]; 18112 passport?: readonly Passport[] | Passport[]; 18113 tfn?: readonly string[] | string[]; 18114 18115 // Employment 18116 employment?: readonly Employment[] | Employment[]; 18117 18118 // Banking (for Payright deposits/debits) 18119 bankAccounts?: readonly BankAccount[] | BankAccount[]; 18120 18121 // Household / context (first-value-wins scalars) 18122 maritalStatus?: MaritalStatus; 18123 dependants?: number; 18124 housingStatus?: HousingStatus; 18125 residencyStatus?: ResidencyStatus; 18126 18127 // Address history (temporal â separate from MasterProfile.addresses[]) 18128 addressHistory?: readonly AddressHistoryEntry[] | AddressHistoryEntry[]; 18129 18130 /** Explicit home-ownership signal. Finance affordability + partner-product segment. */ 18131 homeOwner?: boolean; 18132} 18133 18134// âââ Finance Applications (per-transaction records from BNPL portals) âââââââ 18135// 18136// Lead.financeApplications[] is the denormalised summary of every BNPL 18137// application/agreement the merchant portals (Humm, Payright, future Zip / 18138// Klarna / Afterpay) have shown for this lead. The provider-records tables 18139// (humm-records, future payright-records) remain the source of truth for raw 18140// scrape data â Lead-side carries a slim summary plus a \`raw\` escape hatch so 18141// new fields land on the Lead automatically when providers expose them, no 18142// re-backfill required. 18143// 18144// Two-tier separation (CRITICAL â do NOT duplicate person-level facts here): 18145// - Person-level facts (drivers licence, occupation, employer, banking, DOB) 18146// belong on \`Lead.masterProfile.financeProfile\` (line 935) and are deduped 18147// by the existing pii-merge utility â ONE entry per fact across N 18148// applications. 18149// - Application-level facts (loan amount, status, application id, dates, 18150// merchant, provider) live HERE â one entry per applicationId. 18151 18152/** BNPL provider currently scraped. Extend the union when adding a provider. */ 18153export type FinanceProvider = 'humm'; // 'payright' | 'zip' | 'klarna' | 'afterpay' added when wired 18154 18155/** Provider-side row classification, unioned to avoid free-string drift. */ 18156export type FinanceApplicationRecordType = 18157 | 'pending_application' // Humm: applications still in flight 18158 | 'customer_purchase' // Humm: sale closed via finance 18159 | 'agreement' // generic â Payright (forecast) / others 18160 | 'application' // generic â any provider can stamp this 18161 | 'plan'; // generic â any provider can stamp this 18162 18163/** 18164 * Application lifecycle stage at the provider â provider-agnostic enum. 18165 * Mapped from each provider's raw status text by mapXxxStatus() helpers in 18166 * \`@bigm/shared/finance-status\`. 18167 * 18168 * Buckets defined against real Humm production data (1,878 rows scanned 18169 * 2026-05-08): 18170 * pending â any in-progress state (ID check, Income verification, 18171 * Referred, Declare income, Declare liabilities, Retry ID 18172 * check, Repayment goals, Add funding source, Purchase amount,
18173 * 'N/A' on a pending row) 18174 * approved â Approved + Partially approved 18175 * declined â Declined 18176 * cancelled â Cancelled, Customer Withdrew 18177 * completed â every customer_purchase row (sale closed via finance) 18178 * error â Error, General Issue, Duplicate user error (15% of pendings â 18179 * stuck on portal/integration, NEEDS operator triage. Distinct 18180 * from \`pending\` because the customer is not the blocker.) 18181 * unknown â safety net for new statuses providers introduce 18182 */ 18183export type FinanceApplicationStatus = 18184 | 'pending' 18185 | 'approved' 18186 | 'declined' 18187 | 'cancelled' 18188 | 'completed' 18189 | 'error' 18190 | 'unknown'; 18191 18192/** 18193 * Single BNPL application/agreement summary stored on \`Lead.financeApplications[]\`. 18194 * 18195 * Sourced from the provider-records tables (humm-records, future 18196 * payright-records) by the scraper Lambda. Source-of-truth raw data stays in 18197 * those tables; this is a denormalised slice for fast per-lead reads. 18198 * 18199 * Size budget: ~330 bytes typed + ~500 bytes \`raw\` â 830 bytes per entry. 18200 * 50 applications across providers = ~42 KB, well under DynamoDB's 400 KB 18201 * item limit (Lead row). 18202 */ 18203export interface FinanceApplication { 18204 // Discriminator + provider-side identity 18205 /** Which BNPL provider produced this row. */ 18206 provider: FinanceProvider; 18207 /** Provider's row id. Humm: customerReferenceId (15-char Salesforce ID for 18208 * pending_application, \`APP-N\` sequential for customer_purchase). */ 18209 applicationId: string; 18210 /** Provider-side row classification. */ 18211 recordType: FinanceApplicationRecordType; 18212 /** Which retailer account in the merchant portal the row was scraped under 18213 * (e.g. 'MASSEUSE MASSAGE'). Provider-side display name. */ 18214 merchantStoreName: string | null; 18215 18216 // Status 18217 /** Normalised provider-agnostic enum used to drive UI badges. */ 18218 status: FinanceApplicationStatus; 18219 /** Raw provider status text (e.g. 'Income verification', 'Error', 18220 * 'Customer Withdrew'). Surfaced in tooltips + future-proofs against 18221 * unknown statuses. */ 18222 statusRaw: string; 18223 18224 // Customer snapshot at scrape time (denormalised â convenient for UI list 18225 // rendering. Authoritative person facts live on Lead.masterProfile.) 18226 customerName: string; 18227 email: string | null; 18228 phone: string | null; // E.164 18229 18230 // Application financials 18231 /** Loan amount in whole AUD (Humm: 6067 = $6,067 â no cents). */ 18232 loanAmount: number | null; 18233 /** Provider-side application type (Humm: 'Seller-Led' / '' / null). */ 18234 purchaseType: string | null; 18235 /** ISO 8601 â when provider created the row. */ 18236 createdAt: string; 18237 18238 // Deep-link to the row in the merchant portal â best-effort during scrape. 18239 // null when scraper couldn't extract row href (UI falls back to opening the 18240 // provider's list view by recordType + the agent finds the row by id). 18241 portalUrl: string | null; 18242 18243 // Provenance â always set by the scraper. 18244 firstSeenAt: string; // first scrape that saw this row 18245 lastSyncedAt: string; // bumped every scrape 18246 removedAt?: string; // set when row falls off source list 18247 18248 /** 18249 * Escape hatch â full provider-side row verbatim, per-provider shape. 18250 * 18251 * Anti-backfill insurance: when a provider exposes a new field tomorrow, 18252 * it lands here automatically. Promoting any \`raw.fieldName\` to a typed 18253 * top-level field later is a one-shot script that reads from Lead and 18254 * writes to Lead â no source-of-truth-data-is-gone problem. 18255 * 18256 * Treated as opaque per-provider; do NOT couple inter-provider readers to 18257 * specific keys here. 18258 */ 18259 raw: Record<string, unknown>; 18260} 18261 18262export interface MasterProfile { 18263 // Contact identifiers (deduplicated arrays) 18264 emails?: readonly string[] | string[]; 18265 phones?: readonly string[] | string[]; 18266 18267 // Personal info (first value wins) 18268 firstName?: string; 18269 lastName?: string; 18270 18271 // Full names extracted from various sources (call transcripts, etc.) 18272 names?: readonly string[] | string[]; 18273 18274 // Phase 8d (2026-05-09): \`addresses?: NormalizedAddress[]\` REMOVED. 18275 // Use billingAddress + shippingAddress canonical singletons below. 18276 // Audit history of address changes lives in events-customer (every 18277 // billing/shipping write has a corresponding event with createdAt + _source).
18278 18279 // Canonical billing address. Mirrors Shopify's Customer.defaultAddress / 18280 // Order.billingAddress shape so checkout-ready snapshot use cases can drop 18281 // in directly. Undefined when no billing address has ever been observed. 18282 // 18283 // Sources, in priority order: 18284 // 1. eventData.billing_address on order / checkout / draft_order events 18285 // (latest-write-wins among these â most recent order is authoritative). 18286 // 2. Operator field-diff edits via /toolcall/update-shopify-customer. 18287 // 3. eventData.default_address on customers/* webhook events â seeds when 18288 // empty only, NEVER overwrites a value from (1) or (2). Customer 18289 // webhooks don't separate billing vs shipping at the customer level, 18290 // so default_address seeds BOTH billingAddress AND shippingAddress 18291 // (when each is empty). 18292 billingAddress?: NormalizedAddress; 18293 18294 // Canonical shipping address. Same shape + precedence rules as 18295 // billingAddress (see comment above). Often differs from billingAddress 18296 // for gift orders, drop-ship, or business deliveries â keeping it separate 18297 // preserves that distinction (the legacy \`addresses[]\` array flattened 18298 // them). For customers/* webhook events, default_address seeds BOTH 18299 // billingAddress AND shippingAddress when each is empty. 18300 shippingAddress?: NormalizedAddress; 18301 18302 // Phase 8e (2026-05-12): Address verification verdict from Google Address 18303 // Validation API. Stamped by the address-validator Lambda subscribed to the 18304 // Lead DDB stream. Loop guard via \`addressHash\` field on AddressVerification. 18305 // See AddressVerification interface above for the verdict-mapping nuance. 18306 billingAddressVerification?: AddressVerification; 18307 shippingAddressVerification?: AddressVerification; 18308 18309 // Metadata 18310 tags?: readonly string[] | string[]; 18311 isSharedEmail?: boolean; 18312 isPhoneEmail?: boolean; 18313 verifiedEmail?: boolean; 18314 18315 // External IDs 18316 /** @deprecated since 2026-05-07 â read/write \`Lead.shopifyCustomerId\` (top-level) instead. Kept for one-release backward read compat; will be removed in Phase 1d. */ 18317 shopifyCustomerId?: number; 18318 /** @deprecated since 2026-05-07 â read/write \`Lead.shopifyCustomerLinkedAt\` (top-level) instead. */ 18319 shopifyCustomerLinkedAt?: string; 18320 /** @deprecated since 2026-05-07 â read/write \`Lead.shopifyCustomerLinkSource\` (top-level) instead. */ 18321 shopifyCustomerLinkSource?: string; 18322 wickedContactId?: string; 18323 18324 // Computed values 18325 ltv?: number; 18326 18327 // Sensitive PII extracted from call analysis 18328 creditCards?: readonly string[] | string[]; 18329 datesOfBirth?: readonly string[] | string[]; 18330 socialSecurityNumbers?: readonly string[] | string[]; 18331 otherPII?: readonly OtherPiiItem[] | OtherPiiItem[]; 18332 18333 // Physical profile â used for massage-chair fit (populated by call-analysis / form capture). 18334 // Free-form strings; formatting ("185cm" / "6'1\\"", "92kg" / "200lb") is decided by the populating process. 18335 height?: string; 18336 weight?: string; 18337 // Numeric parses of the above â enable range queries ("leads 180cm+ for XL chair"). 18338 // Populated by text-pii-extractor when height/weight are recognised; first-value-wins. 18339 heightCm?: number; 18340 weightKg?: number; 18341 18342 /** 18343 * Everyone the purchase is for, one entry per person (Chris, 4 Oct 2026: "my husband weighs X and I weigh Y" must 18344 * live on the one lead). \`self\` is the customer; the single fields above stay the customer's own. Filled by the 18345 * text-pii-extractor from texts, emails and call turns; merged by relation (+ name): numbers newest-wins with the 18346 * quote kept, lists dedup-append. Sensitive PII: never log values. 18347 */ 18348 household?: readonly HouseholdMember[] | HouseholdMember[]; 18349 /** What a product has to fit, derived from every household member (tallest, heaviest). */ 18350 fit?: { tallestCm?: number; heaviestKg?: number; users?: number }; 18351 18352 // Sales qualification â populated by agents or AI during the sales cycle. 18353 /** Free-form price band the customer indicated, e.g. "$4â6k", "under $5000". */ 18354 priceRange?: string; 18355 /** Recommended chair model for this lead, e.g. "Masseuse Health+ Pro". */ 18356 recommendedChair?: string; 18357 18358 // Finance application pre-fill data (Humm / Payright / Zip) 18359 financeProfile?: FinanceProfile; 18360 18361 // Medical / pain context â the #1 sales qualifier for massage chairs. 18362 // Populated by the text-pii-extractor (regex + Claude Haiku) from call turns 18363 // and inbound text channels. Never shown in public UI; dedup-append only. 18364 healthProfile?: HealthProfile; 18365 18366 // Contact preferences â opt-outs + channel/timing preferences. 18367 contactPreferences?: ContactPreferences; 18368 18369 // Sales context â hurdle, objections, intent, timeline, logistics (BANT).
18370 salesContext?: SalesContext; 18371 18372 // Demographics â age/life-stage/occupation/veteran for partner-product cross-sell. 18373 demographics?: Demographics; 18374 18375 // Lifestyle â interests, pets, living arrangement, household size. 18376 lifestyle?: Lifestyle; 18377 18378 // Consent â privacy flags gating cross-sell and marketing. 18379 consent?: ConsentFlags; 18380 18381 // Recent life events â retirement / bereavement / moving / kids moved out. 18382 // These are trigger-based targeting signals. Dedupe-append string array. 18383 recentLifeEvents?: readonly string[] | string[]; 18384 18385 // Ineligibility flags â sales/finance disqualifiers extracted by the PII 18386 // workload (Pacemaker, Unemployed, 89yo cannot get finance, etc). SME-reviewable 18387 // via ProfilePane. Each item carries provenance (sourceQuote, rationale, 18388 // promptVersion) + review state (proposed | accepted | rejected). Rejected 18389 // items are sticky â the merge helper never resurrects them. 18390 ineligibilityReasons?: readonly IneligibilityReason[] | IneligibilityReason[]; 18391 18392 // Referral source â how the customer heard about the product. 18393 // "Facebook ad", "friend recommended", "Google search", "billboard". 18394 referralSource?: string; 18395 18396 // Source tracking - records which event first provided each value 18397 _sources?: MasterProfileSources; 18398 18399 // PII extraction source tracking for compliance/audit 18400 piiSources?: PiiSourcesTracking; 18401 18402 // ââ Generic SME review state (side-channel for non-ineligibility PII) ââ 18403 /** 18404 * Per-field SME review state. Keyed by canonical field path: 18405 * - Scalars: \`masterProfile.salesContext.primaryHurdle\` 18406 * - String-array elements: \`masterProfile.healthProfile.conditions:Osteoporosis\` 18407 * 18408 * Existing field shapes (\`string[]\`, scalars) stay untouched â review state 18409 * lives in this side-channel so existing readers don't break. 18410 */ 18411 reviewState?: Record<string, FieldReviewState>; 18412 18413 /** 18414 * Verbatim source quotes from the most-recent extraction, keyed the same way 18415 * as \`reviewState\`. Populated by the merge helper from \`pii.json._sourceQuotes\` 18416 * (when the prompt emits them for the field). Read-only display data. 18417 */ 18418 _sourceQuotes?: Record<string, string>; 18419} 18420 18421// ============================================================================ 18422// Agent Action State Types 18423// ============================================================================ 18424 18425/** 18426 * Agent action status lifecycle: 18427 * - 'pending': Action created, waiting for agent to act 18428 * - 'actioned': Agent took the required action (e.g. replied) 18429 * - 'dismissed': Agent dismissed the action manually 18430 * - 'expired': Action expired without being actioned 18431 */ 18432export type AgentActionStatus = 'pending' | 'in_progress' | 'actioned' | 'dismissed' | 'expired' 18433 /** AI-owned next best action waiting for its slot (piece D). Never a badge: 18434 * every live-action reader keys on \`pending | in_progress\`. */ 18435 | 'scheduled'; 18436 18437/** Type of action the agent needs to take. 18438 * \`reseller_booking_review\` â a reseller showroom visit was just booked (calendar or AI) and 18439 * the RESELLER MANAGER reviews it. Raised by \`bookResellerAppointment\` in \`@bigm/shared\`, 18440 * assigned DIRECTLY to \`resellerAppointments.managerAgentEmail\`'s agent (never the lead 18441 * owner), sales-class (red badge), 7-day expiry, bypasses cooldown + capacity so it is never 18442 * silently dropped. Chris, 17 Sep 2026: "a new reseller appointment booked sales agent action 18443 * (red badge) to go directly to Steve. This will trigger his review." 18444 * \`reseller_reply\` â a RESELLER (third party, not the customer) texted back 18445 * about a reseller appointment. Deliberately NOT \`reply_sms\`: that type sits 18446 * in the customer-reply lane, and putting a reseller's words there invites an 18447 * agent to text the customer by mistake. Raised with a 7-day expiry and the 18448 * message text in \`description\` (never into the customer's /messages thread). */ 18449export type AgentActionType = 'reply_sms' | 'reply_email' | 'reply_site_chat' | 'review_form' | 'follow_up_call' | 'review_lead' | 'review_facebook' | 'close_lead' | 'confirm_sales_attribution' | 'ai_escalated' | 'reseller_reply' | 'reseller_booking_review' | 'delivery_review' | 'service_email_reply' | 'delivery_exception' | 'welcome_hold' | 'service_sms_reply' 18450 /** The AI-owned next best action slot holder (piece D). Only ever carried with 18451 * \`status: 'scheduled'\`; in no lane list; the badge maps carry it at priority 99 18452 * so the compile-enforced \`Record<AgentActionType, â¦>\` maps stay total. */ 18453 | 'ai_next_action'; 18454/** 18455 * SERVICE actions (purple in ShopDash, the \`tenant_service\` role's queue). All four are raised 18456 * with the sales owner's cooldown/capacity gates bypassed and left UNASSIGNED (claims off â the 18457 * service view is an open queue). Sales roles never see them as badges or counts; the service 18458 * role sees only them. The canonical list is \`SERVICE_ACTION_TYPES\` in \`@bigm/shared\`. 18459 * 18460 * \`delivery_review\` â the platform booked a Winnings delivery automatically (or could not, and 18461 * says why) and a service person must check the booking is correct. Raised by 18462 * \`sap-delivery-poller\`;
18462 closed by the review buttons on the order card (\`delivery.reviewed\`). 18463 * \`service_email_reply\` â a customer wrote to the tenant's \`service@\` mailbox (post-purchase: 18464 * welcome, delivery, dispatch, returns). Raised by the per-tenant \`workflow-inbound-email-service-*\` 18465 * row, which routes on the recipient address (\`eventData.to\`), never on a derived predicate. 18466 * Sales mail to \`team@\` stays \`reply_email\`. 18467 * \`delivery_exception\` â Winnings reported \`delivery.failed\` or moved the date 18468 * (\`delivery.date_changed\`); a service person contacts the customer. Raised by 18469 * \`sap-delivery-poller\` when it emits those events. 18470 * \`welcome_hold\` â the automated welcome email could not be sent (lead time, SKU not mapped, 18471 * order flagged) and a service person sends it by hand. Raised by the engine's 18472 * \`resolve_welcome_content\` step (\`holdActionType\` on the welcome rows). 18473 * \`service_sms_reply\` â a customer texted back on a SERVICE conversation (the last text came 18474 * from a service seat or a service-purpose workflow, or they hold a service call-back). 18475 * Raised by the per-tenant \`workflow-inbound-sms-service-*\` row via the engine's 18476 * \`check_last_outbound_sms_lane\`; sales texts stay \`reply_sms\`. 18477 */ 18478 18479export interface AgentActionState { 18480 /** Current status in the action lifecycle */ 18481 status: AgentActionStatus; 18482 18483 /** What the agent needs to do */ 18484 actionType: AgentActionType; 18485 18486 /** MaxContact userId of the assigned agent (from distribution step) */ 18487 assignedAgentMaxId?: string; 18488 18489 /** Event ID that triggered this action */ 18490 triggerEventId: string; 18491 18492 /** Event type that triggered this action (e.g. 'inbound_sms') */ 18493 triggerEventType: string; 18494 18495 /** Action priority â numeric composite score (new) or legacy string (backward compat) */ 18496 priority: number | 'normal' | 'high'; 18497 18498 /** Priority tier derived from numeric score */ 18499 priorityTier?: import('./lead-prioritisation.js').PriorityTier; 18500 18501 /** Breakdown of how the priority score was computed */ 18502 priorityBreakdown?: import('./lead-prioritisation.js').ScoreBreakdown; 18503 18504 /** Human-readable narrative describing why this action was created */ 18505 description?: string; 18506 18507 /** Details of the action this one superseded (if any) */ 18508 supersededAction?: import('./lead-prioritisation.js').SupersededAction; 18509 18510 /** SERVICE-lane close outcome (15 Sep 2026): stamped by the service login's Close 18511 * dialog when a purple action is dismissed. Never a sales disposition â the 18512 * customer stays wherever they are in the pipeline. Mirrored onto the 18513 * agent.action_completed event as \`eventData.serviceOutcome\`. */ 18514 serviceOutcome?: 'resolved' | 'callback_booked' | 'escalated' | 'no_action'; 18515 18516 /** ISO timestamp when action was created */ 18517 createdAt: string; 18518 18519 /** Unix epoch timestamp when this action expires (optional) */ 18520 expiresAt?: number; 18521 18522 /** ISO timestamp when agent acted on this */ 18523 actionedAt?: string; 18524 18525 /** 18526 * ISO timestamp when status flipped to 'in_progress' (call live). 18527 * Used by frontend stale-fallback: treat 'in_progress' older than ~30min as 'pending' for rendering. 18528 */ 18529 inProgressAt?: string; 18530 18531 /** ISO timestamp of last cooldown (prevents re-creation) */ 18532 lastCooldownAt?: string; 18533 18534 // âââ Next best action (piece D, plan \`next-best-action-engine.md\`) âââââââââ 18535 /** Who does this action. Absent = \`agent\` (every pre-D action). */ 18536 owner?: 'agent' | 'ai'; 18537 /** Entry in the tenant's action catalogue this action executes. */ 18538 catalogueId?: string; 18539 /** Channel the action runs on. */ 18540 channel?: 'sms' | 'email' | 'phone' | 'messenger' | 'chat'; 18541 /** AI-owned: the 15-minute slot (ISO) the action runs at. */ 18542 slotAt?: string; 18543 /** ISO twin of \`expiresAt\` (epoch ms) for readers that want a timestamp. */ 18544 nbaExpiresAt?: string; 18545 /** Agent-owned: the AI entry the rebalancer schedules when \`expiresAt\` passes (null = no takeover). */ 18546 aiFallbackCatalogueId?: string | null; 18547 /** Reason codes the computer produced (shown to agents as "why this action"). */ 18548 nbaReasons?: string[]; 18549 /** The computer's top candidates (the due turn lets the model choose among them). */ 18550 nbaCandidates?: Array<{ catalogueId: string; score: number; channel: string; reasons: string[] }>; 18551 nbaComputedAt?: string; 18552 /** AI-owned: set when the sweep wrote \`nba.action_due\` (conditional, so a double sweep cannot double-send). */ 18553 dispatchedAt?: string; 18554 /** Why this slot: "their best SMS hour, 2pm, 8 of 10 replies" / "new lead: earliest slot". */ 18555 slotReason?: string; 18556 /** The chooser's one-line reason (provenance inferred) and its confidence 0..1. */ 18557 rationale?: string; 18558 confidence?: number; 18559} 18560 18561// âââ Best time to contact, per lead, per channel (piece D) âââââââââââââââââââ 18562 18563/** One channel's timing for a lead. \`hours\` are local hours (0..23) ranked best first. */ 18564export interface ContactChannelTiming { 18565 hours: number[]; 18566 /** Days of week (0 = Sunday) when present in the data. */ 18567 days?: number[]; 18568 /** Observations behind \`hours\` (replies / answered calls on this channel). */ 18569 n: number; 18570 /** Hit rate behind the best hour when known. */ 18571 rate?: number; 18572 /** stated = the customer said so (transcript preference); lead = their own history; tenant = the tenant's learned table. */ 18573 basis: 'stated' | 'lead' | 'tenant'; 18574 /** Free-text window from a stated preference, e.g. "after 5pm". */ 18575 stated?: string; 18576} 18577 18578/** Best contact times per channel, written by the NBA compute (decision 2 of the D plan). */ 18579export interface ContactTiming { 18580 updatedAt: string; 18581 /** IANA zone the hours are expressed in. */ 18582 timezone: string; 18583 sms?: ContactChannelTiming; 18584 call?: ContactChannelTiming; 18585 email?: ContactChannelTiming; 18586 messenger?: ContactChannelTiming; 18587 chat?: ContactChannelTiming; 18588} 18589 18590// âââ Sales Cycle ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 18591 18592/** 18593 * Fixed pipeline stages for the sales cycle.
18594 * Progression: untapped â engaged â offer â pending â sold 18595 */ 18596export type SalesCycleStage = 'untapped' | 'engaged' | 'offer' | 'pending' | 'sold'; 18597 18598/** Agent-assessed buyer temperature â how ready is this lead to purchase? */ 18599export type BuyerReadiness = 'hot' | 'warm' | 'cold'; 18600 18601 18602/** 18603 * Sales cycle state on a Lead record. 18604 * \`suggestedStage\` is auto-calculated by create-lead-and-identity on each event. 18605 * \`stage\` is the confirmed stage (auto-set on init/sold, or set when agent approves via Messages dismiss). 18606 */ 18607export interface LeadSalesCycle { 18608 /** Confirmed stage (agent-approved or auto-set on init and order.confirmed) */ 18609 stage: SalesCycleStage; 18610 /** System's current recommendation based on event history */ 18611 suggestedStage: SalesCycleStage; 18612 /** Agent who confirmed the stage (absent if auto-set) */ 18613 stageConfirmedBy?: string; 18614 stageConfirmedAt?: string; 18615 18616 /** ISO timestamp of when suggestedStage last changed (for pipeline velocity & deal aging) */ 18617 suggestedStageEnteredAt?: string; 18618 18619 /** Number of completed sales cycles (incremented on re-entry from sold) */ 18620 cycleCount?: number; 18621 /** ISO timestamp of when the lead last reached 'sold' (set on re-entry) */ 18622 lastSoldAt?: string; 18623} 18624 18625/** 18626 * Tenant-level sales cycle configuration. 18627 * Stored on TenantConfig. Controls event-to-stage mapping and re-entry behaviour. 18628 */ 18629export interface SalesCycleConfig { 18630 /** 18631 * Event type â sales cycle stage mapping. 18632 * Determines which stage each event type advances the lead to. 18633 * Keys are EventCustomerType values, values are SalesCycleStage. 18634 * e.g. { "form.submitted": "engaged", "checkout.started": "offer", "order.confirmed": "sold" } 18635 */ 18636 stageMapping: Record<string, SalesCycleStage>; 18637 18638 /** 18639 * Event types that restart the sales cycle when a lead is currently 'sold'. 18640 * These represent genuine new buying intent (not post-purchase browsing). 18641 * e.g. ["cart.added", "checkout.started", "form.submitted", "inbound_sms"] 18642 */ 18643 reentryEvents: string[]; 18644} 18645 18646// âââ Conversation Disposition âââââââââââââââââââââââââââââââââââââââââââââââ 18647 18648/** 18649 * Sales pipeline stage codes with numeric progression (1â5). 18650 * Higher number = further through the sales pipeline. 18651 * 18652 * Agents select one of these when closing/resolving a conversation 18653 * to indicate how far the sales process progressed. 18654 */ 18655export type DispositionStageCode = 18656 | 'enquiry_received' 18657 | 'product_identified' 18658 | 'appointment_booked' 18659 | 'quote_sent' 18660 | 'sold'; 18661 18662/** 18663 * Exit codes for conversations closed without a sale. 18664 * These sit outside the 1â5 pipeline progression (score = 0). 18665 */ 18666export type DispositionExitCode = 18667 | 'not_interested' 18668 | 'did_not_enquire' 18669 | 'no_action_required' 18670 | 'closed_lost' 18671 | 'stale'; 18672 18673// âââ Closed Lost Reasons ââââââââââââââââââââââââââââââââââââââââââââââââââ 18674 18675/** 18676 * Structured reasons for why a lead exited the sales pipeline without converting. 18677 * Required when disposition code is 'closed_lost'. 18678 */ 18679export type ClosedLostReason = 18680 | 'competitor' 18681 | 'finance_declined' 18682 | 'budget' 18683 | 'timing' 18684 | 'no_response' 18685 | 'product_fit' 18686 | 'changed_mind'; 18687 18688export interface ClosedLostReasonInfo { 18689 reason: ClosedLostReason; 18690 label: string; 18691 description: string; 18692} 18693 18694export const CLOSED_LOST_REASON_INFO: ClosedLostReasonInfo[] = [ 18695 { reason: 'competitor', label: 'Bought from Competitor', description: 'Customer purchased from another business' }, 18696 { reason: 'finance_declined', label: 'Finance Declined', description: 'Finance/credit application was rejected' }, 18697 { reason: 'budget', label: 'Too Expensive', description: 'Price or budget constraints' }, 18698 { reason: 'timing', label: 'Not Ready / Bad Timing', description: 'Customer not ready to purchase now' }, 18699 { reason: 'no_response', label: 'No Response', description: 'Went cold after multiple follow-up attempts' }, 18700 { reason: 'product_fit', label: 'Not the Right Fit', description: 'Product or service doesn\\'t meet their needs' }, 18701 { reason: 'changed_mind', label: 'Changed Their Mind', description: 'Customer decided not to proceed' }, 18702]; 18703 18704/** All valid disposition codes â either a pipeline stage or an exit */ 18705export type ConversationDispositionCode = DispositionStageCode | DispositionExitCode;
18706 18707/** Metadata for a single disposition code â used for UI display and agent guidance */ 18708export interface DispositionCodeInfo { 18709 /** The code value */ 18710 code: ConversationDispositionCode; 18711 /** Numeric score: 1â5 for pipeline stages, 0 for exits */ 18712 score: number; 18713 /** Short label shown in buttons/badges (e.g. "Quote Sent") */ 18714 label: string; 18715 /** One-line description to help agents pick the right stage */ 18716 description: string; 18717 /** Concrete examples so agents know exactly when to use this */ 18718 examples: string[]; 18719 /** Whether this is a pipeline stage (true) or an exit/close (false) */ 18720 isPipelineStage: boolean; 18721} 18722 18723/** 18724 * Registry of all disposition codes with scores, labels, descriptions, and examples. 18725 * This is the single source of truth â UI components render from this array. 18726 */ 18727export const DISPOSITION_CODE_INFO: DispositionCodeInfo[] = [ 18728 // ââ Pipeline stages (1â5) ââ 18729 { 18730 code: 'enquiry_received', 18731 score: 1, 18732 label: 'Enquiry Received', 18733 description: 'Initial contact made â customer has reached out but no specifics discussed yet.', 18734 examples: [ 18735 '"Hi, I saw your ad and want to know more"', 18736 'Missed call from a new number â no voicemail', 18737 'Form submitted with just a name and phone number', 18738 ], 18739 isPipelineStage: true, 18740 }, 18741 { 18742 code: 'product_identified', 18743 score: 2, 18744 label: 'Product Identified', 18745 description: 'Customer has discussed or shown interest in a specific product or service.', 18746 examples: [ 18747 '"I\\'m interested in the 60-minute deep tissue massage"', 18748 'Customer asked about pricing for a specific item', 18749 'Browsed a product page and then messaged asking about availability', 18750 ], 18751 isPipelineStage: true, 18752 }, 18753 { 18754 code: 'appointment_booked', 18755 score: 3, 18756 label: 'Appointment Booked', 18757 description: 'A meeting, demo, call-back, or visit has been scheduled.', 18758 examples: [ 18759 'Customer booked an appointment for next Tuesday', 18760 '"I\\'ll come into the store this Saturday afternoon"', 18761 'Scheduled a follow-up call for tomorrow at 2pm', 18762 ], 18763 isPipelineStage: true, 18764 }, 18765 { 18766 code: 'quote_sent', 18767 score: 4, 18768 label: 'Quote Sent', 18769 description: 'Pricing, proposal, or formal quote has been provided to the customer.', 18770 examples: [ 18771 'Sent a quote via email with package pricing', 18772 '"The total for that service would be $120 â I\\'ve sent you the details"', 18773 'Customer received a draft order link with discount applied', 18774 ], 18775 isPipelineStage: true, 18776 }, 18777 { 18778 code: 'sold', 18779 score: 5, 18780 label: 'Sold', 18781 description: 'Deal is closed â customer has committed, paid, or completed the purchase.', 18782 examples: [ 18783 'Customer completed checkout and order is confirmed', 18784 'Payment received in-store after the conversation', 18785 '"Great, I\\'ve booked and paid â see you Thursday!"', 18786 ], 18787 isPipelineStage: true, 18788 }, 18789 18790 // ââ Exit codes (score 0) ââ 18791 { 18792 code: 'not_interested', 18793 score: 0, 18794 label: 'Not Interested', 18795 description: 'Customer is no longer interested or has gone cold.', 18796 examples: [ 18797 '"Thanks but I\\'ve decided to go with someone else"', 18798 'No response after multiple follow-ups', 18799 'Customer explicitly said they don\\'t need the service', 18800 ], 18801 isPipelineStage: false, 18802 }, 18803 { 18804 code: 'did_not_enquire', 18805 score: 0, 18806 label: 'Did Not Enquire', 18807 description: 'Not a real enquiry â wrong number, spam, or accidental contact.', 18808 examples: [ 18809 'Wrong number â customer was looking for a different business', 18810 'Spam or bot message', 18811 'Accidental form submission with no real intent', 18812 ], 18813 isPipelineStage: false, 18814 }, 18815 { 18816 code: 'no_action_required', 18817 score: 0, 18818 label: 'No Action Required', 18819 description: 'Informational message only â nothing for the dealer to act on.', 18820 examples: [ 18821 'Customer confirming they received a previous message', 18822 '"Thanks, that\\'s all I needed to know"', 18823 'Auto-reply or out-of-office response', 18824 ], 18825 isPipelineStage: false, 18826 }, 18827 { 18828 code: 'closed_lost', 18829 score: 0, 18830 label: 'Closed Lost', 18831 description: 'Lead was in the pipeline but did not convert â a specific reason is required.', 18832 examples: [ 18833 'Customer bought from a competitor', 18834 'Finance application was declined', 18835 'Customer decided the product wasn\\'t right for them', 18836 ], 18837 isPipelineStage: false, 18838 }, 18839 { 18840 code: 'stale', 18841 score: 0, 18842 label: 'Gone Stale', 18843 description: 'No activity or progression for an extended period.', 18844 examples: [ 18845 'Lead imported from legacy system with no tracked engagement', 18846 'No calls, emails, or website activity for 90+ days', 18847 'Historical contact that never entered the active pipeline', 18848 ], 18849 isPipelineStage: false, 18850 }, 18851]; 18852 18853/** Quick lookup: code â DispositionCodeInfo */ 18854export const DISPOSITION_CODE_MAP = new Map<ConversationDispositionCode, DispositionCodeInfo>( 18855 DISPOSITION_CODE_INFO.map(info => [info.code, info]) 18856); 18857 18858/** Get the numeric score for a disposition code (0 for exits, 1â5 for pipeline) */ 18859export function getDispositionScore(code: ConversationDispositionCode): number { 18860 return DISPOSITION_CODE_MAP.get(code)?.score ?? 0; 18861} 18862 18863// ââ Disposition Review (manager approval of bad max result codes) ââ 18864 18865export type DispositionReviewStatus = 'pending' | 'approved' | 'rejected'; 18866 18867export interface DispositionReviewAction { 18868 action: 'approve' | 'reject'; 18869 reviewerEmail: string; 18870 reviewedAt: string; 18871} 18872 18873export interface DispositionReview { 18874 status: DispositionReviewStatus;
18875 code: string; 18876 agentMaxId: string; 18877 codeSetAt: string; 18878 history: DispositionReviewAction[]; 18879} 18880 18881/** Records a disposition applied to a conversation/lead */ 18882export interface ConversationDisposition { 18883 /** The selected disposition code */ 18884 code: ConversationDispositionCode; 18885 /** Numeric score at time of disposition (denormalized for queries) */ 18886 score: number; 18887 /** Display name of agent who disposed */ 18888 disposedBy: string; 18889 /** Email of agent who disposed */ 18890 disposedByEmail: string; 18891 /** ISO timestamp when disposition was applied */ 18892 disposedAt: string; 18893 /** Which channel the conversation was on when disposed */ 18894 channel: string; 18895 /** Optional free-text note from the agent */ 18896 note?: string; 18897 /** Required when code is 'closed_lost' â why the deal was lost */ 18898 closedLostReason?: ClosedLostReason; 18899} 18900 18901/** 18902 * Lead record stored in DynamoDB Lead table 18903 */ 18904/** 18905 * The A/B/C appointment slots the opener offered this lead, so the AI handler 18906 * can resolve a letter reply ("B") to a concrete time deterministically â no 18907 * fragile parsing of the SMS body, no model time-judgement. 18908 */ 18909export interface AppointmentOffer { 18910 slots: { label: 'A' | 'B' | 'C'; startUtc: string }[]; 18911 /** ISO 8601 UTC when the slots were offered */ 18912 offeredAt: string; 18913 /** Opener/booking campaign that made the offer */ 18914 campaignId: string; 18915 /** Which dynamic-opener segment this lead was messaged as (for per-segment 18916 * funnel measurement). Set only when the dynamic_opener capability is on. */ 18917 segment?: 'prospect' | 'customer'; 18918 /** IANA timezone the slot labels were DISPLAYED in when the offer SMS was 18919 * built (address-derived at the opener, e.g. Australia/Perth for a WA-address 18920 * lead). Any consumer reading back the wall-clock the customer SAW must use 18921 * this â never assume Melbourne. Absent on legacy offers â Melbourne. */ 18922 displayTz?: string; 18923} 18924 18925export interface Lead { 18926 /** Primary key - UUID */ 18927 id: string; 18928 18929 /** Tenant identifier (for multi-tenancy) */ 18930 tenantName: string; 18931 18932 /** Primary phone number (E.164 format, optional) */ 18933 phoneNumber?: string; 18934 18935 /** Primary email address (optional) */ 18936 emailAddress?: string; 18937 18938 /** Original phone number before normalization (for error tracking) */ 18939 phoneNumberOriginal?: string; 18940 18941 /** Phone number validation error flag (string: 'null', 'anonymous', 'non-digits', 'other', or empty if no error) */ 18942 phoneNumberError?: string; 18943 18944 /** Original email address before validation (for error tracking) */ 18945 emailAddressOriginal?: string; 18946 18947 /** Email address validation error flag */ 18948 emailAddressError?: string; 18949 18950 /** Lead data (name, etc.) stored as JSON */ 18951 leadData?: Record<string, unknown> | null; 18952 18953 /** A/B/C slots last offered to this lead by the appointment opener (read by 18954 * the AI handler to resolve a letter reply). Overwritten on each new offer. */ 18955 appointmentOffer?: AppointmentOffer; 18956 18957 /** THE master of the customer's Australian state/territory â the single 18958 * authoritative source for "which state is this customer in", set when the 18959 * customer explicitly confirms it (e.g. over SMS booking). Drives the 18960 * appointment DISPLAY timezone (via \`auStateToTimezone\`) and is the canonical 18961 * state for any other consumer. Deliberately SEPARATE from 18962 * \`masterProfile.billingAddress.provinceCode\` (order/billing data) so a 18963 * customer's stated location never clobbers their billing address. Absent â 18964 * fall back to the address state, else Melbourne. */ 18965 confirmedState?: AuState; 18966 18967 /** When the "save our contact card" SMS (vCard short link) was sent to this 18968 * lead â stamped conditionally (attribute_not_exists) by manage_appointment 18969 * on the first AI booking confirmation, so the card is sent once per lead 18970 * ever. Absent â never sent (or lead was in the measurement holdout). */ 18971 contactCardSentAt?: string; 18972 18973 /** Soft decline ("not interested", mistake click, wrong number) â stamped by the 18974 * AI booker's not-interested escalation so openers / re-pitch workflows / 18975 * the dialler can stand down WITHOUT it being an opt-out (the customer may 18976 * re-engage any time). Cleared automatically when the lead later books or 18977 * reschedules. Consumed by the \`check_not_declined\` eligibility check. */ 18978 declinedAt?: string; 18979 /** Provenance of declinedAt (e.g. 'decline:sms:ai_escalate'). */ 18980 declineSource?: string; 18981 18982 /** @deprecated Lead category â replaced by salesCycle.stage + tenant_pipelineStatus GSI */ 18983 leadCategory?: LeadCategory; 18984 18985 /** @deprecated */ 18986 leadCategoryUpdatedUtc?: string; 18987 18988 /** Agent currently handling this lead (MaxContact UserID as string) */ 18989 lockAgentId?: string | null; 18990 18991 /** Type of lock (always set, 'none' when unlocked, or result code like 'BUYHOT'/'BUYCLO' when locked by agent) */ 18992 lockType: LockType; 18993 18994 /** UTC timestamp when lock was acquired */ 18995 lockStartUtc?: string; 18996 18997 /** Agent who owns this lead long-term (MaxContact UserID as string) */ 18998 ownedByAgent?: string | null; 18999 19000 /** UTC timestamp when ownership was assigned */ 19001 ownedByAssignedUtc?: string; 19002 19003 /** UTC timestamp when ownership expires */ 19004 ownedByExpiryExactUTCTime?: string; 19005 19006 /** Reason for ownership */ 19007 ownedByReason?: OwnershipReason; 19008 19009 /** 19010 * ISO 8601 â when this lead was FIRST marked Buyer Close (lockType/ownedByReason 19011 * = BUYCLO or AGAM-BC). Write-once: stamped by lead-disposition-flag-maintainer 19012 * the first time the BC disposition is observed, and backfilled from the 19013 * earliest BC call event. NEVER overwritten or cleared â survives later
19014 * sale/clear and re-BC, so it always records the first-ever buyer-close. Unlike 19015 * \`ownedByAssignedUtc\` (which moves to the most recent BC touch), this is stable. 19016 */ 19017 firstBuyerCloseUtc?: string; 19018 19019 /** 19020 * MaxContact agent ID (as string) who FIRST buyer-closed this lead â captured 19021 * write-once alongside \`firstBuyerCloseUtc\`. Persists even after the deal sells 19022 * and the live ownership/lock fields are cleared, so "who closed this buyer" is 19023 * recoverable for historical/closed leads. Use over \`ownedByAgent\`/ 19024 * \`dispositionAgentMaxId\` (which are wiped on sale) for first-BC attribution. 19025 */ 19026 firstBuyerCloseAgentId?: string; 19027 19028 /** 19029 * Transient per-exit reason hint, stamped in the SAME write that clears a lead 19030 * OUT of Buyer Close (UI "Mark as Sold"/"Remove from BC", order.confirmed 19031 * auto-close). Read once by the lead-disposition-flag-maintainer at the BCâ 19032 * not-BC transition to classify the emitted \`buyer_close.exited\` event, then 19033 * REMOVEd on re-entry so it can never go stale. NOT derived from 19034 * \`salesCycle.stage\` (which is sticky). Sale-code CDR exits omit it â the 19035 * maintainer falls back to \`isSaleCode(lastMaxResultCode)\`. 19036 * 19037 * Values: \`'sold'\` = exited via order.confirmed (the ONLY "sold" for the 19038 * change-log split); \`'removed'\` = UI "Remove from BC"; \`'sold_manual'\` = UI 19039 * "Mark as Sold" (a manual sold that is deliberately NOT counted as order-sold). 19040 */ 19041 bcExitReason?: 'sold' | 'removed' | 'sold_manual'; 19042 19043 /** 19044 * ISO 8601 â when this lead LEFT Buyer Close, stamped in the SAME write that 19045 * sets \`bcExitReason\` (UI "Mark as Sold"/"Remove from BC" via the bc-status 19046 * endpoint, and the order.confirmed auto-close in create-lead-and-identity). 19047 * This is the exit DATE the change-log's daily BC-exit flow buckets on: 19048 * \`sold\`/\`removed\` counts for a row date D are leads whose \`bcExitUtc\` falls 19049 * on D, aged by \`bcExitUtc â firstBuyerCloseUtc\`. Cleared/overwritten on a 19050 * later exit (the most recent exit wins). Absent = never recorded leaving BC 19051 * via a classified path (sale-code CDR / hold exits don't stamp it â they 19052 * aren't counted in the sold/removed split). Historical order-sold exits are 19053 * backfilled to the order.confirmed date. 19054 */ 19055 bcExitUtc?: string; 19056 19057 /** Last MaxContact result code */ 19058 lastMaxResultCode?: string; 19059 19060 /** ISO timestamp when lastMaxResultCode was set */ 19061 lastMaxResultCodeAt?: string; 19062 19063 /** MaxContact agent ID who set the lastMaxResultCode */ 19064 lastMaxResultCodeAgentId?: string; 19065 19066 /** MaxContact list ID the agent was calling from when lastMaxResultCode was set */ 19067 lastMaxResultCodeListId?: number; 19068 19069 /** MaxContact recording URL for the call that set lastMaxResultCode */ 19070 lastMaxResultCodeRecordingUrl?: string; 19071 19072 /** First MaxContact result code (stamped once on first call, never overwritten) */ 19073 firstMaxResultCode?: string; 19074 19075 /** ISO timestamp when firstMaxResultCode was set */ 19076 firstMaxResultCodeAt?: string; 19077 19078 /** MaxContact agent ID who handled the first call */ 19079 firstMaxResultCodeAgentId?: string; 19080 19081 /** MaxContact list ID the lead was first called from */ 19082 firstMaxResultCodeListId?: number; 19083 19084 /** Talk time in ms for the first MaxContact call */ 19085 firstMaxCallTalkTimeMs?: number; 19086 19087 /** Disposition review state â set when lastMaxResultCode is a reviewable code */ 19088 dispositionReview?: DispositionReview; 19089 19090 /** DNC (Do Not Call) status */ 19091 dncStatus: DncStatus; 19092 19093 /** False positive state (for agent-marked non-leads) */ 19094 falsePositive: boolean; 19095 19096 /** Agent who marked as false positive; \`'auto'\` when the spam screen did it at creation */ 19097 falsePositiveMarkedBy?: string; 19098 19099 /** ISO 8601 UTC timestamp when marked false positive */ 19100 falsePositiveMarkedAtUtc?: string; 19101 19102 /** 19103 * Why the automatic screen flagged this lead (absent on agent-marked leads). 19104 * \`bot_form_fill\` = \`scoreBotFormFill\` in @bigm/utils scored the form fill at 19105 * or above its threshold when the lead was created (\`spamScreen\` tenant feature). 19106 */ 19107 falsePositiveReason?: 'bot_form_fill'; 19108 19109 /** Bot score stamped by the spam screen on every screened form lead, flagged or not */ 19110 botScore?: number; 19111 19112 /** Signals that made up \`botScore\`, e.g. \`['phone_invalid','automation_ua']\` */ 19113 botSignals?: string[]; 19114 19115 /** UTC timestamp when lead was created (ISO 8601) */ 19116 createdAt: string; 19117 19118 /** UTC timestamp when lead was last updated (ISO 8601) */ 19119 updatedAt: string; 19120 19121 /** UTC timestamp of the last customer event linked to this lead (ISO 8601) */ 19122 lastEventAt?: string; 19123 19124 /** UTC timestamp of the last REAL interaction (call / SMS / email / form / 19125 * chat / order â INTERACTION_EVENT_TYPES). Unlike lastEventAt, passive 19126 * system events (customer.updated syncs, note imports, ad views) never bump 19127 * this. Forward-only, stamped by create-lead-and-identity. Drives the Sales 19128 * Portal BC age buckets. */ 19129 lastInteractionAt?: string; 19130 19131 /** UTC ISO of the last SERVICE-TEAM contact: an Aircall call either way, or an 19132 * SMS the service lane owns (\`sentByLane:'service'\` / \`source:'missive'\`) â 19133 * \`isServiceContactEvent\` in @bigm/shared is the rule. SPARSE: absent means 19134 * the service team has never spoken to this lead, which keeps the 19135 * \`leadsByTenantServiceContact\` GSI to service-touched leads only. 19136 * Forward-only, stamped by create-lead-and-identity. Orders the Service 19137 * Portal below the purple queue (17 Sep 2026). */ 19138 lastServiceContactAt?: string; 19139 19140 /** UTC ISO of the last CAMPAIGN-ATTRIBUTED outbound message (outbound_sms / 19141 * outbound_email carrying a campaignId) sent to this lead. SPARSE â absent 19142 * means never campaigned, and audience filters treat absence as eligible. 19143 * Forward-only; stamped by create-campaign-send from the events-customer 19144 * row's \`createdAt\` (event time, NOT ingest time â replays and the backfill 19145 * must agree). Drives \`CampaignAudienceFilter.notCampaignedInDays\`. 19146 * 19147 * â ï¸ Distinct from \`lastCampaignAttribution.attributedAt\`, which is last
19148 * campaign TOUCH â it fires on inbound replies and clicks carrying a 19149 * campaignId. This field only ever moves when WE send the lead a message. 19150 * 19151 * â ï¸ Not backed by a GSI (deliberate â a sparse index on it cannot answer 19152 * "never campaigned OR cooled off"; see the backbook selection plan). */ 19153 lastCampaignSentAt?: string; 19154 19155 /** Shopify customer ID (numeric). Canonical store of the link from Lead â Shopify customer. */ 19156 shopifyCustomerId?: number; 19157 19158 /** Composite GSI hash key \`\${tenantName}#\${shopifyCustomerId}\`. Always written together with \`shopifyCustomerId\`. Powers the \`tenantShopifyCustomerId\` (a.k.a. \`leadsByShopifyCustomerId\`) GSI used by \`findLeadByShopifyCustomerId\`. */ 19159 tenantShopifyCustomerId?: string; 19160 19161 /** ISO8601 UTC timestamp when \`shopifyCustomerId\` was first stamped onto this lead. */ 19162 shopifyCustomerLinkedAt?: string; 19163 19164 /** Provenance for the link â \`'auto:phone-exact'\` / \`'auto:email-exact'\` / 19165 * \`'auto:email-and-phone-agree'\` / \`'manual'\` / \`'create-and-link'\` / 19166 * \`'create-lead'\` (initial Lead create from a Shopify customer event) / 19167 * \`'webhook-update'\` (existing lead enriched by \`customers/update\` or order webhook) / 19168 * \`'order-webhook'\` (existing lead enriched by an order event). */ 19169 shopifyCustomerLinkSource?: string; 19170 19171 // ââ Consumer-app identity link (Cognito + Stripe) âââââââââââââââââââââââââââ 19172 // Written only by tenants whose members hold a consumer Cognito account (Delta X Coach, EDA). 19173 // A consumer-app tenant represents one human as three records â entitlements keyed on the 19174 // Cognito sub, the Lead keyed on email, the Stripe customer keyed on email and never persisted. 19175 // Stamping the sub onto the Lead is what stops an email change forking the CRM record away from 19176 // the access it belongs to. Both fields are 1:1 and first-link-wins, exactly like 19177 // \`shopifyCustomerId\` â a conflicting value is flagged (SYNC_REVIEW_LINK_ID_CHANGED), never 19178 // silently repointed. 19179 19180 /** Cognito user sub â the durable identity key. SK of the sparse \`leadsByCognitoSub\` GSI 19181 * (PK=tenantName) used by \`findLeadByCognitoSub\`. Unlike email it cannot change, so a lead 19182 * carrying it survives an address change intact. */ 19183 cognitoSub?: string; 19184 19185 /** ISO8601 UTC timestamp when \`cognitoSub\` was FIRST stamped onto this lead (write-once). */ 19186 cognitoSubLinkedAt?: string; 19187 19188 /** Provenance for the Cognito link â \`'create-lead'\` (lead minted from a consumer-app event) / 19189 * \`'webhook-update'\` (existing lead enriched by a later event) / \`'backfill'\`. */ 19190 cognitoSubLinkSource?: string; 19191 19192 /** Stripe customer id (\`cus_â¦\`). SK of the sparse \`leadsByStripeCustomerId\` GSI 19193 * (PK=tenantName) used by \`findLeadByStripeCustomerId\`. Makes "show me this member's invoices" 19194 * answerable from a lead. 19195 * 19196 * â ï¸ With Stripe Connect DIRECT charges the Customer lives on the CONNECTED account, so any 19197 * read of it needs the \`Stripe-Account\` header â a platform-level lookup returns nothing. */ 19198 stripeCustomerId?: string; 19199 19200 /** ISO8601 UTC timestamp when \`stripeCustomerId\` was FIRST stamped onto this lead (write-once). */ 19201 stripeCustomerLinkedAt?: string; 19202 19203 /** Provenance for the Stripe link â same vocabulary as \`cognitoSubLinkSource\`. */ 19204 stripeCustomerLinkSource?: string; 19205 19206 /** Wicked Reports contact ID */ 19207 wickedContactId?: string; 19208 19209 // ââ Zoho CRM link (MMC + MHC tenants only) ââââââââââââââââââââââââââââââââââ 19210 // Stamped by the Zoho sync (one-off backfill + ongoing poller + MAX-call self-heal). 19211 // Only these two Zoho tenants populate these fields. Zoho record ids are unique 19212 // across modules within an org, so the bare ids are unambiguous. 19213 /** Canonical Zoho CRM record id (Contacts > Leads > Deals, most-recent in tier) â the 19214 * stable "this lead = this Zoho customer" link. STORED but NOT indexed (the legacy 19215 * leadsByZohoId GSI was removed; call matching uses \`zohoDealId\` below). */ 19216 zohoId?: string; 19217 /** Which Zoho module the canonical \`zohoId\` belongs to (for deep-linking). */ 19218 zohoModule?: 'Contacts' | 'Leads' | 'Deals'; 19219 /** Canonical Zoho DEAL record id â the id MaxContact dials on and echoes back in a 19220 * call CDR's \`payload.ReferenceID\`. Powers the sparse \`leadsByZohoDealId\` GSI 19221 * (PK=tenantName, SK=zohoDealId) used to attach MAX calls to the exact lead. 19222 * Most-recent Deal when several exist; set by backfill, poller, or call self-heal. */ 19223 zohoDealId?: string; 19224 /** 'single' = exactly one verified Zoho record matched; 'multiple' = duplicates 19225 * exist in Zoho (the full set is in \`zohoRecordIds\`). Field absent = no match. */ 19226 zohoLinkStatus?: 'single' | 'multiple'; 19227 /** All verified matched Zoho records as \`\${module}:\${id}\` (e.g. "Contacts:5510..."). 19228 * Retained for audit + future Zoho-side dedup. Populated when \`zohoLinkStatus\` is 19229 * 'multiple'; omitted for a clean single match. */ 19230 zohoRecordIds?: string[]; 19231 /** ISO8601 UTC timestamp when \`zohoId\` was first stamped onto this lead. */ 19232 zohoIdLinkedAt?: string; 19233 /** Provenance for the Zoho link â \`'zoho-backfill'\` | \`'zoho-poller'\` | \`'max-call'\` | \`'manual'\`. */ 19234 zohoIdLinkSource?: string; 19235 19236 /** 19237 * Canonical unified identity profile 19238 * Aggregates PII from all customer events with source tracking 19239 */ 19240 masterProfile?: MasterProfile; 19241 19242 /** 19243 * First-touch attribution â how this lead was originally created. 19244 * Stamped once at lead creation time. Immutable after that. 19245 */ 19246 leadSource?: LeadSource; 19247 19248 /** 19249 * Last-campaign attribution â the most recent campaign that touched this lead. 19250 * Updated whenever a campaign-attributed event is processed for this lead. 19251 * "Most recent campaign wins" for sales attribution reporting. 19252 */ 19253 lastCampaignAttribution?: LastCampaignAttribution; 19254 19255 /** 19256 * Last-touch lead source â most recent event's attribution. 19257 * Same shape as leadSource but updates with every new event ("last event wins"). 19258 * Used alongside leadSource (first-touch) to show how the lead's journey has evolved. 19259 */ 19260 lastLeadSource?: LeadSource; 19261 19262 /** 19263 * Facebook platform attribution â from first metaform.leads event. 19264 * When present, overrides leadSource for "facebook" source categorization. 19265 */ 19266 facebookAttribution?: FacebookAttribution; 19267 19268 /** 19269 * Google platform attribution â from first event carrying a gclid. 19270 * When present, overrides leadSource for "google" source categorization. 19271 */ 19272 googleAttribution?: GoogleAttribution; 19273 19274 /** 19275 * Agent action state â tracks a pending action for the assigned agent. 19276 * Set by the create_agent_action workflow step. 19277 */ 19278 agentActionState?: AgentActionState; 19279 19280 /** 19281 * Best contact times per channel for THIS lead (piece D, decision 2): stated preference, 19282 * else the lead's own reply / answer hours, else the tenant's learned table. Written by 19283 * the NBA compute each time the lead is computed; shown on the lead card. 19284 */ 19285 contactTiming?: ContactTiming; 19286 19287 /** 19288 * SPARSE GSI key for \`leadsByNbaSlot\` (tenantName, nbaSlotAt):
19288set only while an AI-owned 19289 * \`scheduled\` action exists, removed when it is dismissed, executed or superseded. 19290 */ 19291 nbaSlotAt?: string; 19292 19293 /** 19294 * ISO timestamp when this lead was queued for agent action. 19295 * SPARSE GSI key â only set when agentActionState.status = 'pending'. 19296 * Removing this attribute removes the lead from leadsByAgentActionQueued GSI. 19297 */ 19298 agentActionQueuedAt?: string; 19299 19300 /** 19301 * MaxContact agent ID of the assigned agent for this action. 19302 * SPARSE GSI key â only set when agentActionState.status = 'pending' AND agent is assigned. 19303 * Used by leadsByAgentActionAssigned GSI for per-agent queries. 19304 */ 19305 agentActionAssignedTo?: string; 19306 19307 /** 19308 * @deprecated 2026-05-21 â no longer written. create_agent_action now rebuilds 19309 * the trail on demand from events-customer via buildTrailFromRecentEvents. 19310 * Field kept on the type only so old rows that still carry it can deserialize; 19311 * the strip-noop-pii-text-extraction-2026-05-21 backfill REMOVEs it from 19312 * surviving rows. Safe to delete once the backfill has completed on all 19313 * Lead tables. 19314 */ 19315 intentTrail?: readonly import('./lead-prioritisation.js').IntentTrailEntry[]; 19316 19317 /** 19318 * Most recent conversation disposition applied by a dealer/agent. 19319 * Records the sales pipeline stage reached (1â5) or exit reason (0). 19320 * Set when an agent closes/resolves a conversation from the Messages page. 19321 */ 19322 lastConversationDisposition?: ConversationDisposition; 19323 19324 // âââ Sales Cycle ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 19325 19326 /** 19327 * Sales cycle state: stage + next action recommendation. 19328 * suggestedStage auto-calculated by create-lead-and-identity on each event. 19329 * stage confirmed by agent via Messages dismiss flow. 19330 */ 19331 salesCycle?: LeadSalesCycle; 19332 19333 /** 19334 * The AI's own sales funnel for this lead (plan \`sales-ai-mhc-ai-sale-mode.md\`). 19335 * Separate from \`salesCycle\`, which is every lead's pipeline over 30+ event types: 19336 * this one is exclusive, forward-only, and exists only while the AI is selling. 19337 */ 19338 aiSale?: LeadAiSale; 19339 19340 /** 19341 * Set while the lead is held out of the MaxContact dialler (Zoho Deal Stage \`Buyer Closing\`) by 19342 * \`set_zoho_stage\` (plan \`ai-and-appointment-dial-stop.md\`). \`prevStage\` is the Stage before the 19343 * FIRST hold, so the 3-day release of an \`ai_card\` hold puts it back. An \`appointment\` hold is 19344 * never released automatically. Removed on release or when an AI-card lead pays. 19345 */
19346 dialStop?: { prevStage?: string; since: string; reason: 'ai_card' | 'appointment' }; 19347 19348 /** Agent-assessed buyer readiness (hot/warm/cold) â set during conversation dismiss */ 19349 buyerReadiness?: BuyerReadiness; 19350 19351 /** Last exit code when agent closed conversation without a sale */ 19352 lastExitCode?: { 19353 code: string; 19354 dismissedBy: string; 19355 dismissedAt: string; 19356 channel: string; 19357 closedLostReason?: ClosedLostReason; 19358 }; 19359 19360 // âââ Finance Applications (denormalised summary; multi-provider) ââââââ 19361 /** 19362 * Per-application BNPL records (Humm, future Payright/Zip/Klarna/Afterpay). 19363 * Source-of-truth raw data lives in provider-records tables (humm-records, 19364 * future payright-records). Each entry here is a slim summary keyed by 19365 * \`\${provider}#\${applicationId}\` plus a \`raw\` escape hatch. 19366 * 19367 * Person-level facts (drivers licence, employer, occupation, banking) live 19368 * on \`masterProfile.financeProfile\` â NEVER duplicated across applications. 19369 * 19370 * Stamped by \`create-lead-and-identity\` from \`finance.application_received\` 19371 * + \`finance.application_status_changed\` events emitted by provider 19372 * scrapers. Single Lead writer â no concurrent-mutation contention. 19373 * 19374 * See type definitions ~line 968 above and plan in 19375 * \`~/.claude/plans/serene-snuggling-pnueli.md\`. 19376 */ 19377 financeApplications?: readonly FinanceApplication[] | FinanceApplication[]; 19378} 19379 19380/** 19381 * Lead attributes used in GSI queries 19382 */ 19383export interface LeadGSIAttributes { 19384 /** For leadsByPhone GSI */ 19385 phoneNumber?: string; 19386 updatedAt: string; 19387 19388 /** For leadsByEmail GSI */ 19389 emailAddress?: string; 19390 19391 /** For leadsByLockType GSI */ 19392 lockType: LockType; 19393 19394 /** For leadsByTenantPipelineStatus GSI (composite: tenantName#status) */ 19395 tenant_pipelineStatus?: string; 19396 19397 /** For leadsByOwner GSI */ 19398 ownedByAgent?: string | null; 19399 ownedByAssignedUtc?: string; 19400 19401 /** For leadsByPhoneError GSI */ 19402 phoneNumberError?: string; 19403 19404 /** For leadsByEmailError GSI */ 19405 emailAddressError?: string; 19406 19407 /** For leadsByTenant GSI */ 19408 tenantName: string; 19409 19410 /** For leadsByAgentActionAssigned GSI (sparse - only set when pending + assigned) */ 19411 agentActionAssignedTo?: string; 19412} 19413 19414/** 19415 * Lead creation input (omits auto-generated fields) 19416 */ 19417export interface CreateLeadInput { 19418 tenantName: string; 19419 phoneNumber?: string; 19420 emailAddress?: string; 19421 phoneNumberOriginal?: string; 19422 phoneNumberError?: string; 19423 emailAddressOriginal?: string; 19424 emailAddressError?: string; 19425 leadData?: Record<string, unknown> | null; 19426 leadCategory?: LeadCategory; 19427 leadCategoryUpdatedUtc?: string; 19428 lockAgentId?: string | null; 19429 lockType?: LockType; 19430 lockStartUtc?: string; 19431 ownedByAgent?: string | null; 19432 ownedByAssignedUtc?: string; 19433 ownedByExpiryExactUTCTime?: string; 19434 ownedByReason?: OwnershipReason; 19435 lastMaxResultCode?: string; 19436 dncStatus?: DncStatus; 19437 falsePositive?: boolean; 19438 falsePositiveMarkedBy?: string; 19439 falsePositiveMarkedAtUtc?: string; 19440 falsePositiveReason?: 'bot_form_fill'; 19441 botScore?: number; 19442 botSignals?: string[]; 19443} 19444 19445/** 19446 * Lead update input (only updatable fields) 19447 */ 19448export interface UpdateLeadInput { 19449 phoneNumber?: string; 19450 emailAddress?: string; 19451 phoneNumberOriginal?: string; 19452 phoneNumberError?: string; 19453 emailAddressOriginal?: string; 19454 emailAddressError?: string; 19455 leadData?: Record<string, unknown> | null; 19456 leadCategory?: LeadCategory; 19457 leadCategoryUpdatedUtc?: string; 19458 lockAgentId?: string | null; 19459 lockType?: LockType; 19460 lockStartUtc?: string; 19461 ownedByAgent?: string | null; 19462 ownedByAssignedUtc?: string; 19463 ownedByExpiryExactUTCTime?: string; 19464 ownedByReason?: OwnershipReason; 19465 lastMaxResultCode?: string; 19466 dncStatus?: DncStatus; 19467 falsePositive?: boolean; 19468 falsePositiveMarkedBy?: string; 19469 falsePositiveMarkedAtUtc?: string; 19470 falsePositiveReason?: 'bot_form_fill'; 19471 botScore?: number; 19472 botSignals?: string[]; 19473 updatedAt?: string; 19474 lastEventAt?: string; 19475} 19476 19477`,rn=`/** 19478 * Legal entity â DynamoDB \`legal-entities\`, PK \`entityId\` (S). 19479 * 19480 * A legal entity is the company behind one or more brands (Lemon Wedge Pty Ltd â Masseuse 19481 * Massage, Masseuse Health Co, the NZ store; Green Telescope Pty Ltd â AgeWellness, Dosalume,
19482 * NutriPrime, LifeNourish). The row holds public-register facts, the people and accounts attached 19483 * to the company, the KYC document pack Shopify Payments needs, and the audit trail of every edit 19484 * made on the ShopDash Business Entities page (\`/business-entities\`, app_admin only). 19485 * 19486 * â Never on this row: a licence, passport, TFN, BSB or account number, a date of birth, or a 19487 * residential address. The account representative's personal detail lives ONLY in the encrypted 19488 * document store (\`_docStore.representativeKey\`), and \`kyc.bankPayout\` is a flag that says the 19489 * account exists and where it is kept. 19490 * 19491 * Store-level facts (support address, currency, timezone, Shopify plan, Stripe ids) belong to 19492 * the brand's \`tenants\` row, not here (Chris, 30 Sep 2026). A few legacy keys of that kind still 19493 * exist on old rows; they are marked deprecated below and the page hides them. 19494 * 19495 * Rule set + completion: \`shopdash/amplify/functions/dataApi/entity-kyc.ts\`. 19496 * Document checks: \`shopdash/amplify/functions/dataApi/entity-artefacts.ts\`. 19497 * Doc: \`BigM/Doco/ADMIN/BUSINESS_ENTITIES.md\`. 19498 */ 19499 19500export type LegalEntityType = 'company' | 'sole-trader' | 'partnership' | 'trust' | 'other'; 19501export type LegalEntityJurisdiction = 'AU' | 'NZ' | 'Other'; 19502export type GstBasis = 'cash' | 'accruals'; 19503export type GstReportingCycle = 'monthly' | 'quarterly' | 'annually'; 19504/** How the entity relates to the platform: Green Telescope is \`platform-owner\`, Lemon Wedge is \`client-owned\`. */ 19505export type LegalEntityRole = 'client-owned' | 'platform-owner' | string; 19506 19507export interface LegalEntityAddress { 19508 line1: string | null; 19509 line2?: string | null; 19510 suburb: string | null; 19511 state: string | null; 19512 postcode: string | null; 19513 country: string | null; 19514} 19515 19516export interface LegalEntityAbnStatus { 19517 status?: string; 19518 /** YYYY-MM-DD the ABN became active (ABN Lookup). */ 19519 from?: string; 19520} 19521 19522export interface LegalEntityContacts { 19523 billingEmail?: string | null; 19524 legalEmail?: string | null; 19525 /** @deprecated tenant-level: the brand's support address lives on its \`tenants\` row. Hidden on the page. */ 19526 supportEmail?: string | null; 19527} 19528 19529/** One brand the entity trades as. Per-brand store facts are read live from \`tenants\`, never copied here. */ 19530export interface LegalEntityTrade { 19531 brandSlug: string | null; 19532 /** The brand's \`tenants\` PK when it is on the platform (e.g. \`masseuse-massage-store.myshopify.com\`). */ 19533 tenantId: string | null; 19534 site: string | null; 19535 onPlatform: boolean; 19536 shopifyPlan?: string | null; 19537 /** Estate notes kept from the hand-ported record; the page shows them, the form never edits them. */ 19538 _warn?: string; 19539 _note?: string; 19540} 19541 19542export interface LegalEntityPlatformAccounts { 19543 shopifyOwnerName?: string | null; 19544 shopifyOwnerLogin?: string | null; 19545 shopifyPartnerOrgId?: string | null; 19546 shopifyPartnerOrgName?: string | null; 19547 shopifyDevDashboardOrgIds?: string[]; 19548 xeroOrg?: string | null; 19549 xeroTenantId?: string | null; 19550 xeroAccess?: 'read-only' | 'read-write' | null; 19551 /** @deprecated tenant-level (per store). Hidden on the page. */ 19552 shopifyPlan?: string | null; 19553} 19554 19555export interface LegalEntityFinance { 19556 /** SSM prefix for the entity's own secrets, e.g. \`/keys/entities/green-telescope\`. */ 19557 ssmPrefix?: string | null; 19558 /** @deprecated tenant-level. Hidden on the page. */ 19559 currency?: string | null; 19560 /** @deprecated tenant-level. Hidden on the page. */ 19561 timezone?: string | null; 19562 /** @deprecated tenant-level. Hidden on the page. */ 19563 pricesIncludeTax?: boolean | null; 19564 /** @deprecated tenant-level (\`tenants.billing.stripeCustomerId\`). Hidden on the page. */ 19565 shopzenBillingCustomerId?: string | null; 19566 /** @deprecated tenant-level. Hidden on the page. */ 19567 merchantStripeAccount?: string | null; 19568} 19569 19570/** An officeholder as the ASIC extract lists them: public register facts only (no date of birth, no address). */ 19571export interface LegalEntityOfficeholder { 19572 fullName: string; 19573 role: 'Director' | 'Secretary' | 'Alternate director' | string; 19574 /** YYYY-MM-DD */ 19575 appointedOn?: string | null; 19576 /** YYYY-MM-DD; set when the extract lists the appointment as ceased. */ 19577 ceasedOn?: string | null; 19578} 19579 19580/** A member (shareholder) as the ASIC extract lists them. Public register facts only. */ 19581export interface LegalEntityShareholder { 19582 name: string; 19583 shareClass?: string | null; 19584 sharesHeld?: number | null; 19585 totalSharesInClass?: number | null; 19586 beneficiallyHeld?: boolean | null; 19587 fullyPaid?: boolean | null; 19588} 19589 19590export interface LegalEntityShareClass { 19591 shareClass: string; 19592 description?: string | null; 19593 totalShares?: number | null; 19594 totalPaid?: number | null; 19595 totalUnpaid?: number | null; 19596} 19597 19598/** 19599 * Public register facts the model read off the latest business document (ASIC extract), kept 19600 * so the page can show "the extract lists â¦" and offer them as values. Never a date of birth 19601 * or an address: those are compared against the representative file and dropped. 19602 */ 19603export interface LegalEntityArtefactExtraction { 19604 artefact: string; 19605 s3Key: string; 19606 at: string; 19607 registeredOn?: string | null; 19608 reviewDate?: string | null; 19609 officeholders?: LegalEntityOfficeholder[]; 19610 shareholders?: LegalEntityShareholder[]; 19611 shareStructure?: LegalEntityShareClass[]; 19612} 19613 19614/** The register check made by hand against ABN Lookup (28 Aug 2026). */ 19615export interface LegalEntityVerification { 19616 register: string; 19617 abn?: string; 19618 /** YYYY-MM-DD */ 19619 on?: string; 19620 /** Field names the register confirmed, e.g. \`legalName\`, \`abn\`, \`acn\`, \`state/postcode\`. */ 19621 confirms: string[]; 19622 crossCheck?: string; 19623} 19624 19625/** A register-checked field edited on the page since the check: the check lapses, the old value is kept here. */ 19626export interface LegalEntityVerificationLapse { 19627 at: string; 19628 by: string; 19629 from: unknown; 19630} 19631 19632// ââ KYC pack ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 19633 19634/** Shopify's document-type vocabulary as used on this platform. */ 19635export type KycShopifyDocType = 19636 | 'Commercial register extract' | 'Certificate of registration'
19637 | 'Passport' | 'Drivers licence' | 'National ID' 19638 | 'Bank statement' | 'Utility bill' | 'Council rates notice' 19639 | string; 19640 19641/** One declared document. The bucket (HEAD) is the truth of whether it exists; \`present\` is only the row's claim. */ 19642export interface KycDocumentEntry { 19643 /** S3 role folder: \`business\`, \`identity\`, \`residentialAddress\`, \`residentialAddress-alternate\`. */ 19644 role: string; 19645 shopifyDocType: KycShopifyDocType; 19646 file: string; 19647 holder?: string | null; 19648 /** YYYY-MM-DD */ 19649 expiresOn?: string | null; 19650 present?: boolean; 19651 onFile?: boolean; 19652 s3Key?: string | null; 19653 note?: string | null; 19654 /** 'front' | 'back' for a two-sided licence. Legacy entries infer it from the file name. */ 19655 side?: 'front' | 'back' | null; 19656 uploadedAt?: string | null; 19657 uploadedBy?: string | null; 19658 versionId?: string | null; 19659 /** Set on the OLD entry when a replacement lands; the entry stays (nothing gets deleted). */ 19660 supersededBy?: string | null; 19661} 19662 19663/** A flag that the payout account exists and where it is kept. Never the BSB or account number. */ 19664export interface KycBankPayout { 19665 institution?: string | null; 19666 ref?: string | null; 19667 recordedAt?: string | null; 19668 recordedBy?: string | null; 19669 updatedAt?: string | null; 19670 updatedBy?: string | null; 19671 _rule?: string; 19672 /** Legacy on the hand-ported row; not the numbers. */ 19673 accountName?: string | null; 19674} 19675 19676/** One line of the page's activity log. \`by\` is the verified Cognito email. */ 19677export interface LegalEntityAuditEntry { 19678 at: string; 19679 by: string; 19680 action: 19681 | 'presign-upload' | 'confirm-upload' | 'view' | 'kyc-flags' 19682 | 'edit-fields' | 'view-representative' | 'edit-representative' 19683 | 'artefact-check' | 'fill-from-artefact' | 'fill-representative-from-artefact' | 'revert-test-artifact' 19684 | string; 19685 s3Key?: string; 19686 [k: string]: unknown; 19687} 19688 19689export interface LegalEntityKyc { 19690 documents: KycDocumentEntry[]; 19691 bankPayout?: KycBankPayout | null; 19692 twoFactorRequired?: boolean; 19693 audit?: LegalEntityAuditEntry[]; 19694 /** Documents refused and left out, kept for the record. */ 19695 _rejected?: Array<{ file: string; why: string }>; 19696 /** Hand-ported notes from the onboarding skill. */ 19697 docsDir?: string; 19698 _pii?: string; 19699 _note?: string; 19700 _expiryNote?: string; 19701 _crossCheck?: string; 19702 _legacyDocsDir?: string; 19703} 19704 19705export interface LegalEntityDocStore { 19706 bucket: string; 19707 /** \`entities/{entityId}/_representative/representative.json\` â the only place the representative's DOB and address live. */ 19708 representativeKey: string; 19709 kmsKeyArn?: string; 19710 _rule?: string; 19711} 19712 19713// ââ document checks (AI reads the KYC documents) ââââââââââââââââââââââââââââââââââââââââââââââââââ 19714 19715export type ArtefactCheckStatus = 'match' | 'mismatch' | 'unreadable'; 19716 19717/** 19718 * "This field's value was read from an uploaded document and matched." Keyed by field path 19719 * (\`legalName\`, \`registeredAddress.postcode\`, \`representative.dateOfBirth\`, â¦). 19720 * Business fields carry \`valueHash\` (sha256 of the normalised value at check time) and, on a 19721 * mismatch, a \`note\` with what the document reads. Representative fields carry NEITHER â they 19722 * are pinned to the representative file's S3 \`repVersionId\` (a date of birth is brute-forceable 19723 * from a bare hash). 19724 */ 19725export interface LegalEntityArtefactCheck { 19726 status: ArtefactCheckStatus; 19727 /** File name of the document that was read. */ 19728 artefact: string; 19729 s3Key: string; 19730 versionId: string | null; 19731 valueHash?: string; 19732 repVersionId?: string | null; 19733 at: string; 19734 model: string; 19735 note?: string; 19736 /** What the document prints, in the record's own shape, for a business field mismatch â so the page can offer it. Never for representative fields. */ 19737 documentValue?: unknown; 19738} 19739 19740// ââ the row âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 19741 19742export interface LegalEntity {
19743 entityId: string; 19744 legalName: string | null; 19745 type: LegalEntityType | null; 19746 /** ABN Lookup's vocabulary, e.g. \`Australian Private Company\` (ASIC prints "Proprietary"; same thing). */ 19747 entityClassification: string | null; 19748 /** 11 digits, no spaces. */ 19749 abn: string | null; 19750 /** 9 digits, no spaces; companies only. */ 19751 acn: string | null; 19752 abnStatus?: LegalEntityAbnStatus | null; 19753 jurisdiction: LegalEntityJurisdiction | string | null; 19754 gstRegistered: boolean | null; 19755 /** YYYY-MM-DD */ 19756 gstRegisteredFrom?: string | null; 19757 gstBasis?: GstBasis | null; 19758 gstReportingCycle?: GstReportingCycle | null; 19759 /** MM-DD, e.g. \`06-30\`. */ 19760 financialYearEnd?: string | null; 19761 registeredAddress: LegalEntityAddress | null; 19762 contacts?: LegalEntityContacts | null; 19763 /** Registered contact phone (entity-level; brand lines live on the tenant). */ 19764 phone?: string | null; 19765 /** Current directors' full names (given names first). The ASIC extract confirms this list. */ 19766 directors: string[]; 19767 /** Members with their holdings, as the ASIC extract lists them; companies only. */ 19768 shareholders?: LegalEntityShareholder[]; 19769 roles: LegalEntityRole[]; 19770 /** Registered business names against the ABN. */ 19771 tradingAs: string[]; 19772 alsoKnownAs: string[]; 19773 domainsOwned: string[]; 19774 trades: LegalEntityTrade[]; 19775 platformAccounts?: LegalEntityPlatformAccounts | null; 19776 finance?: LegalEntityFinance | null; 19777 verifiedAgainst?: LegalEntityVerification | null; 19778 /** Keyed by field path. */ 19779 verificationLapsed?: Record<string, LegalEntityVerificationLapse> | null; 19780 /** \`verifiedAgainst\` confirmed the registration facts. */ 19781 confirmed?: boolean; 19782 /** The Shopify store whose settings seeded the registration fields. */ 19783 shopifySource?: string | null; 19784 /** Legacy machine path of the representative file; the live key is \`_docStore.representativeKey\`. */ 19785 accountRepresentativeRef?: string | null; 19786 /** Where each fact came from, keyed by field path or a group of them. */ 19787 _provenance?: Record<string, string> | null; 19788 _portedFrom?: { file?: string; on?: string; _note?: string } | null; 19789 _docStore?: LegalEntityDocStore | null; 19790 kyc: LegalEntityKyc; 19791 artefactChecks?: Record<string, LegalEntityArtefactCheck> | null; 19792 /** What the latest business document (ASIC extract) lists â public facts only. */ 19793 artefactExtractions?: { business?: LegalEntityArtefactExtraction | null } | null; 19794} 19795 19796/** The two seeded entities. Not a closed set: onboarding may add more. */ 19797export type KnownLegalEntityId = 'lemon-wedge' | 'green-telescope' | string; 19798`,on=`/** 19799 * MaxContact Agent Status Types 19800 * 19801 * Agent statuses are reported by MaxContact via the UserStatus record type. 19802 * These 12 base statuses represent the core states an agent can be in. 19803 * Named breaks (e.g. "Break (Lunch)") are compound keys built from the 19804 * "Break" base status + a break name; they are NOT separate statuses. 19805 */ 19806 19807/** MaxContact agent status metadata */ 19808export interface MaxAgentStatus { 19809 /** The status string as returned by MaxContact (e.g., "Ready") */ 19810 code: string; 19811 /** Human-readable display label */ 19812 label: string; 19813 /** Description for tooltips / dropdowns */ 19814 description: string; 19815 /** Tailwind classes for badge styling (without the base layout classes) */ 19816 badgeClass: string; 19817 /** Tailwind bg class for timeline bar color */ 19818 barColor: string; 19819 /** Whether the agent is considered "productive" in this status */ 19820 isProductive: boolean; 19821} 19822 19823/** All known MaxContact agent statuses */ 19824export const MAX_AGENT_STATUSES: Record<string, MaxAgentStatus> = { 19825 Ready: { 19826 code: 'Ready', 19827 label: 'Ready', 19828 description: 'Agent is available and waiting for calls', 19829 badgeClass: 'bg-green-100 text-green-700', 19830 barColor: 'bg-green-400', 19831 isProductive: true, 19832 }, 19833 Talking: { 19834 code: 'Talking', 19835 label: 'Talking', 19836 description: 'Agent is on an active call', 19837 badgeClass: 'bg-green-200 text-green-800', 19838 barColor: 'bg-green-600', 19839 isProductive: true, 19840 }, 19841 Calling: { 19842 code: 'Calling', 19843 label: 'Calling', 19844 description: 'Agent is dialling an outbound call', 19845 badgeClass: 'bg-blue-100 text-blue-700', 19846 barColor: 'bg-blue-400', 19847 isProductive: true, 19848 }, 19849 Ringing: { 19850 code: 'Ringing', 19851 label: 'Ringing', 19852 description: 'Inbound call is ringing for the agent', 19853 badgeClass: 'bg-blue-100 text-blue-700', 19854 barColor: 'bg-blue-400', 19855 isProductive: true, 19856 }, 19857 Previewing: { 19858 code: 'Previewing', 19859 label: 'Previewing', 19860 description: 'Agent is previewing lead info before calling', 19861 badgeClass: 'bg-blue-100 text-blue-700', 19862 barColor: 'bg-blue-400', 19863 isProductive: true, 19864 }, 19865 Break: { 19866 code: 'Break', 19867 label: 'Break', 19868 description: 'Agent is on a break', 19869 badgeClass: 'bg-yellow-100 text-yellow-700', 19870 barColor: 'bg-yellow-400', 19871 isProductive: false, 19872 }, 19873 Dispositioning: { 19874 code: 'Dispositioning', 19875 label: 'Dispositioning', 19876 description: 'Agent is selecting a disposition code after a call', 19877 badgeClass: 'bg-purple-100 text-purple-700', 19878 barColor: 'bg-purple-400', 19879 isProductive: true, 19880 }, 19881 LoggedOff: { 19882 code: 'LoggedOff', 19883 label: 'Logged Off', 19884 description: 'Agent has logged out of the system', 19885 badgeClass: 'bg-gray-100 text-gray-500', 19886 barColor: 'bg-gray-300', 19887 isProductive: false, 19888 }, 19889 NotReady: { 19890 code: 'NotReady', 19891 label: 'Not Ready', 19892 description: 'Agent is not ready to receive calls', 19893 badgeClass: 'bg-gray-100 text-gray-600', 19894 barColor: 'bg-gray-400', 19895 isProductive: false, 19896 }, 19897 NailingUp: { 19898 code: 'NailingUp', 19899 label: 'Nailing Up', 19900 description: 'Agent connection is being established', 19901 badgeClass: 'bg-gray-100 text-gray-600', 19902 barColor: 'bg-gray-400', 19903 isProductive: false, 19904 }, 19905 ManagingCallbacks: { 19906 code: 'ManagingCallbacks', 19907 label: 'Managing Callbacks', 19908 description: 'Agent is managing scheduled callbacks', 19909 badgeClass: 'bg-gray-100 text-gray-600', 19910 barColor: 'bg-gray-400', 19911 isProductive: true, 19912 }, 19913 OnHold: { 19914 code: 'OnHold', 19915 label: 'On Hold', 19916 description: 'Agent has placed the caller on hold', 19917 badgeClass: 'bg-orange-100 text-orange-700', 19918 barColor: 'bg-orange-400', 19919 isProductive: true, 19920 }, 19921 Conferencing: { 19922 code: 'Conferencing', 19923 label: 'Conferencing', 19924 description: 'Agent is in a conference call', 19925 badgeClass: 'bg-orange-100 text-orange-700', 19926 barColor: 'bg-orange-400', 19927 isProductive: true, 19928 }, 19929 Unknown: { 19930 code: 'Unknown', 19931 label: 'Unknown', 19932 description: 'Status could not be determined', 19933 badgeClass: 'bg-gray-100 text-gray-600', 19934 barColor: 'bg-gray-400', 19935 isProductive: false, 19936 }, 19937}; 19938 19939/** Union type of all base agent status code strings */ 19940export type MaxAgentStatusCode = keyof typeof MAX_AGENT_STATUSES; 19941 19942/** Array of all base status codes (useful for dropdowns, validation) */ 19943export const AGENT_STATUS_CODES = Object.keys(MAX_AGENT_STATUSES) as MaxAgentStatusCode[]; 19944 19945/** 19946 * Look up agent status metadata by status string.
19947 * Handles compound break keys like "Break (Lunch)" by returning Break metadata. 19948 * Returns Unknown entry for unrecognised statuses (never returns null). 19949 */ 19950export function getAgentStatusInfo(status: string): MaxAgentStatus { 19951 if (status.startsWith('Break')) { 19952 return MAX_AGENT_STATUSES['Break']; 19953 } 19954 return MAX_AGENT_STATUSES[status] ?? MAX_AGENT_STATUSES['Unknown']; 19955} 19956 19957/** 19958 * Get the full badge Tailwind classes for a given status string. 19959 * Includes base layout classes + status-specific colour classes. 19960 */ 19961export function getStatusBadgeClass(status: string): string { 19962 const base = 'inline-block px-2 py-0.5 text-xs font-medium rounded-full'; 19963 return \`\${base} \${getAgentStatusInfo(status).badgeClass}\`; 19964} 19965 19966/** 19967 * Get the timeline bar colour class for a given status string. 19968 */ 19969export function getStatusBarColor(status: string): string { 19970 return getAgentStatusInfo(status).barColor; 19971} 19972 19973/** 19974 * Check if an agent in this status is considered productive. 19975 */ 19976export function isProductiveStatus(status: string): boolean { 19977 return getAgentStatusInfo(status).isProductive; 19978} 19979
19980/** 19981 * Statuses that make an agent unavailable for distribution. 19982 * Mirrors distribute-to-pool.ts auto-discover logic. 19983 */ 19984const NOT_AVAILABLE_STATUSES = new Set(['LoggedOff']); 19985 19986/** Reason an agent is not available for distribution. */ 19987export type AgentUnavailableReason = 19988 | 'no_max_id' 19989 | 'archived' 19990 | 'distribution_disabled' 19991 | 'wrong_tenant' 19992 | 'status_not_distributable' 19993 | 'at_capacity'; 19994 19995export interface AgentDistributionStatus { 19996 available: boolean; 19997 reason?: AgentUnavailableReason; 19998 /** True iff all base checks pass and the only failure (if any) is capacity. */ 19999 atCapacity: boolean; 20000 /** Effective capacity from the agent record (undefined / 0 = no cap). */ 20001 capacity?: number; 20002 /** Echoed from the caller if provided. */ 20003 ownedCount?: number; 20004} 20005 20006/** Agent input shape accepted by the distribution helpers. */ 20007export interface AgentDistributionInput { 20008 maxId?: number | string; 20009 isArchived?: boolean; 20010 isAvailableForDistribution?: boolean; 20011 lastMaxContactStatus?: string; 20012 tenants?: readonly string[]; 20013 maxConcurrentLeads?: number | null; 20014} 20015 20016/** 20017 * Single source of truth for "is this agent available for lead distribution?" 20018 * 20019 * Used by: 20020 * - Backend workflow auto-assigner (\`distribute-to-pool.ts\`) 20021 * - Backend rebalancer (\`agent-action-rebalancer\`) 20022 * - Frontend Agents dashboard tab (\`â Leads\` / \`â Leads\` badge) 20023 * - Frontend reassign modal (eligible section) 20024 * 20025 * Rules (ALL must pass): 20026 * 1. Agent has a \`maxId\` 20027 * 2. Not archived 20028 * 3. \`isAvailableForDistribution !== false\` 20029 * 4. \`tenants[]\` includes \`tenantName\` (when tenantName is provided) 20030 * 5. \`lastMaxContactStatus\` is set and not in { LoggedOff } 20031 * 6. If \`maxConcurrentLeads > 0\` AND \`ownedCount\` is provided: 20032 * \`ownedCount < maxConcurrentLeads\` (enforced only when ownedCount is known) 20033 */ 20034export function getAgentDistributionStatus( 20035 agent: AgentDistributionInput, 20036 tenantName?: string, 20037 ownedCount?: number, 20038): AgentDistributionStatus { 20039 const capacity = agent.maxConcurrentLeads ?? undefined; 20040 20041 if (!agent.maxId) return { available: false, reason: 'no_max_id', atCapacity: false, capacity, ownedCount }; 20042 if (agent.isArchived) return { available: false, reason: 'archived', atCapacity: false, capacity, ownedCount }; 20043 if (agent.isAvailableForDistribution === false) return { available: false, reason: 'distribution_disabled', atCapacity: false, capacity, ownedCount }; 20044 if (tenantName && (!agent.tenants || !agent.tenants.includes(tenantName))) return { available: false, reason: 'wrong_tenant', atCapacity: false, capacity, ownedCount }; 20045 if (!agent.lastMaxContactStatus || NOT_AVAILABLE_STATUSES.has(agent.lastMaxContactStatus)) { 20046 return { available: false, reason: 'status_not_distributable', atCapacity: false, capacity, ownedCount }; 20047 } 20048 20049 if (capacity && capacity > 0 && ownedCount !== undefined && ownedCount >= capacity) { 20050 return { available: false, reason: 'at_capacity', atCapacity: true, capacity, ownedCount }; 20051 } 20052 20053 return { available: true, atCapacity: false, capacity, ownedCount }; 20054} 20055 20056/** 20057 * Boolean convenience wrapper around \`getAgentDistributionStatus\`. 20058 * Back-compat signature â \`ownedCount\` is optional, so existing two-arg callers 20059 * continue to work (rules 1-5 only, capacity skipped). 20060 */ 20061export function isAgentAvailableForDistribution( 20062 agent: AgentDistributionInput, 20063 tenantName?: string, 20064 ownedCount?: number, 20065): boolean { 20066 return getAgentDistributionStatus(agent, tenantName, ownedCount).available; 20067} 20068`,sn=`/** 20069 * Tests for the BC auto-remove / ownership-clear disposition sets. 20070 * Run: npx --yes tsx --test src/max-result-codes.test.ts 20071 * 20072 * Guards the 2026-07-22 operator decision: exactly 14 codes auto-remove a lead 20073 * from the Buyer-Close pile (all stamping 'removed'); PCB and the 20074 * stay-in-pile codes must NOT be members. 20075 */ 20076import { describe, it } from 'node:test'; 20077import assert from 'node:assert/strict'; 20078import { 20079 BC_AUTO_REMOVE_CODES, 20080 isBcAutoRemoveCode, 20081 OWNERSHIP_CLEAR_CODES, 20082 clearsAgentOwnership, 20083 isSaleCode, 20084 isAgentOwnershipCode, 20085 BC_ENTRY_CODES, 20086 isBcEntryCode, 20087 isConnectedConversationCode, 20088 getMaxResultCodeInfo, 20089 getResultCodeCategory, 20090 dispositionToSalesCycle, 20091} from './max-result-codes.js'; 20092 20093/** New codes announced by MMC sales 5 Aug 2026 (Max list restructure). */ 20094const NEW_2026_08_CODES = ['CTN', 'PITCHEDSI', 'PITCHEDVI', 'BCCB', 'BCSR', 'BCTB']; 20095 20096const EXPECTED_14 = [
20097 'SALEOUT', 'SALEOUT-BC', 'SOLDOUT', 'SALEFIN', 'SALEFIN-BC', 'SOLDFNANCE', 20098 'NIPRI', 'NIPRI-BC', 'NIPRO', 'NIPRO-BC', 20099 'FINDECLINE', 'FNANCEDEC', 'FINDEC-BC', 20100 'LOGENQ', 20101]; 20102 20103describe('BC_AUTO_REMOVE_CODES', () => { 20104 it('is exactly the 14 approved codes (order-insensitive)', () => { 20105 assert.deepEqual([...BC_AUTO_REMOVE_CODES].sort(), [...EXPECTED_14].sort()); 20106 }); 20107 20108 it('isBcAutoRemoveCode true for every member', () => { 20109 for (const code of EXPECTED_14) { 20110 assert.equal(isBcAutoRemoveCode(code), true, code); 20111 } 20112 }); 20113 20114 it('PCB is deliberately EXCLUDED (2026-07-22 decision)', () => { 20115 assert.equal(isBcAutoRemoveCode('PCB'), false); 20116 assert.equal(isBcAutoRemoveCode('NPCB'), false); 20117 }); 20118 20119 it('stay-in-pile codes are not members', () => { 20120 for (const code of ['HKT', 'DNC', 'NEVERENQ', 'NEVENQ', 'RFD', 'CCS', 'BUYCLO', 'AGAM-BC', 'PCB']) { 20121 assert.equal(isBcAutoRemoveCode(code), false, code); 20122 } 20123 }); 20124 20125 it('unknown / empty codes are not members', () => { 20126 assert.equal(isBcAutoRemoveCode('NOT_A_CODE'), false); 20127 assert.equal(isBcAutoRemoveCode(''), false); 20128 }); 20129}); 20130 20131describe('OWNERSHIP_CLEAR_CODES (widened 2026-07-22)', () => { 20132 it('equals the BC auto-remove union', () => { 20133 assert.deepEqual([...OWNERSHIP_CLEAR_CODES].sort(), [...EXPECTED_14].sort()); 20134 }); 20135 20136 it('clearsAgentOwnership true for all 14, incl. the previously-manual NI/finance/LOGENQ codes', () => { 20137 for (const code of EXPECTED_14) { 20138 assert.equal(clearsAgentOwnership(code), true, code); 20139 } 20140 }); 20141 20142 it('clearsAgentOwnership false for PCB / HKT / DNC / ownership codes', () => { 20143 for (const code of ['PCB', 'HKT', 'DNC', 'NEVERENQ', 'BUYCLO', 'AGAM-BC']) { 20144 assert.equal(clearsAgentOwnership(code), false, code); 20145 } 20146 }); 20147}); 20148 20149describe('unchanged predicates', () => { 20150 it('isSaleCode stays the 6-code sale family', () => { 20151 for (const code of ['SALEFIN', 'SALEFIN-BC', 'SALEOUT', 'SALEOUT-BC', 'SOLDFNANCE', 'SOLDOUT']) { 20152 assert.equal(isSaleCode(code), true, code); 20153 } 20154 assert.equal(isSaleCode('NIPRI'), false); 20155 assert.equal(isSaleCode('LOGENQ'), false); 20156 }); 20157 20158 it('isAgentOwnershipCode = BUYCLO/AGAM-BC + the 2026-08 BC follow-up codes', () => { 20159 for (const code of ['BUYCLO', 'AGAM-BC', 'BCCB', 'BCSR', 'BCTB']) { 20160 assert.equal(isAgentOwnershipCode(code), true, code); 20161 } 20162 assert.equal(isAgentOwnershipCode('BUYHOT'), false); 20163 assert.equal(isAgentOwnershipCode('SALEOUT'), false); 20164 assert.equal(isAgentOwnershipCode('PITCHEDSI'), false); 20165 assert.equal(isAgentOwnershipCode('CTN'), false); 20166 }); 20167}); 20168 20169describe('2026-08 new codes (CTN / PITCHED* / BC*)', () => { 20170 it('all six are registered', () => { 20171 for (const code of NEW_2026_08_CODES) { 20172 assert.notEqual(getMaxResultCodeInfo(code), null, code); 20173 } 20174 }); 20175 20176 it('none of them auto-remove from BC, clear ownership, or count as a sale', () => { 20177 for (const code of NEW_2026_08_CODES) { 20178 assert.equal(isBcAutoRemoveCode(code), false, code); 20179 assert.equal(clearsAgentOwnership(code), false, code); 20180 assert.equal(isSaleCode(code), false, code); 20181 } 20182 }); 20183 20184 it('BC follow-up codes stamp ownership/BC entry; CTN + pitched codes do not (2026-08-05 decision)', () => { 20185 for (const code of ['BCCB', 'BCSR', 'BCTB']) { 20186 assert.equal(isAgentOwnershipCode(code), true, code); 20187 } 20188 for (const code of ['CTN', 'PITCHEDSI', 'PITCHEDVI']) { 20189 assert.equal(isAgentOwnershipCode(code), false, code); 20190 } 20191 }); 20192 20193 it('BC ENTRY codes are exactly BUYCLO + AGAM-BC â BC follow-up codes are NOT entry codes (2026-08-11 decision: BC* never re-opens an exited lead)', () => { 20194 assert.deepEqual([...BC_ENTRY_CODES].sort(), ['AGAM-BC', 'BUYCLO']); 20195 for (const code of ['BUYCLO', 'AGAM-BC']) { 20196 assert.equal(isBcEntryCode(code), true, code); 20197 assert.equal(isAgentOwnershipCode(code), true, code); 20198 } 20199 for (const code of ['BCCB', 'BCSR', 'BCTB', 'CTN', 'PITCHEDSI', 'PITCHEDVI', 'BUYHOT', 'SALEOUT']) { 20200 assert.equal(isBcEntryCode(code), false, code); 20201 } 20202 }); 20203 20204 it('pitched + BC follow-up codes ARE connected conversations (suppress cold outreach)', () => { 20205 for (const code of ['PITCHEDSI', 'PITCHEDVI', 'BCCB', 'BCSR', 'BCTB']) { 20206 assert.equal(isConnectedConversationCode(code), true, code); 20207 } 20208 }); 20209 20210 it('CTN is NOT a connected conversation (answering-machine-equivalent)', () => { 20211 assert.equal(isConnectedConversationCode('CTN'), false); 20212 assert.equal(getResultCodeCategory('CTN'), 'unavailable'); 20213 }); 20214 20215 it('categories: pitched/BCSR qualified, BCCB/BCTB callback', () => { 20216 assert.equal(getResultCodeCategory('PITCHEDSI'), 'qualified'); 20217 assert.equal(getResultCodeCategory('PITCHEDVI'), 'qualified'); 20218 assert.equal(getResultCodeCategory('BCSR'), 'qualified'); 20219 assert.equal(getResultCodeCategory('BCCB'), 'callback'); 20220 assert.equal(getResultCodeCategory('BCTB'), 'callback'); 20221 }); 20222 20223 it('sales-cycle mapping exists for all six (dismiss_and_close_lead must not no-op)', () => { 20224 for (const code of NEW_2026_08_CODES) {
20225 assert.notEqual(dispositionToSalesCycle(code), null, code); 20226 } 20227 assert.deepEqual(dispositionToSalesCycle('CTN'), { stage: 'untapped' }); 20228 assert.deepEqual(dispositionToSalesCycle('PITCHEDSI'), { stage: 'engaged', buyerReadiness: 'warm' }); 20229 assert.deepEqual(dispositionToSalesCycle('PITCHEDVI'), { stage: 'engaged', buyerReadiness: 'hot' }); 20230 assert.deepEqual(dispositionToSalesCycle('BCCB'), { stage: 'pending', buyerReadiness: 'hot' }); 20231 }); 20232}); 20233`,ln=`/** 20234 * MaxContact Result Code Types 20235 * 20236 * Result codes (disposition codes) are used by MaxContact to categorize 20237 * call outcomes. Certain codes like BUYHOT and BUYCLO trigger agent ownership 20238 * of leads. 20239 * 20240 * Data sourced from MaxContact API: /telephony/resultcodes/user/{userId} 20241 */ 20242 20243import type { SalesCycleStage, BuyerReadiness, DispositionExitCode, ClosedLostReason } from './lead.js'; 20244 20245/** MaxContact result code metadata */ 20246export interface MaxResultCode { 20247 /** The result code string (e.g., "BUYHOT") */ 20248 code: string; 20249 /** Human-readable description */ 20250 description: string; 20251 /** Whether this triggers a callback to the customer */ 20252 isCallback: boolean; 20253 /** Whether this is considered a successful call outcome */ 20254 isSuccess: boolean; 20255 /** Whether the agent should call again */ 20256 isCallAgain: boolean; 20257 /** Whether the call was transferred */ 20258 isTransfer: boolean; 20259 /** Whether this is a system-generated result code */ 20260 isSystemResultCode: boolean; 20261} 20262 20263/** 20264 * Static map of all MaxContact result codes with their metadata. 20265 * Sourced from MaxContact API response. 20266 */ 20267export const MAX_RESULT_CODES: Record<string, MaxResultCode> = { 20268 // System codes 20269 'ITXUSERDISCONNECT': { 20270 code: 'ITXUSERDISCONNECT', 20271 description: 'A User Disconnected before handling the interaction', 20272 isCallback: true, 20273 isSuccess: false, 20274 isCallAgain: false, 20275 isTransfer: false, 20276 isSystemResultCode: true, 20277 }, 20278 'SYSBUSY': { 20279 code: 'SYSBUSY', 20280 description: 'Busy (System)', 20281 isCallback: false, 20282 isSuccess: false, 20283 isCallAgain: false, 20284 isTransfer: false, 20285 isSystemResultCode: true, 20286 }, 20287 'ICANCELTERMINATE': { 20288 code: 'ICANCELTERMINATE', 20289 description: 'Cancel Terminate Interaction', 20290 isCallback: true, 20291 isSuccess: false, 20292 isCallAgain: false, 20293 isTransfer: false, 20294 isSystemResultCode: true, 20295 }, 20296 'IDUPLICATETERMINATE': { 20297 code: 'IDUPLICATETERMINATE', 20298 description: 'Duplicate Terminate Interaction', 20299 isCallback: true, 20300 isSuccess: false, 20301 isCallAgain: false, 20302 isTransfer: false, 20303 isSystemResultCode: true, 20304 }, 20305 'ITXMOVED': { 20306 code: 'ITXMOVED', 20307 description: 'Interaction Session Was Moved', 20308 isCallback: true, 20309 isSuccess: false, 20310 isCallAgain: false, 20311 isTransfer: false, 20312 isSystemResultCode: true, 20313 }, 20314 'IABANDONED': { 20315 code: 'IABANDONED', 20316 description: 'Interaction abandoned', 20317 isCallback: true, 20318 isSuccess: false, 20319 isCallAgain: false, 20320 isTransfer: false, 20321 isSystemResultCode: true, 20322 }, 20323 'IBRANCHED': { 20324 code: 'IBRANCHED', 20325 description: 'Interaction branched', 20326 isCallback: true, 20327 isSuccess: false, 20328 isCallAgain: false, 20329 isTransfer: false, 20330 isSystemResultCode: true, 20331 }, 20332 'ICUSTOMERTIMEOUT': { 20333 code: 'ICUSTOMERTIMEOUT', 20334 description: 'Interaction customer timeout', 20335 isCallback: true, 20336 isSuccess: false, 20337 isCallAgain: false, 20338 isTransfer: false, 20339 isSystemResultCode: true, 20340 }, 20341 'IRECEIVED': { 20342 code: 'IRECEIVED', 20343 description: 'Interaction received', 20344 isCallback: true, 20345 isSuccess: false, 20346 isCallAgain: false, 20347 isTransfer: false, 20348 isSystemResultCode: true, 20349 }, 20350 'ISENT': { 20351 code: 'ISENT', 20352 description: 'Interaction sent', 20353 isCallback: true, 20354 isSuccess: false, 20355 isCallAgain: false, 20356 isTransfer: false, 20357 isSystemResultCode: true, 20358 }, 20359 'ITERMINATED': { 20360 code: 'ITERMINATED', 20361 description: 'Interaction terminated', 20362 isCallback: true, 20363 isSuccess: false, 20364 isCallAgain: false, 20365 isTransfer: false, 20366 isSystemResultCode: true, 20367 }, 20368 'ITIMEOUT': { 20369 code: 'ITIMEOUT', 20370 description: 'Interaction timed out', 20371 isCallback: true, 20372 isSuccess: false,
20373 isCallAgain: false, 20374 isTransfer: false, 20375 isSystemResultCode: true, 20376 }, 20377 'SKIPPED': { 20378 code: 'SKIPPED', 20379 description: 'Skipped Record', 20380 isCallback: false, 20381 isSuccess: false, 20382 isCallAgain: false, 20383 isTransfer: false, 20384 isSystemResultCode: true, 20385 }, 20386 20387 // Agent answer machine codes 20388 'AGAM': { 20389 code: 'AGAM', 20390 description: 'Agent Answer Machine', 20391 isCallback: false, 20392 isSuccess: false, 20393 isCallAgain: false, 20394 isTransfer: false, 20395 isSystemResultCode: false, 20396 }, 20397 'AGAMF1': { 20398 code: 'AGAMF1', 20399 description: 'AGAM for F1', 20400 isCallback: false, 20401 isSuccess: false, 20402 isCallAgain: true, 20403 isTransfer: false, 20404 isSystemResultCode: false, 20405 }, 20406 'AGAM-BC': { 20407 code: 'AGAM-BC', 20408 description: 'Buyer Closing AGAM', 20409 isCallback: true, 20410 isSuccess: false, 20411 isCallAgain: true, 20412 isTransfer: false, 20413 isSystemResultCode: false, 20414 }, 20415 20416 // Buyer qualification codes (trigger agent ownership) 20417 'BUYHOT': { 20418 code: 'BUYHOT', 20419 description: 'Buyer Hot - Lead is qualified and interested', 20420 isCallback: true, 20421 isSuccess: false, 20422 isCallAgain: true, 20423 isTransfer: false, 20424 isSystemResultCode: false, 20425 }, 20426 'BUYCLO': { 20427 code: 'BUYCLO', 20428 description: 'Buyer Closing - Lead is ready to purchase', 20429 isCallback: true, 20430 isSuccess: false, 20431 isCallAgain: false, 20432 isTransfer: false, 20433 isSystemResultCode: false, 20434 }, 20435 'BUYHOT-BC': { 20436 code: 'BUYHOT-BC', 20437 description: 'Buyer Hot BC Call', 20438 isCallback: true, 20439 isSuccess: false, 20440 isCallAgain: true, 20441 isTransfer: false, 20442 isSystemResultCode: false, 20443 }, 20444 20445 // Sale codes 20446 'SALEFIN': { 20447 code: 'SALEFIN', 20448 description: 'Sale Finance', 20449 isCallback: false, 20450 isSuccess: true, 20451 isCallAgain: false, 20452 isTransfer: false, 20453 isSystemResultCode: false, 20454 }, 20455 'SALEFIN-BC': { 20456 code: 'SALEFIN-BC', 20457 description: 'Sale Finance Buyer Closing', 20458 isCallback: false, 20459 isSuccess: true, 20460 isCallAgain: false, 20461 isTransfer: false, 20462 isSystemResultCode: false, 20463 }, 20464 'SALEOUT': { 20465 code: 'SALEOUT', 20466 description: 'Sold Outright', 20467 isCallback: false, 20468 isSuccess: true, 20469 isCallAgain: false, 20470 isTransfer: false, 20471 isSystemResultCode: false, 20472 }, 20473 'SALEOUT-BC': { 20474 code: 'SALEOUT-BC', 20475 description: 'Sale Outright - Buyer Closing', 20476 isCallback: false, 20477 isSuccess: true, 20478 isCallAgain: false, 20479 isTransfer: false, 20480 isSystemResultCode: false, 20481 }, 20482 'SOLDFNANCE': { 20483 code: 'SOLDFNANCE', 20484 description: 'Sold Finance', 20485 isCallback: false, 20486 isSuccess: true, 20487 isCallAgain: false, 20488 isTransfer: false, 20489 isSystemResultCode: false, 20490 }, 20491 'SOLDOUT': { 20492 code: 'SOLDOUT', 20493 description: 'Sold Outright', 20494 isCallback: false, 20495 isSuccess: true, 20496 isCallAgain: false, 20497 isTransfer: false, 20498 isSystemResultCode: false, 20499 }, 20500 20501 // Not interested codes 20502 'NIPRI': { 20503 code: 'NIPRI', 20504 description: 'Not Interested - Price', 20505 isCallback: false, 20506 isSuccess: false, 20507 isCallAgain: false, 20508 isTransfer: false, 20509 isSystemResultCode: false, 20510 }, 20511 'NIPRO': { 20512 code: 'NIPRO', 20513 description: 'Not Interested - Product', 20514 isCallback: false, 20515 isSuccess: false, 20516 isCallAgain: false, 20517 isTransfer: false, 20518 isSystemResultCode: false, 20519 }, 20520 'NIPRI-BC': { 20521 code: 'NIPRI-BC', 20522 description: 'Not Interested Price BC Call', 20523 isCallback: false, 20524 isSuccess: false, 20525 isCallAgain: false, 20526 isTransfer: false, 20527 isSystemResultCode: false, 20528 }, 20529 'NIPRO-BC': { 20530 code: 'NIPRO-BC', 20531 description: 'Not Interested Product Buyer Closing', 20532 isCallback: false, 20533 isSuccess: false, 20534 isCallAgain: false, 20535 isTransfer: false, 20536 isSystemResultCode: false, 20537 }, 20538 20539 // Finance decline codes 20540 'FNANCEDEC': { 20541 code: 'FNANCEDEC', 20542 description: 'Finance Declined', 20543 isCallback: false, 20544 isSuccess: false, 20545 isCallAgain: false, 20546 isTransfer: false, 20547 isSystemResultCode: false, 20548 }, 20549 'FINDECLINE': { 20550 code: 'FINDECLINE', 20551 description: 'Finance Declined', 20552 isCallback: false, 20553 isSuccess: false,
20554 isCallAgain: false, 20555 isTransfer: false, 20556 isSystemResultCode: false, 20557 }, 20558 'FINDEC-BC': { 20559 code: 'FINDEC-BC', 20560 description: 'Finance Decline BC Call', 20561 isCallback: false, 20562 isSuccess: false, 20563 isCallAgain: false, 20564 isTransfer: false, 20565 isSystemResultCode: false, 20566 }, 20567 20568 // Call status codes 20569 'NOANSWER': { 20570 code: 'NOANSWER', 20571 description: 'No Answer', 20572 isCallback: false, 20573 isSuccess: false, 20574 isCallAgain: false, 20575 isTransfer: true, 20576 isSystemResultCode: false, 20577 }, 20578 'BUSY': { 20579 code: 'BUSY', 20580 description: 'Busy', 20581 isCallback: false, 20582 isSuccess: false, 20583 isCallAgain: false, 20584 isTransfer: true, 20585 isSystemResultCode: false, 20586 }, 20587 'HUNGUP': { 20588 code: 'HUNGUP', 20589 description: 'Customer HungUp', 20590 isCallback: false, 20591 isSuccess: false, 20592 isCallAgain: false, 20593 isTransfer: false, 20594 isSystemResultCode: false, 20595 }, 20596 'DEADAIR': { 20597 code: 'DEADAIR', 20598 description: 'Dead Air', 20599 isCallback: false, 20600 isSuccess: false, 20601 isCallAgain: false, 20602 isTransfer: false, 20603 isSystemResultCode: false, 20604 }, 20605 'DNC': { 20606 code: 'DNC', 20607 description: 'Do Not Call', 20608 isCallback: false, 20609 isSuccess: false, 20610 isCallAgain: false, 20611 isTransfer: false, 20612 isSystemResultCode: false, 20613 }, 20614 'DISCONNECT': { 20615 code: 'DISCONNECT', 20616 description: 'Call Disconnected', 20617 isCallback: false, 20618 isSuccess: false, 20619 isCallAgain: false, 20620 isTransfer: false, 20621 isSystemResultCode: false, 20622 }, 20623 'WRONGNO': { 20624 code: 'WRONGNO', 20625 description: 'Wrong Number', 20626 isCallback: false, 20627 isSuccess: false, 20628 isCallAgain: false, 20629 isTransfer: false, 20630 isSystemResultCode: false, 20631 }, 20632 'INVNUM': { 20633 code: 'INVNUM', 20634 description: 'Invalid Number', 20635 isCallback: false, 20636 isSuccess: false, 20637 isCallAgain: false, 20638 isTransfer: false, 20639 isSystemResultCode: false, 20640 }, 20641 20642 // Callback codes 20643 'PCB': { 20644 code: 'PCB', 20645 description: 'Set A Callback', 20646 isCallback: true, 20647 isSuccess: false, 20648 isCallAgain: false, 20649 isTransfer: false, 20650 isSystemResultCode: false, 20651 }, 20652 'NPCB': { 20653 code: 'NPCB', 20654 description: 'Set A Callback - New Number', 20655 isCallback: true, 20656 isSuccess: false, 20657 isCallAgain: true, 20658 isTransfer: false, 20659 isSystemResultCode: false, 20660 }, 20661 'REQCALLBK': { 20662 code: 'REQCALLBK', 20663 description: 'Request Call Back', 20664 isCallback: false, 20665 isSuccess: false, 20666 isCallAgain: false, 20667 isTransfer: false, 20668 isSystemResultCode: false, 20669 }, 20670 'CCS': { 20671 code: 'CCS', 20672 description: 'Current CallBack Set', 20673 isCallback: false, 20674 isSuccess: false, 20675 isCallAgain: false, 20676 isTransfer: false, 20677 isSystemResultCode: false, 20678 }, 20679 20680 // Transfer codes 20681 'HKT': { 20682 code: 'HKT', 20683 description: 'Hot Key Transfer', 20684 isCallback: false, 20685 isSuccess: true, 20686 isCallAgain: false, 20687 isTransfer: true, 20688 isSystemResultCode: false, 20689 }, 20690 'INTTRAN': { 20691 code: 'INTTRAN', 20692 description: 'Internal Transfer', 20693 isCallback: false, 20694 isSuccess: false, 20695 isCallAgain: false, 20696 isTransfer: true, 20697 isSystemResultCode: false, 20698 }, 20699 'INTTRANS': { 20700 code: 'INTTRANS', 20701 description: 'Internal Transfer', 20702 isCallback: false, 20703 isSuccess: false, 20704 isCallAgain: false, 20705 isTransfer: true, 20706 isSystemResultCode: false, 20707 }, 20708 20709 // Showroom codes 20710 'SHOWROOM': { 20711 code: 'SHOWROOM', 20712 description: 'Showroom - Customer visiting in person', 20713 isCallback: false, 20714 isSuccess: false, 20715 isCallAgain: false, 20716 isTransfer: false, 20717 isSystemResultCode: false, 20718 }, 20719 'SHOWRM-BC': { 20720 code: 'SHOWRM-BC', 20721 description: 'Showroom - BC Call', 20722 isCallback: true, 20723 isSuccess: false, 20724 isCallAgain: true, 20725 isTransfer: false, 20726 isSystemResultCode: false, 20727 }, 20728 20729 // Enquiry codes 20730 'NEVERENQ': { 20731 code: 'NEVERENQ', 20732 description: 'Never Enquired', 20733 isCallback: false, 20734 isSuccess: false, 20735 isCallAgain: false, 20736 isTransfer: false, 20737 isSystemResultCode: false, 20738 }, 20739 'NEVENQ': { 20740 code: 'NEVENQ', 20741 description: 'Never Enquired / Hang Up', 20742 isCallback: false, 20743 isSuccess: false,
20744 isCallAgain: false, 20745 isTransfer: false, 20746 isSystemResultCode: false, 20747 }, 20748 'LOGENQ': { 20749 code: 'LOGENQ', 20750 description: 'Logistics Enquiry', 20751 isCallback: false, 20752 isSuccess: false, 20753 isCallAgain: false, 20754 isTransfer: false, 20755 isSystemResultCode: false, 20756 }, 20757 20758 // Miscellaneous codes 20759 'REDIAL': { 20760 code: 'REDIAL', 20761 description: 'Redial Client Straight Away', 20762 isCallback: false, 20763 isSuccess: false, 20764 isCallAgain: true, 20765 isTransfer: false, 20766 isSystemResultCode: false, 20767 }, 20768 'Redial1stA': { 20769 code: 'Redial1stA', 20770 description: 'Redial Lead After 1st Attempt Only', 20771 isCallback: false, 20772 isSuccess: false, 20773 isCallAgain: true, 20774 isTransfer: false, 20775 isSystemResultCode: false, 20776 }, 20777 'RFD': { 20778 code: 'RFD', 20779 description: 'Remove From Dialler', 20780 isCallback: false, 20781 isSuccess: false, 20782 isCallAgain: false, 20783 isTransfer: false, 20784 isSystemResultCode: false, 20785 }, 20786 'NOTESONLY': { 20787 code: 'NOTESONLY', 20788 description: 'Notes only on a script only script', 20789 isCallback: false, 20790 isSuccess: false, 20791 isCallAgain: false, 20792 isTransfer: false, 20793 isSystemResultCode: false, 20794 }, 20795 'ABIQ': { 20796 code: 'ABIQ', 20797 description: 'Abandon in Queue - Recall 1min', 20798 isCallback: false, 20799 isSuccess: false, 20800 isCallAgain: false, 20801 isTransfer: false, 20802 isSystemResultCode: false, 20803 }, 20804 'IVRMISSED': { 20805 code: 'IVRMISSED', 20806 description: 'Missed Inbound Call', 20807 isCallback: false, 20808 isSuccess: false, 20809 isCallAgain: false, 20810 isTransfer: false, 20811 isSystemResultCode: false, 20812 }, 20813 'MISINBOUND': { 20814 code: 'MISINBOUND', 20815 description: 'Missed Inbound Call', 20816 isCallback: false, 20817 isSuccess: false, 20818 isCallAgain: false, 20819 isTransfer: false, 20820 isSystemResultCode: false, 20821 }, 20822 'INBOUND': { 20823 code: 'INBOUND', 20824 description: 'Inbound call with no agent disposition (system default)', 20825 isCallback: false, 20826 isSuccess: false, 20827 isCallAgain: false, 20828 isTransfer: false, 20829 isSystemResultCode: true, 20830 }, 20831 'RETVM': { 20832 code: 'RETVM', 20833 description: 'Return to Voicemail', 20834 isCallback: false, 20835 isSuccess: false, 20836 isCallAgain: false, 20837 isTransfer: false, 20838 isSystemResultCode: false, 20839 }, 20840 20841 // Transfer / bridge codes (in addition to INTTRAN / INTTRANS / HKT) 20842 'BRIDGE': { 20843 code: 'BRIDGE', 20844 description: 'Bridge Call (Internal)', 20845 isCallback: false, 20846 isSuccess: false, 20847 isCallAgain: false, 20848 isTransfer: true, 20849 isSystemResultCode: true, 20850 }, 20851 'XFER': { 20852 code: 'XFER', 20853 description: 'Transferred', 20854 isCallback: false, 20855 isSuccess: false, 20856 isCallAgain: false, 20857 isTransfer: true, 20858 isSystemResultCode: false, 20859 }, 20860 20861 // System / dialler-side codes 20862 'KICKED': { 20863 code: 'KICKED', 20864 description: 'User Kicked', 20865 isCallback: false, 20866 isSuccess: false, 20867 isCallAgain: false, 20868 isTransfer: false, 20869 isSystemResultCode: false, 20870 }, 20871 20872 // 2026-08 codes (announced by MMC sales 5 Aug 2026 â Max list restructure). 20873 // PITCHEDSI/PITCHEDVI move the lead OUT of the dialler into its own Max list 20874 // (NOT saved as a BC). BCCB/BCSR/BCTB are BC follow-up codes and ALSO count 20875 // as BC entry/ownership evidence (see isAgentOwnershipCode â the team marks 20876 // BC in the Max back end with no webhook, so these codes are often the first 20877 // BC signal the platform sees). CTN behaves like the answering-machine 20878 // family in the dialler (lead keeps being dialled). 20879 'CTN': { 20880 code: 'CTN', 20881 description: 'Cant Talk Now', 20882 isCallback: false, 20883 isSuccess: false, 20884 isCallAgain: true, 20885 isTransfer: false, 20886 isSystemResultCode: false, 20887 }, 20888 'PITCHEDSI': { 20889 code: 'PITCHEDSI', 20890 description: 'Pitched - Shown Interest', 20891 isCallback: false, 20892 isSuccess: false, 20893 isCallAgain: false, 20894 isTransfer: false, 20895 isSystemResultCode: false, 20896 }, 20897 'PITCHEDVI': { 20898 code: 'PITCHEDVI', 20899 description: 'Pitched - Very Interested', 20900 isCallback: false, 20901 isSuccess: false, 20902 isCallAgain: false, 20903 isTransfer: false, 20904 isSystemResultCode: false, 20905 }, 20906 'BCCB': { 20907 code: 'BCCB', 20908 description: 'BC Call Back', 20909 isCallback: true, 20910 isSuccess: false, 20911 isCallAgain: true, 20912 isTransfer: false, 20913 isSystemResultCode: false, 20914 }, 20915 'BCSR': { 20916 code: 'BCSR', 20917 description: 'BC Showroom', 20918 isCallback: false, 20919 isSuccess: false,
20920 isCallAgain: false, 20921 isTransfer: false, 20922 isSystemResultCode: false, 20923 }, 20924 'BCTB': { 20925 code: 'BCTB', 20926 description: 'BC Time Booked - Private Call Back Scheduled', 20927 isCallback: true, 20928 isSuccess: false, 20929 isCallAgain: false, 20930 isTransfer: false, 20931 isSystemResultCode: false, 20932 }, 20933 20934 // Test codes 20935 'AutoTest': { 20936 code: 'AutoTest', 20937 description: 'Automation Test', 20938 isCallback: false, 20939 isSuccess: false, 20940 isCallAgain: false, 20941 isTransfer: false, 20942 isSystemResultCode: false, 20943 }, 20944 'MaxTest': { 20945 code: 'MaxTest', 20946 description: 'Max support testing', 20947 isCallback: false, 20948 isSuccess: false, 20949 isCallAgain: false, 20950 isTransfer: false, 20951 isSystemResultCode: false, 20952 }, 20953 'MaxPCB': { 20954 code: 'MaxPCB', 20955 description: 'MaxContact PCB testing', 20956 isCallback: true, 20957 isSuccess: false, 20958 isCallAgain: false, 20959 isTransfer: false, 20960 isSystemResultCode: false, 20961 }, 20962 'MICHAEL': { 20963 code: 'MICHAEL', 20964 description: 'Michael Queue Missed Call', 20965 isCallback: false, 20966 isSuccess: false, 20967 isCallAgain: false, 20968 isTransfer: false, 20969 isSystemResultCode: false, 20970 }, 20971}; 20972 20973/** 20974 * Look up result code metadata by code string. 20975 * Returns null if the code is not found. 20976 */ 20977export function getMaxResultCodeInfo(code: string): MaxResultCode | null { 20978 return MAX_RESULT_CODES[code] ?? null; 20979} 20980 20981/** 20982 * Check if a result code triggers agent ownership of the lead. 20983 * BUYCLO and AGAM-BC codes assign the lead to the calling agent. 20984 * (BUYHOT was historically in this set but no longer stamps ownership â 20985 * see workspace docs on the MaxContact ownership rewrite.) 20986 * 20987 * 2026-08-05: the BC follow-up codes BCCB / BCSR / BCTB also stamp 20988 * ownership + BC entry. Rationale: the sales team marks leads BC directly 20989 * in the MaxContact back end (no webhook fires), then works them with these 20990 * codes â ~90 leads were observed carrying BCTB/BCSR with no BUYCLO/AGAM-BC 20991 * ever received, leaving them outside the platform's BC pile and protected 20992 * from cold outreach only by the overwritable lastMaxResultCode. Treating 20993 * BC* codes as BC-entry evidence makes that protection durable and puts 20994 * those leads into BC reporting. 20995 */ 20996export function isAgentOwnershipCode(code: string): boolean { 20997 return code === 'BUYCLO' || code === 'AGAM-BC' 20998 || code === 'BCCB' || code === 'BCSR' || code === 'BCTB'; 20999} 21000 21001/** 21002 * Codes that mark a GENUINE Buyer-Close entry / re-entry. 21003 * 21004 * 2026-08-11 (operator decision, Chris): this is deliberately NARROWER than 21005 * \`isAgentOwnershipCode\`. The BC follow-up codes (BCCB/BCSR/BCTB) are 21006 * evidence a lead is being WORKED as a BC â they may claim an unowned lead 21007 * and stamp first entry on a never-BC lead â but they must NOT re-open a 21008 * lead that has already EXITED the pile (bcExitUtc set, e.g. sold or 21009 * auto-removed). Only a true entry code re-enters a lead, clearing 21010 * bcExitReason/bcExitUtc. Without this split, any BC* follow-up call on a 21011 * sold/removed lead silently erased the exit stamp and resurrected the lead 21012 * into the pile aged from its original entry. 21013 */ 21014export const BC_ENTRY_CODES = ['BUYCLO', 'AGAM-BC'] as const; 21015export type BcEntryCode = typeof BC_ENTRY_CODES[number]; 21016 21017/** Check if a result code is a genuine BC entry/re-entry code (see BC_ENTRY_CODES). */ 21018export function isBcEntryCode(code: string): boolean { 21019 return (BC_ENTRY_CODES as readonly string[]).includes(code); 21020} 21021 21022/** 21023 * Check if a result code is a sale outcome. 21024 * Note: sale codes also end agent ownership â see \`clearsAgentOwnership()\`. 21025 * They are no longer "lock disposition" codes (they do not persist as lockType). 21026 */ 21027export function isSaleCode(code: string): boolean { 21028 return ['SALEFIN', 'SALEFIN-BC', 'SALEOUT', 'SALEOUT-BC', 'SOLDFNANCE', 'SOLDOUT'].includes(code); 21029} 21030 21031/** 21032 * Disposition codes that AUTO-REMOVE a lead from the Buyer-Close pile at CDR 21033 * time, stamping \`bcExitReason='removed'\` + \`bcExitUtc\` (= call start). 21034 * 21035 * Operator decision (Chris, 2026-07-22 â supersedes the 2026-07-04 decision 21036 * that kept finance-declined in the pile and included PCB): 21037 * - ALL of these stamp 'removed', including the dialler sale codes. 21038 * \`bcExitReason='sold'\` is reserved for ORDER-VERIFIED exits (a Shopify 21039 * order.confirmed matching the lead) â a dialler-recorded sale exits the 21040 * pile as 'removed' and flips to 'sold' if/when the order arrives 21041 * ("order wins on reason, first exit wins on date").
21042 * - PCB is deliberately EXCLUDED (2026-07-22; was in the 5-Jul list). 21043 * - Finance-declined family is deliberately INCLUDED (reverses 5-Jul). 21044 * - HKT / DNC / NEVERENQ / NEVENQ / RFD / CCS stay in the pile for manual 21045 * disposition. 21046 */ 21047export const BC_AUTO_REMOVE_CODES = [ 21048 // Dialler sale family (exit as 'removed'; order.confirmed upgrades to 'sold') 21049 'SALEOUT', 'SALEOUT-BC', 'SOLDOUT', 'SALEFIN', 'SALEFIN-BC', 'SOLDFNANCE', 21050 // Not interested 21051 'NIPRI', 'NIPRI-BC', 'NIPRO', 'NIPRO-BC', 21052 // Finance declined (both live spellings + BC variant) 21053 'FINDECLINE', 'FNANCEDEC', 'FINDEC-BC', 21054 // Logistics enquiry (existing customer, not a prospect) 21055 'LOGENQ', 21056] as const; 21057export type BcAutoRemoveCode = typeof BC_AUTO_REMOVE_CODES[number]; 21058 21059/** Check if a result code auto-removes the lead from the Buyer-Close pile. */ 21060export function isBcAutoRemoveCode(code: string): boolean { 21061 return (BC_AUTO_REMOVE_CODES as readonly string[]).includes(code); 21062} 21063 21064/** 21065 * Disposition codes that explicitly END a lead's agent ownership. 21066 * When a CDR carries one of these, the lead's lock + ownership fields 21067 * are wiped regardless of prior state. 21068 * 21069 * 2026-07-22: widened from the sale-code family to the full 21070 * BC_AUTO_REMOVE_CODES union â a code that removes the lead from the BC 21071 * pile also releases the agent's lock (NI / finance-declined / LOGENQ 21072 * previously left the lock in place for manual disposition; that manual 21073 * step is now automated). Behavior note: these codes now wipe ANY lock 21074 * type, including agent_hold, exactly like the sale codes always did. 21075 */ 21076export const OWNERSHIP_CLEAR_CODES = BC_AUTO_REMOVE_CODES; 21077export type OwnershipClearCode = typeof OWNERSHIP_CLEAR_CODES[number]; 21078 21079/** 21080 * Check if a result code explicitly clears agent ownership. 21081 * A CDR carrying one of these wipes lockType, lockAgentId, lockStartUtc, 21082 * ownedByAgent, ownedByReason, ownedByAssignedUtc, ownedByExpiryExactUTCTime. 21083 */ 21084export function clearsAgentOwnership(code: string): boolean { 21085 return (OWNERSHIP_CLEAR_CODES as readonly string[]).includes(code); 21086} 21087 21088/** 21089 * Check if a result code should persist as a lock disposition on the lead. 21090 * After the ownership rewrite this is equivalent to \`isAgentOwnershipCode\` 21091 * â only BUYCLO and AGAM-BC are lock-stamping. Sale codes used to be in 21092 * this set but now clear ownership instead (see \`clearsAgentOwnership\`). 21093 */ 21094export function isLockDispositionCode(code: string): boolean { 21095 return isAgentOwnershipCode(code); 21096} 21097 21098/** 21099 * Check if a result code is an agent disposition (non-system, non-missed-inbound). 21100 * These are codes explicitly chosen by an agent during/after a call. 21101 */ 21102export function isAgentDispositionCode(code: string): boolean { 21103 const info = getMaxResultCodeInfo(code); 21104 if (!info) return false; 21105 if (info.isSystemResultCode) return false; 21106 // Missed inbound codes are system-assigned, not agent choices 21107 if (['IVRMISSED', 'MISINBOUND', 'INBOUND'].includes(code)) return false; 21108 return true; 21109} 21110 21111/** 21112 * Check if a result code indicates a successful call outcome. 21113 */ 21114export function isSuccessCode(code: string): boolean { 21115 const info = getMaxResultCodeInfo(code); 21116 return info?.isSuccess ?? false; 21117} 21118 21119/** Result code categories for grouping in UI */ 21120export type ResultCodeCategory = 21121 | 'missed_inbound' 21122 | 'sale' 21123 | 'qualified' 21124 | 'callback' 21125 | 'not_interested' 21126 | 'unavailable' 21127 | 'transfer' 21128 | 'system' 21129 | 'other'; 21130 21131/** 21132 * Get the category of a result code for UI grouping. 21133 */ 21134export function getResultCodeCategory(code: string): ResultCodeCategory { 21135 // Missed inbound (system-assigned codes for unanswered inbound calls). 21136 // RETVM = Return to Voicemail â inbound call sent to voicemail without 21137 // reaching an agent, so it's classified alongside IVRMISSED / MISINBOUND. 21138 if (['IVRMISSED', 'MISINBOUND', 'INBOUND', 'RETVM'].includes(code)) { 21139 return 'missed_inbound'; 21140 } 21141 // Sale outcomes 21142 if (['SALEFIN', 'SALEFIN-BC', 'SALEOUT', 'SALEOUT-BC', 'SOLDFNANCE', 'SOLDOUT', 'HKT'].includes(code)) { 21143 return 'sale'; 21144 } 21145 // Qualified leads (PITCHEDSI/PITCHEDVI = pitched into own Max list; 21146 // BCSR = showroom visit booked for an existing BC) 21147 if (['BUYHOT', 'BUYCLO', 'BUYHOT-BC', 'SHOWROOM', 'SHOWRM-BC', 'PITCHEDSI', 'PITCHEDVI', 'BCSR'].includes(code)) { 21148 return 'qualified'; 21149 } 21150 // Callback scheduled (BCCB/BCTB = callback set on an existing BC) 21151 if (['PCB', 'NPCB', 'REQCALLBK', 'CCS', 'AGAM-BC', 'BCCB', 'BCTB'].includes(code)) { 21152 return 'callback'; 21153 } 21154 // Not interested 21155 if (['NIPRI', 'NIPRO', 'NIPRI-BC', 'NIPRO-BC', 'NEVERENQ', 'NEVENQ', 'DNC'].includes(code)) { 21156 return 'not_interested'; 21157 } 21158 // Unavailable/unreachable (CTN = "Cant Talk Now" â treated like the 21159 // answering-machine family per MMC sales direction; lead keeps dialling) 21160 if (['NOANSWER', 'BUSY', 'HUNGUP', 'DEADAIR', 'WRONGNO', 'INVNUM', 'DISCONNECT', 'AGAM', 'AGAMF1', 'CTN'].includes(code)) { 21161 return 'unavailable'; 21162 } 21163 // Transfer (BRIDGE = internal-bridge transfer, XFER = explicit transfer code) 21164 if (['INTTRAN', 'INTTRANS', 'BRIDGE', 'XFER'].includes(code)) { 21165 return 'transfer'; 21166 } 21167 // KICKED = MaxContact dialler kicked the user off â surfaced under System 21168 // for operator-visibility even though MaxContact doesn't flag it as such. 21169 if (code === 'KICKED') { 21170 return 'system'; 21171 } 21172 // System codes 21173 const info = getMaxResultCodeInfo(code); 21174 if (info?.isSystemResultCode) { 21175 return 'system'; 21176 } 21177 return 'other'; 21178} 21179 21180/** High-level disposition sentiment for UI grouping */ 21181export type DispositionSentiment = 'good' | 'neutral' | 'bad'; 21182 21183/** Map ResultCodeCategory â Good/Neutral/Bad sentiment */ 21184export function getDispositionSentiment(code: string): DispositionSentiment { 21185 const cat = getResultCodeCategory(code); 21186 if (cat === 'sale' || cat === 'qualified') return 'good';
21187 if (cat === 'not_interested' || cat === 'missed_inbound') return 'bad'; 21188 return 'neutral'; 21189} 21190 21191/** Sentiment display config */ 21192export const DISPOSITION_SENTIMENT_CONFIG: Record<DispositionSentiment, { label: string; color: string; bg: string }> = { 21193 good: { label: 'Good', color: 'text-green-700', bg: 'bg-green-100' }, 21194 neutral: { label: 'Neutral', color: 'text-amber-700', bg: 'bg-amber-100' }, 21195 bad: { label: 'Bad', color: 'text-red-700', bg: 'bg-red-100' }, 21196}; 21197 21198/** Answering machine disposition codes â calls where the agent reached voicemail, not a person */ 21199export const ANSWERING_MACHINE_CODES = ['AGAM', 'AGAMF1', 'AGAM-BC'] as const; 21200 21201/** Check if a result code is an answering machine disposition */ 21202export function isAnsweringMachineCode(code: string): boolean { 21203 return (ANSWERING_MACHINE_CODES as readonly string[]).includes(code); 21204} 21205 21206/** 21207 * Disposition codes where the agent had a real conversation with a person. 21208 * Used for "connected call" metrics (>30s, >1min talk time thresholds). 21209 * Excludes: answering machines, no-answer, busy, dead air, wrong/invalid numbers, 21210 * system codes, missed inbound, redials, and test codes. 21211 */ 21212export const CONNECTED_CONVERSATION_CODES = [ 21213 // Sales 21214 'SALEFIN', 'SALEFIN-BC', 'SALEOUT', 'SALEOUT-BC', 'SOLDFNANCE', 'SOLDOUT', 'HKT', 21215 // Qualified 21216 'BUYHOT', 'BUYCLO', 'BUYHOT-BC', 'SHOWROOM', 'SHOWRM-BC', 21217 // Pitched (2026-08 codes â agent pitched the product to a person; the lead 21218 // moves to its own Max list. MUST stay in this list so pitched leads are 21219 // suppressed from automated cold outreach / back-book selection.) 21220 'PITCHEDSI', 'PITCHEDVI', 21221 // BC follow-ups (2026-08 codes â real conversation on an existing BC) 21222 'BCCB', 'BCSR', 'BCTB', 21223 // Callbacks (agent spoke to person, scheduled follow-up) 21224 'PCB', 'NPCB', 'REQCALLBK', 21225 // Not interested (agent spoke to person, they declined) 21226 'NIPRI', 'NIPRO', 'NIPRI-BC', 'NIPRO-BC', 'NEVERENQ', 'NEVENQ', 'DNC', 21227 // Finance declines (real conversation, finance didn't go through) 21228 'FNANCEDEC', 'FINDECLINE', 'FINDEC-BC', 21229 // Other real conversations 21230 'LOGENQ', 21231 // Hung up (person answered but hung up â still a connection) 21232 'HUNGUP', 21233] as const; 21234 21235/** Check if a result code represents a connected conversation with a person */ 21236export function isConnectedConversationCode(code: string): boolean { 21237 return (CONNECTED_CONVERSATION_CODES as readonly string[]).includes(code); 21238} 21239 21240/** Disposition codes that require manager review */ 21241export const REVIEWABLE_DISPOSITION_CODES = ['NIPRI', 'NIPRO', 'DNC', 'NEVERENQ', 'NEVENQ'] as const; 21242export type ReviewableDispositionCode = typeof REVIEWABLE_DISPOSITION_CODES[number]; 21243 21244/** Check if a result code requires manager review */ 21245export function isReviewableDispositionCode(code: string): boolean { 21246 return (REVIEWABLE_DISPOSITION_CODES as readonly string[]).includes(code); 21247} 21248 21249// âââ Disposition â Sales Cycle Mapping ââââââââââââââââââââââââââââââââââââââ 21250 21251/** Result of mapping a disposition code to sales cycle fields */ 21252export interface DispositionSalesCycleResult { 21253 /** Sales cycle stage to confirm (omitted for exit-code dispositions) */ 21254 stage?: SalesCycleStage; 21255 /** Buyer readiness to set */ 21256 buyerReadiness?: BuyerReadiness; 21257 /** Exit code to set (for not-interested / closed-lost dispositions) */ 21258 exitCode?: DispositionExitCode; 21259 /** Closed-lost reason (only when exitCode is 'closed_lost') */ 21260 closedLostReason?: ClosedLostReason; 21261} 21262 21263/** 21264 * Map a MaxContact disposition code to sales cycle fields. 21265 * Returns null for unknown or system codes (no action should be taken). 21266 * 21267 * Used by: 21268 * - dismiss_and_close_lead workflow step 21269 * - backfill-close-agent-actions script 21270 */ 21271const DISPOSITION_SALES_CYCLE_MAP: Record<string, DispositionSalesCycleResult> = { 21272 // Sale outcomes â sold 21273 'SALEFIN': { stage: 'sold' }, 21274 'SALEFIN-BC': { stage: 'sold' }, 21275 'SALEOUT': { stage: 'sold' }, 21276 'SALEOUT-BC': { stage: 'sold' }, 21277 'SOLDFNANCE': { stage: 'sold' }, 21278 'SOLDOUT': { stage: 'sold' }, 21279 21280 // Qualified â pending + hot
21281 'BUYHOT': { stage: 'pending', buyerReadiness: 'hot' }, 21282 'BUYHOT-BC': { stage: 'pending', buyerReadiness: 'hot' }, 21283 'BUYCLO': { stage: 'pending', buyerReadiness: 'hot' }, 21284 21285 // Showroom â offer + warm 21286 'SHOWROOM': { stage: 'offer', buyerReadiness: 'warm' }, 21287 'SHOWRM-BC': { stage: 'offer', buyerReadiness: 'warm' }, 21288 21289 // Callback â engaged + warm 21290 'PCB': { stage: 'engaged', buyerReadiness: 'warm' }, 21291 'NPCB': { stage: 'engaged', buyerReadiness: 'warm' }, 21292 'REQCALLBK': { stage: 'engaged', buyerReadiness: 'warm' }, 21293 21294 // Pitched (2026-08) â engaged; SI = warm, VI = hot. Not saved as BC. 21295 'PITCHEDSI': { stage: 'engaged', buyerReadiness: 'warm' }, 21296 'PITCHEDVI': { stage: 'engaged', buyerReadiness: 'hot' }, 21297 21298 // BC follow-up codes (2026-08) â the lead is already an owned BC being 21299 // actively closed, same treatment as BUYCLO/AGAM-BC. Forward-only stage 21300 // progression means this never regresses a further-along lead. 21301 'BCCB': { stage: 'pending', buyerReadiness: 'hot' }, 21302 'BCSR': { stage: 'pending', buyerReadiness: 'hot' }, 21303 'BCTB': { stage: 'pending', buyerReadiness: 'hot' }, 21304 21305 // Not interested â exit code + cold 21306 'NIPRI': { buyerReadiness: 'cold', exitCode: 'not_interested' }, 21307 'NIPRO': { buyerReadiness: 'cold', exitCode: 'not_interested' }, 21308 'NIPRI-BC': { buyerReadiness: 'cold', exitCode: 'not_interested' }, 21309 'NIPRO-BC': { buyerReadiness: 'cold', exitCode: 'not_interested' }, 21310 21311 // Never enquired â exit code + cold 21312 'NEVERENQ': { buyerReadiness: 'cold', exitCode: 'did_not_enquire' }, 21313 'NEVENQ': { buyerReadiness: 'cold', exitCode: 'did_not_enquire' }, 21314 21315 // DNC â exit code + cold 21316 'DNC': { buyerReadiness: 'cold', exitCode: 'not_interested' }, 21317 21318 // Finance declined â closed lost + cold 21319 'FNANCEDEC': { buyerReadiness: 'cold', exitCode: 'closed_lost', closedLostReason: 'finance_declined' }, 21320 'FINDECLINE': { buyerReadiness: 'cold', exitCode: 'closed_lost', closedLostReason: 'finance_declined' }, 21321 'FINDEC-BC': { buyerReadiness: 'cold', exitCode: 'closed_lost', closedLostReason: 'finance_declined' }, 21322 21323 // No answer / missed â untapped (no contact made) 21324 // CTN (2026-08) = "Cant Talk Now" â answering-machine-equivalent per MMC 21325 // sales; the customer did not actually take the call. 21326 'NOANSWER': { stage: 'untapped' }, 21327 'BUSY': { stage: 'untapped' }, 21328 'AGAM': { stage: 'untapped' }, 21329 'AGAMF1': { stage: 'untapped' }, 21330 'CTN': { stage: 'untapped' }, 21331 // AGAM-BC = "Buyer Closing AGAM" â agent worked the BC list and got an 21332 // answering machine. Treated as a buyer-closing ownership event (same as 21333 // BUYCLO) for sales-cycle progression: the agent has claimed the lead. 21334 'AGAM-BC': { stage: 'pending', buyerReadiness: 'hot' }, 21335 21336 // Hung up / dead air â untapped (no real conversation) 21337 'HUNGUP': { stage: 'untapped' }, 21338 'DEADAIR': { stage: 'untapped' }, 21339 'DISCONNECT': { stage: 'untapped' }, 21340 21341 // Bad number â untapped 21342 'WRONGNO': { stage: 'untapped' }, 21343 'INVNUM': { stage: 'untapped' }, 21344 21345 // Callback (CCS) â untapped (preserve existing stage via forward-only rule) 21346 'CCS': { stage: 'untapped' }, 21347 21348 // Transfer â untapped 21349 'HKT': { stage: 'untapped' }, 21350 'INTTRAN': { stage: 'untapped' }, 21351 'INTTRANS': { stage: 'untapped' }, 21352 21353 // Enquiry â untapped 21354 'LOGENQ': { stage: 'untapped' }, 21355 21356 // Redial/misc â untapped 21357 'REDIAL': { stage: 'untapped' }, 21358 'Redial1stA': { stage: 'untapped' }, 21359 'RFD': { stage: 'untapped' }, 21360 'NOTESONLY': { stage: 'untapped' }, 21361 'ABIQ': { stage: 'untapped' }, 21362 21363 // Missed inbound â untapped 21364 'IVRMISSED': { stage: 'untapped' }, 21365 'MISINBOUND': { stage: 'untapped' }, 21366 'INBOUND': { stage: 'untapped' }, 21367 21368 // Test codes â untapped 21369 'AutoTest': { stage: 'untapped' }, 21370 'MaxTest': { stage: 'untapped' }, 21371 'MaxPCB': { stage: 'untapped' }, 21372 'MICHAEL': { stage: 'untapped' }, 21373 21374 // System codes â untapped 21375 'ITXUSERDISCONNECT': { stage: 'untapped' }, 21376 'SYSBUSY': { stage: 'untapped' }, 21377 'ICANCELTERMINATE': { stage: 'untapped' }, 21378 'IDUPLICATETERMINATE': { stage: 'untapped' }, 21379 'ITXMOVED': { stage: 'untapped' }, 21380 'IABANDONED': { stage: 'untapped' }, 21381 'IBRANCHED': { stage: 'untapped' }, 21382 'ICUSTOMERTIMEOUT': { stage: 'untapped' }, 21383 'IRECEIVED': { stage: 'untapped' }, 21384 'ISENT': { stage: 'untapped' }, 21385 'ITERMINATED': { stage: 'untapped' }, 21386 'ITIMEOUT': { stage: 'untapped' }, 21387 'SKIPPED': { stage: 'untapped' }, 21388}; 21389 21390/** 21391 * Map a MaxContact disposition code to sales cycle stage, buyer readiness, and exit code. 21392 * Returns null for unknown or system codes â callers should take no action. 21393 */ 21394export function dispositionToSalesCycle(resultCode: string): DispositionSalesCycleResult | null { 21395 return DISPOSITION_SALES_CYCLE_MAP[resultCode] ?? null; 21396} 21397`,dn=`/** 21398 * Media Asset Registry Types 21399 * 21400 * Single source of truth for the unified \`media-assets\` DynamoDB table â one 21401 * typed record per asset regardless of which bucket holds the bytes (or whether 21402 * the bytes are hosted externally, e.g. Shopify CDN product photos by-reference). 21403 * 21404 * Table: media-assets 21405 * PK tenantId 21406 * SK assetId 21407 * GSI1 mediaResolver PK gsi1pk = \`\${tenantId}#\${productId|__brand__}\` SK gsi1sk = \`\${assetType}#\${status}#\${role}\` 21408 * GSI2 mediaBrowse PK gsi2pk = \`\${tenantId}#\${assetType}\` SK gsi2sk = \`\${status}#\${updatedAt}\` 21409 * GSI3 mediaSyncDedup PK gsi3pk = \`\${tenantId}#\${source.kind}\` SK gsi3sk = \`\${source.ref}\` 21410 * 21411 * â ï¸ The three GSI key pairs are DENORMALIZED concatenated strings. NEVER build 21412 * them inline â always go through \`applyMediaGsiKeys()\` (or the per-key builders)
21413 * so a status/product/updatedAt change recomputes every dependent key. A writer 21414 * that updates \`status\` but forgets \`gsi2sk\` makes the row invisible to the 21415 * operator browse while it still resolves â the same failure mode as the 21416 * events-customer four-tenant-keys bug. 21417 * 21418 * This file is imported by the shopdash FRONTEND, so it stays free of \`node:crypto\` 21419 * and any Node-only API. Content-hash DIGESTS are computed in the backend 21420 * (\`@bigm/shared\`); this file only FORMATS a precomputed digest into an assetId. 21421 */ 21422 21423import type { AssetPerformanceMetrics } from './meta-asset-metadata.js'; 21424 21425/** The 13-type taxonomy. \`prepared\` distinguishes raw files from composed assets. */ 21426export type MediaAssetType = 21427 | 'logo' 21428 | 'product-photo' 21429 | 'lifestyle' 21430 | 'testimonial' 21431 | 'award-badge' 21432 | 'agent-avatar' 21433 | 'featured-on-logo' 21434 | 'generated-image' 21435 | 'video' 21436 /** An owner's recorded voice (audio only) for a reel; see \`MediaRecording\`. */ 21437 | 'voice-take' 21438 /** An owner's camera clip recorded in the post editor. */ 21439 | 'clip' 21440 | 'mms-template' 21441 | 'email-template' 21442 | 'site-template' 21443 | 'rendered-output'; 21444 21445/** Lifecycle gate â only \`approved\` assets are eligible for sends. */ 21446export type MediaAssetStatus = 'draft' | 'approved' | 'archived'; 21447 21448/** Slot role within a product's creative set. Open-ended; common values listed. */ 21449export type MediaAssetRole = 21450 | 'hero' 21451 | 'gallery' 21452 | 'review' 21453 | 'award' 21454 | 'avatar' 21455 | 'logo' 21456 | 'featured-on' 21457 /** A photo or video of one physical floor unit; see \`MediaAppliesTo.floorUnitIds\`. */ 21458 | 'floor-unit' 21459 | (string & {}); 21460 21461/** 21462 * Channels an asset is suitable for. \`social\` is an operator/manifest decision, never 21463 * inferred: it means "this may be posted publicly on the tenant's social accounts". 21464 */ 21465export type MediaChannel = 'sms' | 'email' | 'mms' | 'site' | 'social'; 21466 21467/** Where an asset originated. \`ref\` is the stable per-source identity used for GSI3 dedup. */ 21468export type MediaSourceKind = 21469 | 'shopify-catalog' 21470 | 'shopify-files' 21471 | 'klaviyo' 21472 | 'manual-upload' 21473 | 'generated' 21474 | 'facebook' 21475 | 'agent-avatar' 21476 | 'rendered' 21477 | 'web-ingest'; 21478 21479export interface MediaSource { 21480 kind: MediaSourceKind; 21481 /** Stable identity per source (variantId, facebookId, S3 filenameâ¦) â GSI3 SK. */ 21482 ref: string; 21483 syncedAt: string; 21484 /** Original URL the bytes were ingested from (\`kind:'web-ingest'\`) â provenance for image-rights vetting. */ 21485 sourceUrl?: string; 21486} 21487 21488/** 21489 * Byte location. \`byRef:true\` means the bytes live somewhere we do NOT manage 21490 * (e.g. Shopify CDN product photo) â \`bucket\`/\`key\` are omitted and lifecycle 21491 * rules do not apply. Otherwise the bytes live in our bucket under \`{tenant}/media/\`. 21492 */ 21493export interface MediaStorage { 21494 url: string; 21495 byRef: boolean; 21496 bucket?: string; 21497 key?: string; 21498} 21499 21500export interface MediaDims { 21501 width?: number; 21502 height?: number; 21503 bytes?: number; 21504 /** jpg | png | webp | svg | mp4 ⦠*/ 21505 format?: string; 21506 /** MIME type as stored (image/jpeg, video/mp4 â¦). */ 21507 mime?: string; 21508 /** Video duration in seconds; absent for stills. */ 21509 durationSec?: number; 21510} 21511 21512/** 21513 * Derivative renditions made ONCE from the master by the asset-compressor Lambda 21514 * (\`video-transcoder\`) and stamped back onto the master row. Keys are the 21515 * platform-neutral shapes every channel needs: 21516 * feed 1080Ã1350 JPEG (4:5) square 1080Ã1080 JPEG (1:1) 21517 * story 1080Ã1920 JPEG (9:16) reel 1080Ã1920 H.264 MP4 (9:16, +faststart) 21518 * mms the â¤300 KB carrier-safe variant (\`_mms.*\`) that SMS/MMS already use 21519 * Social publishers read these; nothing re-encodes at send time. 21520 */ 21521export type MediaDerivativeKind = 'feed' | 'square' | 'story' | 'reel' | 'mms' 21522 /** The reel with the owner's own recorded voice on it (\`voiceTakeId\` names the take). */ 21523 | 'voiceover'; 21524 21525export interface MediaDerivative { 21526 /** S3 key of the rendition (same bucket as the master). */ 21527 key: string; 21528 /** Public CDN URL of the rendition â what an external fetcher (Meta) is given. */ 21529 url: string; 21530 width: number; 21531 height: number; 21532 bytes: number; 21533 mime: string; 21534 durationSec?: number; 21535 madeAt: string; 21536 /** For a \`voiceover\` rendition: the \`voice-take\` asset mixed onto the reel. */ 21537 voiceTakeId?: string; 21538} 21539 21540/** 21541 * A recording made on the owner's phone inside the post editor: a voice take (audio only, 21542 * meant to be laid over a reel) or a clip (camera video). Kept as a library asset so it 21543 * can be reused on other posts;
21543 the transcript is what makes it findable. 21544 */ 21545export interface MediaRecording { 21546 kind: 'voice-take' | 'clip'; 21547 /** Who recorded it (login email) and when. */ 21548 by: string; 21549 at: string; 21550 /** The board action it was recorded for, and (voice take) the reel it was recorded over. */ 21551 forActionId?: string; 21552 forAssetId?: string; 21553 durationSec?: number; 21554 /** The caption shown as the script while recording. */ 21555 script?: string; 21556 /** What the browser produced (\`audio/mp4\`, \`audio/webm\`, \`video/mp4\`, \`video/webm\`). */ 21557 mime?: string; 21558 /** Voice take: replace the reel's audio, or keep it quietly under the voice. */ 21559 mix?: 'replace' | 'duck'; 21560 /** Clip: stitched onto \`forAssetId\` at its start or its end (the joined video becomes its own asset). */ 21561 place?: 'start' | 'end'; 21562 /** Filled by the transcoder (Deepgram) once the take has landed. */ 21563 transcript?: string; 21564 transcribedAt?: string; 21565} 21566 21567/** 21568 * Relationship fan-out. Each asset has ONE primary \`productId\` (drives the fast 21569 * GSI1 resolver) but may ALSO apply to many products / collections, or be 21570 * brand-level (productId omitted, \`brandLevel:true\`). 21571 */ 21572export interface MediaAppliesTo { 21573 productIds?: string[]; 21574 collections?: string[]; 21575 brandLevel?: boolean; 21576 /** Floor units (\`FloorUnit.unitId\`) this asset shows; paired with \`role: 'floor-unit'\`. 21577 * \`productId\` stays the parent product so GSI1 resolution still works. */ 21578 floorUnitIds?: string[]; 21579} 21580 21581/** 21582 * Structured testimonial. Aligned with \`MmsCardContent.reviewBadge\` 21583 * ({ rating, reviewCount, platform }) so the resolver can feed a card's 21584 * \`contentOverride\`. \`imageUrl\` is an OPTIONAL pre-rendered review graphic. 21585 */ 21586export interface MediaTestimonial { 21587 quote: string; 21588 author?: string; 21589 rating?: number; 21590 platform?: string; 21591 imageUrl?: string; 21592} 21593 21594/** 21595 * Award / badge. Aligned with a \`MmsCardContent.awardBadges[]\` entry 21596 * ({ imageUrl, alt }) so the resolver can populate \`contentOverride.awardBadges\` 21597 * directly; \`name\`/\`issuer\` carry the structured metadata. 21598 */ 21599export interface MediaAward { 21600 imageUrl: string; 21601 alt: string; 21602 name?: string; 21603 issuer?: string; 21604} 21605 21606/** 21607 * Provenance for AI-generated (bespoke) creative. Stored on the row so a generated 21608 * image is always disclosable: the prompt, the model, what it cost, and which 21609 * improvement action asked for it. \`watermarked\` records that the provider's 21610 * invisible watermark (e.g. Google SynthID) was left ON. 21611 */ 21612export interface MediaGenerated { 21613 prompt?: string; 21614 model?: string; 21615 /** Cohort/segment this was made for, e.g. 'viewed-no-buy'. */ 21616 cohort?: string; 21617 generatedAt?: string; 21618 /** 'vertex' | 'openai' | ⦠*/ 21619 provider?: string; 21620 /** Request parameters as sent (aspectRatio, sampleCount, safety settingsâ¦). */ 21621 params?: Record<string, string | number | boolean>; 21622 costUsd?: number; 21623 /** \`improvement-actions.actionId\` that requested the image, when one did. */ 21624 requestedByActionId?: string; 21625 watermarked?: boolean; 21626} 21627 21628/** AI vision enrichment output (Category 4). \`embeddingId\` is a placeholder for later vector search. */ 21629export interface MediaAiEnrichment { 21630 caption?: string; 21631 tags?: string[]; 21632 embeddingId?: string; 21633 describedAt?: string; 21634 /** Classifier confidence 0..1 â drives the per-type approval gate. */ 21635 confidence?: number; 21636} 21637 21638/** Compliance flags (flag-only; never blocks approval). */ 21639export interface MediaCompliance { 21640 /** TGA therapeutic-claim risk for AU-regulated tenants. */ 21641 tgaFlag?: boolean; 21642 tgaNotes?: string; 21643} 21644 21645/** Per-channel usage count + date range for one channel. */ 21646export interface MediaUsageChannel { 21647 count: number; 21648 firstUsedAt?: string; 21649 lastUsedAt?: string; 21650} 21651 21652/** 21653 * How many times this asset was USED in outbound sends, derived from 21654 * \`events-customer\` (the source of truth) by the usage-aggregator. Counts cover 21655 * a rolling \`windowDays\` window. \`workflow\` = real-time campaign sends, 21656 * \`backbook\` = cohort-driven backbook-engine sends. 21657 */ 21658export interface MediaUsage { 21659 workflow: MediaUsageChannel; 21660 backbook: MediaUsageChannel; 21661 /** Earliest/latest use across all channels. */ 21662 firstUsedAt?: string; 21663 lastUsedAt?: string; 21664 /** Rolling window the counts cover (e.g. 365). */ 21665 windowDays?: number; 21666 computedAt?: string; 21667} 21668 21669/** Sentinel productId for brand-level assets (no specific product) on GSI1. */ 21670export const BRAND_LEVEL_PRODUCT_SENTINEL = '__brand__'; 21671 21672/** 21673 * A unified media-asset registry record. 21674 */ 21675export interface MediaAsset { 21676 /** Partition key. */ 21677 tenantId: string; 21678 /** Sort key. content-hash (\`sha256-â¦\`) for raw files, \`media-<uuid>\` for composed. */ 21679 assetId: string; 21680 21681 assetType: MediaAssetType; 21682 /** Raw file (false) vs composed/ready-to-send asset (true). */ 21683 prepared: boolean; 21684 status: MediaAssetStatus; 21685 role?: MediaAssetRole; 21686 channelSuitability?: MediaChannel[]; 21687 21688 storage: MediaStorage; 21689 dims?: MediaDims; 21690 /** Renditions made once from the master; see \`MediaDerivativeKind\`. */ 21691 derivatives?: Partial<Record<MediaDerivativeKind, MediaDerivative>>; 21692 source: MediaSource; 21693 21694 // ââ relationships ââ 21695 /** Primary product link â drives GSI1. Omitted for brand-level assets. */ 21696 productId?: string; 21697 appliesTo?: MediaAppliesTo; 21698 21699 // ââ type-specific ââ 21700 testimonial?: MediaTestimonial; 21701 award?: MediaAward; 21702 generated?: MediaGenerated; 21703 /** Set on \`voice-take\` and \`clip\` assets recorded in the post editor. */ 21704 recording?: MediaRecording; 21705 /** Set on a video the transcoder made by stitching a clip onto a reel. */ 21706 joined?: { reelAssetId: string; clipAssetId: string; place: 'start' | 'end'; at: string }; 21707 21708 // ââ enrichment / perf / compliance ââ 21709 ai?: MediaAiEnrichment; 21710 compliance?: MediaCompliance; 21711 /** Carried over for \`source.kind === 'facebook'\` assets (reused shape). */ 21712 metrics?: AssetPerformanceMetrics; 21713 21714 // ââ usage (from events-customer, stamped by the usage-aggregator) ââ 21715 usage?: MediaUsage; 21716 /** Flat \`workflow.count + backbook.count\` â the in-memory sort key for the Media view. Default 0. */ 21717 usageCount?: number; 21718 21719 // ââ denormalized GSI keys (set ONLY via applyMediaGsiKeys) ââ 21720 gsi1pk?: string; 21721 gsi1sk?: string; 21722 gsi2pk: string; 21723 gsi2sk: string; 21724 gsi3pk?: string; 21725 gsi3sk?: string; 21726 21727 createdAt: string; 21728 updatedAt: string; 21729 /** Epoch seconds â set for \`rendered-output\` so derived PNGs auto-clean. */ 21730 ttl?: number; 21731} 21732 21733// âââââââââââââââââââââââââ GSI key builders âââââââââââââââââââââââââ 21734 21735export function buildMediaResolverGsi1Pk(tenantId: string, productId?: string | null): string { 21736 return \`\${tenantId}#\${productId || BRAND_LEVEL_PRODUCT_SENTINEL}\`; 21737} 21738 21739export function buildMediaResolverGsi1Sk( 21740 assetType: MediaAssetType, 21741 status: MediaAssetStatus, 21742 role?: string | null, 21743): string { 21744 return \`\${assetType}#\${status}#\${role || ''}\`; 21745} 21746 21747export function buildMediaBrowseGsi2Pk(tenantId: string, assetType: MediaAssetType): string { 21748 return \`\${tenantId}#\${assetType}\`; 21749} 21750 21751export function buildMediaBrowseGsi2Sk(status: MediaAssetStatus, updatedAt: string): string { 21752 return \`\${status}#\${updatedAt}\`; 21753} 21754 21755export function buildMediaSyncGsi3Pk(tenantId: string, sourceKind: MediaSourceKind): string { 21756 return \`\${tenantId}#\${sourceKind}\`; 21757} 21758 21759export function buildMediaSyncGsi3Sk(sourceRef: string): string { 21760 return sourceRef; 21761} 21762 21763/** 21764 * Recompute and stamp ALL GSI key pairs onto an asset from its current fields. 21765 * Call this immediately before every Put/Update. This is the ONLY sanctioned way 21766 * to set the gsi* attributes â see the denormalization warning in the docblock. 21767 */ 21768export function applyMediaGsiKeys<T extends MediaAsset>(asset: T): T { 21769 asset.gsi1pk = buildMediaResolverGsi1Pk(asset.tenantId, asset.productId); 21770 asset.gsi1sk = buildMediaResolverGsi1Sk(asset.assetType, asset.status, asset.role); 21771 asset.gsi2pk = buildMediaBrowseGsi2Pk(asset.tenantId, asset.assetType); 21772 asset.gsi2sk = buildMediaBrowseGsi2Sk(asset.status, asset.updatedAt); 21773 asset.gsi3pk = buildMediaSyncGsi3Pk(asset.tenantId, asset.source.kind); 21774 asset.gsi3sk = buildMediaSyncGsi3Sk(asset.source.ref); 21775 return asset; 21776} 21777 21778// âââââââââââââââââââââââââ assetId helpers âââââââââââââââââââââââââ 21779 21780/** 21781 * Format a precomputed sha256 hex digest into a content-addressed assetId. 21782 * The digest itself is computed in the backend (\`@bigm/shared\`) so this package 21783 * stays free of \`node:crypto\` and remains importable by the shopdash frontend. 21784 */ 21785export function contentHashAssetId(sha256Hex: string): string { 21786 return \`sha256-\${sha256Hex.slice(0, 32)}\`; 21787} 21788 21789/** Mint a UUID assetId for composed/generated assets. Isomorphic (Node 18+/browser). */ 21790export function composedAssetId(): string { 21791 const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; 21792 const uuid = c && typeof c.randomUUID === 'function' ? c.randomUUID() : fallbackUuid(); 21793 return \`media-\${uuid}\`; 21794} 21795 21796function fallbackUuid(): string { 21797 // Defensive only â every supported runtime (Node 18+, modern browsers) has crypto.randomUUID. 21798 let seed = 0; 21799 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
21800 seed = (seed + ch.charCodeAt(0) + 0x9e3779b1) >>> 0; 21801 const n = seed % 16; 21802 const v = ch === 'x' ? n : (n & 0x3) | 0x8; 21803 return v.toString(16); 21804 }); 21805} 21806`,cn=`/** 21807 * Message Placeholder Type Definitions 21808 * 21809 * Defines all available placeholders for SMS and Email templates, 21810 * their data sources, default values, and availability conditions. 21811 */ 21812 21813/** 21814 * Availability context - when is this placeholder available 21815 */ 21816export type PlaceholderAvailability = 21817 | 'always' // Available in all contexts 21818 | 'draft_order' // Only when draft order is created 21819 | 'checkout' // Only when checkout data exists 21820 | 'discount_config' // Only when campaign has discount configured 21821 | 'product'; // Only when a target product resolves (media-assets registry) 21822 21823/** 21824 * Placeholder metadata - documents source and default for each placeholder 21825 */ 21826export interface PlaceholderDefinition { 21827 /** The placeholder key (without braces, e.g., "name" for {{name}}) */ 21828 key: string; 21829 /** Human-readable description */ 21830 description: string; 21831 /** Dot-notation path to the source value */ 21832 source: string; 21833 /** Fallback source if primary is unavailable */ 21834 fallbackSource?: string; 21835 /** Default value if all sources are unavailable */ 21836 defaultValue: string; 21837 /** Example value for documentation */ 21838 example: string; 21839 /** When this placeholder is available */ 21840 availability: PlaceholderAvailability; 21841} 21842 21843/** 21844 * Runtime placeholder values - the actual resolved values 21845 */ 21846export interface MessagePlaceholderValues { 21847 /** Customer first name */ 21848 name: string; 21849 /** Customer first name â the welcome email's explicit first-name token (\`name\` is the legacy alias) */ 21850 firstName: string; 21851 /** Shopify order name, e.g. \`#31363\` (welcome email/SMS \`#INV\`) */ 21852 orderNumber: string; 21853 /** Branded short link to the Shopify order status page (the "invoice"), e.g. \`https://mmc.link/aB3kQ9\` â welcome SMS (Trent, 16 Sep 2026) */ 21854 orderLink: string; 21855 /** Delivery date as a Melbourne day phrase, e.g. \`Monday 21 September\` */ 21856 deliveryDate: string; 21857 /** Pre-rendered HTML: what to expect on delivery day (by delivery type) */ 21858 deliveryBlock: string; 21859 /** Pre-rendered HTML: getting started + dimensions (by content pack) */ 21860 productBlock: string; 21861 /** Pre-rendered HTML: companion product addendum (chiller, red light panel), empty when none */ 21862 companionBlock: string; 21863 /** Tracked campaign link */ 21864 link: string; 21865 /** Primary product name */ 21866 productName: string; 21867 /** Total savings amount (formatted currency) */ 21868 savings: string; 21869 /** Discount percentage */ 21870 discountPercent: string; 21871 /** Primary discount code */ 21872 discountCode: string; 21873 /** Free items discount code */ 21874 freeDiscountCode: string; 21875 /** Comma-separated list of free items */ 21876 freeItems: string; 21877 /** Cart total before discounts (formatted currency) */ 21878 cartTotal: string; 21879 /** Total discount from draft order invoice vs store prices (formatted currency) */ 21880 discountDraftInvoiceTotal: string; 21881 /** Hosted video/media URL (full quality, no size limit) */ 21882 video: string; 21883 /** Calling agent's first name */ 21884 agent: string; 21885 /** Store domain (e.g. dev-masseuse-massage-store.myshopify.com) */ 21886 storeDomain: string; 21887 /** Primary product image URL (Shopify CDN) */ 21888 productImageUrl: string; 21889 /** Primary variant title (e.g. "Latte") */ 21890 variantTitle: string; 21891 /** Subtotal before discount (formatted currency) */ 21892 subtotal: string; 21893 /** Final total after discount (formatted currency) */ 21894 total: string; 21895 /** Strikethrough original total (formatted currency) */ 21896 originalTotal: string; 21897 /** Currency code (e.g. AUD, USD) */ 21898 currency: string; 21899 /** Tenant logo URL */ 21900 logoUrl: string; 21901 /** Pre-rendered savings clause ("We've taken $X off. " or "...off (Y% saving). "). Empty when 0. */ 21902 savingsLine: string; 21903 /** Pre-rendered free-items clause ("Free X included. ", plural-aware). Empty when none. */ 21904 freeItemsLine: string; 21905 /** Target product's hero image URL (media-assets resolver). Empty when none resolves. */ 21906 productHero: string; 21907 /** Approved testimonial for the target product â quote text (SMS) or styled HTML block (email). Empty when none. */ 21908 productReview: string; 21909 /** Award/badge for the target product â image URL (email) or name text (SMS). Empty when none. */ 21910 productAward: string; 21911 21912 // ââ Showroom / appointment lifecycle (showroom.* workflows) ââââââââââââââ 21913 // Sourced from the triggering \`showroom.*\` event's SNAPSHOTTED eventData, so 21914 // the message always states what the customer was actually booked into, even 21915 // if the tenant later edits its showroom config. 21916 /** Appointment date in the customer's own zone, e.g. "Tuesday 5 August". */ 21917 appointmentDate: string; 21918 /** Appointment time in the customer's own zone, e.g. "10:00am". */ 21919 appointmentTime: string; 21920 /** Showroom display name, e.g. "South Melbourne Showroom". */ 21921 locationName: string; 21922 /** One line address, e.g. "117 York Street, South Melbourne VIC 3205". */ 21923 locationAddress: string; 21924 /** Maps deep link for the showroom. Empty when the tenant configures none. */ 21925 mapsUrl: string; 21926 /** Minutes late, on showroom.delayed only, e.g. "15". */ 21927 delayMinutes: string; 21928 /** 21929 * Mode-aware "where" sentence, so ONE reminder template serves both a phone 21930 * callback and a showroom visit. Empty string for \`mode: 'phone'\` (there is 21931 * no place to be), " Our showroom: <name>, <address>." for showroom. Always 21932 * carries its own leading space so it can sit inline after a sentence. 21933 */ 21934 locationLine: string; 21935 21936 /** 21937 * Reseller showroom contact person the customer should ask for ("Brett") â 21938 * from \`reseller_appointment.*\` events' snapshotted eventData. Empty for 21939 * every other event type. 21940 */ 21941 resellerContactName: string; 21942} 21943 21944/** 21945 * Complete placeholder definitions with sources and defaults 21946 * 21947 * SOURCE KEY: 21948 * - lead.masterProfile.* = Lead's master profile data 21949 * - checkout.eventData.* = Shopify checkout event data 21950 * - campaign.* = Campaign context configuration 21951 * - context.discount.* = Discount calculation results (runtime) 21952 * - context.draftOrder.* = Draft order data (runtime) 21953 */ 21954export const MESSAGE_PLACEHOLDER_DEFINITIONS: PlaceholderDefinition[] = [ 21955 { 21956 key: 'name', 21957 description: 'Customer first name', 21958 source: 'lead.masterProfile.firstName', 21959 fallbackSource: 'checkout.eventData.identity.firstName', 21960 defaultValue: 'there', 21961 example: 'John', 21962 availability: 'always', 21963 }, 21964 { 21965 key: 'link', 21966 description: 'Tracked campaign link to complete purchase or view offer', 21967 source: 'context.trackingLink', 21968 defaultValue: '', 21969 example: 'https://shop.example.com/c/abc123', 21970 availability: 'always', 21971 }, 21972 { 21973 key: 'productName', 21974 description: 'Primary product name from cart/discount', 21975 source: 'context.discount.productName', 21976 defaultValue: 'massage chair', 21977 example: 'Ultimate Chiro®', 21978 availability: 'checkout', 21979 }, 21980 { 21981 key: 'firstName', 21982 description: 'Customer first name (welcome email); falls back to "there"', 21983 source: 'context.welcome.placeholders.firstName', 21984 defaultValue: 'there', 21985 example: 'James', 21986 availability: 'always', 21987 }, 21988 { 21989 key: 'orderNumber', 21990 description: 'Shopify order name for the booking (welcome email / SMS #INV)', 21991 source: 'context.welcome.placeholders.orderNumber', 21992 defaultValue: '', 21993 example: '#31363', 21994 availability: 'always', 21995 }, 21996 { 21997 key: 'orderLink', 21998 description: 'Short link to the Shopify order status page for the order (welcome SMS: the order number links to the invoice)', 21999 source: 'context.welcome.placeholders.orderLink', 22000 defaultValue: '', 22001 example: 'https://mmc.link/aB3kQ9', 22002 availability: 'always', 22003 }, 22004 { 22005 key: 'deliveryDate', 22006 description: 'Requested delivery date from the SAP booking, as a Melbourne day phrase', 22007 source: 'context.welcome.placeholders.deliveryDate', 22008 defaultValue: '', 22009 example: 'Monday 21 September', 22010 availability: 'always', 22011 }, 22012 { 22013 key: 'deliveryBlock', 22014 description: 'Pre-rendered HTML for the delivery-type section of the welcome email', 22015 source: 'context.welcome.placeholders.deliveryBlock', 22016 defaultValue: '', 22017 example: '<h2>What to Expect on Delivery Day</h2>â¦', 22018 availability: 'always', 22019 }, 22020 { 22021 key: 'productBlock', 22022 description: 'Pre-rendered HTML for the content-pack section (manual, dimensions)', 22023 source: 'context.welcome.placeholders.productBlock', 22024 defaultValue: '', 22025 example: '<h2>Getting Started With Your Sauna</h2>â¦', 22026 availability: 'always', 22027 }, 22028 { 22029 key: 'companionBlock', 22030 description: 'Pre-rendered HTML addendum for a companion product on the same booking', 22031 source: 'context.welcome.placeholders.companionBlock', 22032 defaultValue: '', 22033 example: '<h2>
22033Dimensions for Extra Red Light Panels</h2>â¦', 22034 availability: 'always', 22035 }, 22036 { 22037 key: 'savings', 22038 description: 'Total savings amount (formatted as AUD currency)', 22039 source: 'context.discount.totalSavings', 22040 defaultValue: '$0', 22041 example: '$3,513', 22042 availability: 'draft_order', 22043 }, 22044 { 22045 key: 'discountPercent', 22046 description: 'Discount percentage value', 22047 source: 'campaign.discount.value', 22048 defaultValue: '', 22049 example: '50', 22050 availability: 'discount_config', 22051 }, 22052 { 22053 key: 'discountCode', 22054 description: 'Primary discount code', 22055 source: 'campaign.discount.code', 22056 defaultValue: '', 22057 example: 'BOX50', 22058 availability: 'discount_config', 22059 }, 22060 { 22061 key: 'freeDiscountCode', 22062 description: 'Free items discount code', 22063 source: 'campaign.freeDiscount.code', 22064 defaultValue: '', 22065 example: 'BOXFREE', 22066 availability: 'discount_config', 22067 }, 22068 { 22069 key: 'freeItems', 22070 description: 'Comma-separated list of free items added', 22071 source: 'context.discount.freeItemsAdded', 22072 defaultValue: '', 22073 example: 'Lifetime Warranty, White Glove Installation', 22074 availability: 'draft_order', 22075 }, 22076 { 22077 key: 'cartTotal', 22078 description: 'Cart total before discounts (formatted as AUD currency)', 22079 source: 'checkout.eventData.totalPrice', 22080 defaultValue: '$0', 22081 example: '$9,995', 22082 availability: 'checkout', 22083 }, 22084 { 22085 key: 'discountDraftInvoiceTotal', 22086 description: 'Total discount: store normal prices minus draft order line item prices', 22087 source: 'context.draftOrder.totalDiscount', 22088 defaultValue: '$0', 22089 example: '$4,603', 22090 availability: 'draft_order', 22091 }, 22092 { 22093 key: 'video', 22094 description: 'Hosted video/media URL for full-quality playback in browser', 22095 source: 'campaign.messages.sms.hostedMediaUrl', 22096 defaultValue: '', 22097 example: 'https://d1z8g1gomppj0i.cloudfront.net/tenant/assets/product-demo.mp4', 22098 availability: 'always', 22099 }, 22100 { 22101 key: 'agent', 22102 description: 'Calling agent first name (from resolve_agent_video step)', 22103 source: 'context.agentVideo.agentFirstName', 22104 defaultValue: 'Chris', 22105 example: 'Liam', 22106 availability: 'always', 22107 }, 22108 { 22109 key: 'savingsLine', 22110 description: "Pre-rendered savings clause with trailing space + period. % suppressed when < 20% (small percents undersell). Empty when savings = 0.", 22111 source: 'derived from context.discount.totalSavings + discountPercent', 22112 defaultValue: '', 22113 example: "We've taken $8,700 off (42% saving). ", 22114 availability: 'draft_order', 22115 }, 22116 { 22117 key: 'freeItemsLine', 22118 description: 'Pre-rendered free-items clause with trailing space + period. Plural-aware via formatFreeItemsList. Empty when no free items.', 22119 source: 'derived from context.discount.freeItemsAdded', 22120 defaultValue: '', 22121 example: 'Free Lifetime Care Warranty included. ', 22122 availability: 'draft_order', 22123 }, 22124 { 22125 key: 'productHero', 22126 description: "Target product's hero image URL, resolved from the media-assets registry (GS
22126I1). Used as MMS image on SMS and hero <img> in email.", 22127 source: 'media-assets resolver (resolveCreativeBundle.hero)', 22128 defaultValue: '', 22129 example: 'https://cdn.shopify.com/.../ultimate-chiro-hero.jpg', 22130 availability: 'product', 22131 }, 22132 { 22133 key: 'productReview', 22134 description: 'Approved testimonial for the target product. Plain quote text in SMS; styled review block in email (resolvePlaceholdersHtml).', 22135 source: 'media-assets resolver (resolveCreativeBundle.review)', 22136 defaultValue: '', 22137 example: '"Changed my life â I sleep through the night now." â Jane R. â â â â â ', 22138 availability: 'product', 22139 }, 22140 { 22141 key: 'productAward', 22142 description: 'Award/badge for the target product. Award name text in SMS; badge <img> in email.', 22143 source: 'media-assets resolver (resolveCreativeBundle.award)', 22144 defaultValue: '', 22145 example: 'Best Massage Chair 2025 â Product Review Awards', 22146 availability: 'product', 22147 }, 22148 { 22149 key: 'appointmentDate', 22150 description: 'Date of the booked showroom visit, in the customer\\'s own timezone.', 22151 source: 'showroom.* event eventData.startTimeUtc', 22152 defaultValue: '', 22153 example: 'Tuesday 5 August', 22154 availability: 'always', 22155 }, 22156 { 22157 key: 'appointmentTime', 22158 description: 'Start time of the booked showroom visit, in the customer\\'s own timezone.', 22159 source: 'showroom.* event eventData.startTimeUtc', 22160 defaultValue: '', 22161 example: '10:00am', 22162 availability: 'always', 22163 }, 22164 { 22165 key: 'locationName', 22166 description: 'Showroom name, snapshotted onto the appointment at booking time.', 22167 source: 'showroom.* event eventData.locationName', 22168 defaultValue: '', 22169 example: 'South Melbourne Showroom', 22170 availability: 'always', 22171 }, 22172 { 22173 key: 'locationAddress', 22174 description: 'One line showroom address, snapshotted onto the appointment at booking time.', 22175 source: 'showroom.* event eventData.locationAddress', 22176 defaultValue: '', 22177 example: '117 York Street, South Melbourne VIC 3205', 22178 availability: 'always', 22179 }, 22180 { 22181 key: 'mapsUrl', 22182 description: 'Maps deep link for the showroom. Empty when the tenant configures none.', 22183 source: 'TenantConfig.showroom.mapsUrl (via the event)', 22184 defaultValue: '', 22185 example: 'https://maps.google.com/?q=117+York+Street+South+Melbourne', 22186 availability: 'always', 22187 }, 22188 { 22189 key: 'delayMinutes', 22190 description: 'How many minutes late the visit will now start. showroom.delayed only.', 22191 source: 'showroom.delayed event eventData.delayMinutes', 22192 defaultValue: '', 22193 example: '15', 22194 availability: 'always', 22195 }, 22196 { 22197 key: 'locationLine', 22198 description: 'Mode aware "where" sentence so one reminder template serves both a phone callback (empty) and a showroom visit. Carries its own leading space.', 22199 source: 'appointment.reminder_* event eventData.mode + locationName + locationAddress', 22200 defaultValue: '', 22201 example: ' Our showroom: South Melbourne Showroom, 117 York Street, South Melbourne VIC 3205.', 22202 availability: 'always', 22203 }, 22204 { 22205 key: 'resellerContactName', 22206 description: 'Reseller showroom contact person the customer should ask for. Only populated on reseller_appointment.* triggered workflows.', 22207 source: 'reseller_appointment.* event eventData.resellerContactName', 22208 defaultValue: '', 22209 example: 'Brett', 22210 availability: 'always', 22211 }, 22212]; 22213 22214/** 22215 * Get placeholder definition by key 22216 */ 22217export function getPlaceholderDefinition(key: string): PlaceholderDefinition | undefined { 22218 return MESSAGE_PLACEHOLDER_DEFINITIONS.find(p => p.key === key); 22219} 22220 22221/** 22222 * Get all placeholder keys 22223 */ 22224export function getPlaceholderKeys(): string[] { 22225 return MESSAGE_PLACEHOLDER_DEFINITIONS.map(p => p.key); 22226} 22227 22228/** 22229 * Get placeholders filtered by availability 22230 */ 22231export function getPlaceholdersByAvailability( 22232 availability: PlaceholderAvailability 22233): PlaceholderDefinition[] { 22234 return MESSAGE_PLACEHOLDER_DEFINITIONS.filter( 22235 p => p.availability === availability || p.availability === 'always' 22236 ); 22237} 22238 22239/** 22240 * Default placeholder values 22241 */ 22242export const DEFAULT_PLACEHOLDER_VALUES: MessagePlaceholderValues = { 22243 name: 'there', 22244 firstName: 'there', 22245 orderNumber: '', 22246 orderLink: '', 22247 deliveryDate: '', 22248 deliveryBlock: '', 22249 productBlock: '', 22250 companionBlock: '', 22251 link: '', 22252 productName: 'massage chair', 22253 savings: '$0', 22254 discountPercent: '', 22255 discountCode: '', 22256 freeDiscountCode: '', 22257 freeItems: '', 22258 cartTotal: '$0', 22259 discountDraftInvoiceTotal: '$0', 22260 video: '', 22261 agent: 'Chris', 22262 storeDomain: '', 22263 productImageUrl: '', 22264 variantTitle: '', 22265 subtotal: '$0', 22266 total: '$0', 22267 originalTotal: '$0', 22268 currency: 'AUD', 22269 logoUrl: '', 22270 savingsLine: '', 22271 freeItemsLine: '', 22272 productHero: '', 22273 productReview: '', 22274 productAward: '', 22275 appointmentDate: '', 22276 appointmentTime: '', 22277 locationName: '', 22278 locationAddress: '', 22279 mapsUrl: '', 22280 delayMinutes: '', 22281 locationLine: '', 22282 resellerContactName: '', 22283}; 22284 22285`,pn=`/** 22286 * Meta Ad Config Types 22287 * 22288 * Type definitions for per-ad popup/modal configurations for Facebook Website ads. 22289 * Stored in DynamoDB meta-ad-config table with pk/sk pattern. 22290 * 22291 * Schema: 22292 * - pk: tenantName (e.g., "masseuse-massage-store.myshopify.com") 22293 * - sk: "AD#{adId}" for per-ad config, or "DEFAULT" for tenant default 22294 */ 22295 22296// ============================================================================ 22297// Sort Key Types 22298// ============================================================================ 22299 22300/** 22301 * Sort key pattern for meta-ad-config table. 22302 * Either "DEFAULT" for tenant fallback, or "AD#{adId}" for per-ad config. 22303 */ 22304export type MetaAdConfigSk = 'DEFAULT' | \`AD#\${string}\`; 22305 22306// ============================================================================ 22307// Ad Metadata 22308// ============================================================================ 22309 22310/**
22311 * Cached metadata from Facebook ad. 22312 * Populated during sync, displayed in ShopDash UI for context. 22313 */ 22314export interface MetaAdMetadata { 22315 /** Facebook campaign name */ 22316 campaignName?: string; 22317 /** Facebook ad set name */ 22318 adsetName?: string; 22319 /** Ad destination URL */ 22320 destinationUrl?: string; 22321 /** Last sync timestamp */ 22322 lastSyncedAt?: string; 22323} 22324 22325// ============================================================================ 22326// Meta Ad Config 22327// ============================================================================ 22328 22329/** 22330 * Configuration for a Meta ad popup/modal. 22331 * 22332 * For per-ad configs (sk = "AD#{adId}"): 22333 * - displayName comes from Facebook ad name 22334 * - adMetadata is populated during sync 22335 * 22336 * For DEFAULT config (sk = "DEFAULT"): 22337 * - Used as fallback when no ad-specific config exists 22338 * - Also used as template when syncing new ads 22339 */ 22340export interface MetaAdConfig { 22341 /** Partition key: tenant domain */ 22342 pk: string; 22343 /** Sort key: "DEFAULT" or "AD#{adId}" */ 22344 sk: MetaAdConfigSk; 22345 /** Whether this config is enabled */ 22346 enabled: boolean; 22347 /** Display name for UI (ad name from Facebook for per-ad, custom for DEFAULT) */ 22348 displayName?: string; 22349 /** S3 template ID for popup content */ 22350 popupTemplateId?: string; 22351 /** S3 template ID for SMS message */ 22352 smsTemplateId?: string; 22353 /** Shopify discount code to send */ 22354 discountCode: string; 22355 /** Display value for discount (e.g., "10%", "$20 OFF") */ 22356 discountValue: string; 22357 /** Delay in seconds before showing popup */ 22358 delaySeconds: number; 22359 /** URL paths where popup should appear (empty = all paths) */ 22360 paths?: string[]; 22361 /** Headline text for popup */ 22362 headline?: string; 22363 /** Subheadline text for popup */ 22364 subheadline?: string; 22365 /** Cached ad metadata from Facebook */ 22366 adMetadata?: MetaAdMetadata; 22367 /** Created timestamp */ 22368 createdAt?: string; 22369 /** Updated timestamp */ 22370 updatedAt?: string; 22371} 22372 22373// ============================================================================ 22374// API Types 22375// ============================================================================ 22376 22377/** 22378 * Response from meta-discount-config API. 22379 * Subset of MetaAdConfig fields needed by the theme block. 22380 */ 22381export interface MetaDiscountConfigResponse { 22382 enabled: boolean; 22383 paths: string[]; 22384 discountCode: string; 22385 discountValue: string; 22386 delaySeconds: number; 22387 headline?: string; 22388 subheadline?: string; 22389 /** Indicates if this is ad-specific or default config */ 22390 matchType?: 'ad' | 'default'; 22391} 22392 22393/** 22394 * Request body for meta-discount-sms API. 22395 */ 22396export interface MetaDiscountSmsRequest { 22397 phone: string; 22398 tenantName: string; 22399 /** Optional ad ID for per-ad template lookup */ 22400 adId?: string; 22401 visitorId?: string; 22402 attribution?: { 22403 fbclid?: string; 22404 utmSource?: string; 22405 utmMedium?: string; 22406 utmCampaign?: string; 22407 }; 22408} 22409 22410/** 22411 * Response from meta-discount-sms API. 22412 */ 22413export interface MetaDiscountSmsResponse { 22414 success: boolean; 22415 alreadyClaimed: boolean; 22416 message: string; 22417} 22418 22419// ============================================================================ 22420// Sync Types 22421// ============================================================================ 22422 22423/** 22424 * Input for syncing ads from Facebook to meta-ad-config table. 22425 */ 22426export interface SyncMetaAdsInput { 22427 tenantId: string; 22428 adAccountId: string; 22429} 22430 22431/** 22432 * Result from syncing a single ad. 22433 */ 22434export interface SyncMetaAdResult { 22435 adId: string; 22436 adName: string; 22437 action: 'created' | 'updated' | 'skipped'; 22438 reason?: string; 22439} 22440 22441/** 22442 * Response from sync operation. 22443 */ 22444export interface SyncMetaAdsResponse { 22445 success: boolean; 22446 totalAds: number; 22447 created: number; 22448 updated: number; 22449 skipped: number; 22450 results: SyncMetaAdResult[]; 22451 error?: string; 22452} 22453 22454// ============================================================================ 22455// ShopDash Types 22456// ============================================================================ 22457 22458/** 22459 * Ad config as displayed in ShopDash UI. 22460 * Extends MetaAdConfig with parsed ad ID. 22461 */ 22462export interface MetaAdConfigListItem extends MetaAdConfig { 22463 /** Parsed ad ID from sort key (null for DEFAULT) */ 22464 adId: string | null; 22465} 22466 22467/** 22468 * Input for creating/updating an ad config. 22469 */ 22470export interface MetaAdConfigInput { 22471 enabled: boolean; 22472 displayName?: string; 22473 popupTemplateId?: string; 22474 smsTemplateId?: string; 22475 discountCode: string; 22476 discountValue: string; 22477 delaySeconds: number; 22478 paths?: string[]; 22479 headline?: string; 22480 subheadline?: string; 22481} 22482 22483// ============================================================================ 22484// Helper Functions 22485// ============================================================================ 22486 22487/** 22488 * Parse ad ID from sort key.
22489 * @param sk Sort key (e.g., "AD#120225317644430219" or "DEFAULT") 22490 * @returns Ad ID or null for DEFAULT 22491 */ 22492export function parseAdIdFromSk(sk: MetaAdConfigSk): string | null { 22493 if (sk === 'DEFAULT') return null; 22494 if (sk.startsWith('AD#')) return sk.substring(3); 22495 return null; 22496} 22497 22498/** 22499 * Build sort key from ad ID. 22500 * @param adId Ad ID or null for DEFAULT 22501 * @returns Sort key 22502 */ 22503export function buildSkFromAdId(adId: string | null): MetaAdConfigSk { 22504 return adId ? \`AD#\${adId}\` : 'DEFAULT'; 22505} 22506 22507/** 22508 * Check if a config is the tenant default. 22509 */ 22510export function isDefaultConfig(config: MetaAdConfig): boolean { 22511 return config.sk === 'DEFAULT'; 22512} 22513 22514/** 22515 * Check if a config is for a specific ad. 22516 */ 22517export function isAdSpecificConfig(config: MetaAdConfig): boolean { 22518 return config.sk.startsWith('AD#'); 22519} 22520`,un=`/** 22521 * Meta Asset Metadata Types 22522 * 22523 * Stores campaign associations, performance metrics, and active status 22524 * for Facebook ad images and videos synced to S3. 22525 * 22526 * Table: meta-asset-metadata 22527 * PK: tenantId (e.g., "masseuse-massage-store.myshopify.com") 22528 * SK: asset filename (e.g., "meta-abc123.jpg" or "meta-vid-456.mp4") 22529 */ 22530 22531/** 22532 * Campaign association for an asset 22533 */ 22534export interface AssetCampaignAssociation { 22535 campaignId: string; 22536 campaignName: string; 22537 adsetName?: string; 22538 adId: string; 22539 adName: string; 22540 effectiveStatus: string; // ACTIVE, PAUSED, ARCHIVED, etc. 22541} 22542 22543/** 22544 * Aggregated performance metrics for an asset 22545 */ 22546export interface AssetPerformanceMetrics { 22547 impressions: number; 22548 clicks: number; 22549 ctr: number; 22550 spend: number; 22551 reach: number; 22552 leads: number; 22553 purchases: number; 22554 roas: number; 22555 costPerClick: number; 22556 costPerLead: number; 22557} 22558 22559/** 22560 * Performance tier for AI recommendations 22561 */ 22562export type AssetPerformanceTier = 'high' | 'medium' | 'low' | 'new'; 22563 22564/** 22565 * Asset type 22566 */ 22567export type MetaAssetType = 'image' | 'video'; 22568 22569/** 22570 * Full metadata record for a synced Meta asset 22571 */ 22572export interface MetaAssetMetadata { 22573 /** Partition key: tenantId */ 22574 pk: string; 22575 22576 /** Sort key: asset filename (S3 key suffix, e.g., "meta-abc123.jpg") */ 22577 sk: string; 22578 22579 /** Whether this is an image or video */ 22580 assetType: MetaAssetType; 22581 22582 /** Facebook image/video ID */ 22583 facebookId: string; 22584 22585 /** Facebook image hash (images only) */ 22586 facebookHash?: string; 22587 22588 /** Original name from Facebook */ 22589 name: string; 22590 22591 /** Image/video dimensions */ 22592 width?: number; 22593 height?: number; 22594 22595 /** Full S3 key */ 22596 s3Key: string; 22597 22598 /** CloudFront CDN URL */ 22599 cdnUrl: string; 22600 22601 /** Facebook created_time */ 22602 createdTime?: string; 22603 22604 /** Ad IDs currently using this asset with ACTIVE status */ 22605 activeInAds: string[]; 22606 22607 /** All campaign associations (active and inactive) */ 22608 campaigns: AssetCampaignAssociation[]; 22609 22610 /** True if any ad using this asset is ACTIVE */ 22611 isCurrentlyActive: boolean; 22612 22613 /** Aggregated performance metrics across all ads using this asset */ 22614 metrics: AssetPerformanceMetrics; 22615 22616 /** Date range for the metrics data */ 22617 metricsDateRange: { start: string; end: string }; 22618 22619 /** Last time the asset file was synced to S3 */ 22620 syncedAt: string; 22621 22622 /** Last time performance metrics were updated */ 22623 metricsUpdatedAt: string; 22624 22625 /** Optional TTL for auto-cleanup (epoch seconds) */ 22626 ttl?: number; 22627} 22628 22629/** 22630 * Build the partition key for a meta asset metadata record 22631 */ 22632export function buildMetaAssetPk(tenantId: string): string { 22633 return tenantId; 22634} 22635 22636/** 22637 * Build the sort key for a meta asset metadata record 22638 */ 22639export function buildMetaAssetSk(assetFilename: string): string { 22640 return assetFilename; 22641} 22642
22643/** 22644 * Determine performance tier based on metrics 22645 */ 22646export function getPerformanceTier(metrics: AssetPerformanceMetrics): AssetPerformanceTier { 22647 if (metrics.impressions === 0 && metrics.clicks === 0) return 'new'; 22648 if (metrics.ctr > 0.02 || metrics.impressions > 10000) return 'high'; 22649 if (metrics.ctr > 0.01 || metrics.impressions > 1000) return 'medium'; 22650 return 'low'; 22651} 22652 22653/** 22654 * Create default (zeroed) performance metrics 22655 */ 22656export function defaultAssetMetrics(): AssetPerformanceMetrics { 22657 return { 22658 impressions: 0, 22659 clicks: 0, 22660 ctr: 0, 22661 spend: 0, 22662 reach: 0, 22663 leads: 0, 22664 purchases: 0, 22665 roas: 0, 22666 costPerClick: 0, 22667 costPerLead: 0, 22668 }; 22669} 22670`,mn=`/** 22671 * Catalog of available MMS card variants. Single source of truth for: 22672 * - Workflow builder dropdown (shopdash StepMessagesPanel.vue) 22673 * - /templates MMS catalog tab (shopdash TemplatesView.vue) 22674 * - Render dispatch (BigM render-for-draft.ts + mms-card-renderer Lambda) 22675 * 22676 * To add a new variant (PR checklist): 22677 * 1. Pick a \`cardType\` string (new or existing) â the renderer's dispatch key. 22678 * 2. Define the typed payload at BigM/lambda/lambda-deployed-mms-card-renderer/ 22679 * mms-card-renderer/cards/<id>-types.ts. 22680 * 3. Build the Satori vnode tree at .../mms-card-renderer/cards/<id>.ts â 22681 * export build<Id>Card() + compute<Id>Height(). See cards/draft-order-preview.ts. 22682 * 4. Add a \`case '<cardType>':\` branch in mms-card-renderer/handler.ts 22683 * buildVnodeAndKey() to return { vnode, key, height, ... } for the new card. 22684 * 5. Write the placeholder builder in BigM/packages/shared/src/draft-order-image/ 22685 * build-<id>-placeholders.ts (data marshaling from tenant + lead + context). 22686 * 6. Wire dispatch in BigM/packages/shared/src/draft-order-image/render-for-draft.ts: 22687 * add a \`templateId?: string\` input field if not already present, look up 22688 * cardType via getMmsCardTemplate(), switch on templateId to pick the right 22689 * placeholder builder. (The variant-2 PR introduces this switch â variants 22690 * 3+ extend it.) Mirror file to shopdash/packages/shared/. 22691 * 7. Thread the new \`templateId\` argument into the helper call sites: 22692 * send-sms.ts:389-430 (workflow auto-send path) and 22693 * campaign-execution-engine/tools/render-draft-order-image.ts (the system 22694 * step). Both should pass campaign.messages.mmsCard.templateId through. 22695 * 8. Add an entry to MMS_CARD_TEMPLATES below with the new id, label, 22696 * description, requires[], and cardType. 22697 * 9. Deploy: pwsh -File BigM/scripts/build-and-deploy-esbuild.ps1 -Lambda 22698 * mms-card-renderer AND -Lambda campaign-execution-engine. 22699 * 22700 * UI updates auto-flow from the manifest â no shopdash code changes required 22701 * (dropdown, preview, catalog tile, warning chip all read from MMS_CARD_TEMPLATES). 22702 */ 22703 22704/** 22705 * Runtime preconditions a card variant requires. Drives the "MMS won't fire" 22706 * warning chip in the workflow builder. Empty \`requires[]\` = always fires. 22707 */ 22708export type MmsCardRequirement = 22709 | 'draft_order' // workflow has a create_promotional_offer step 22710 | 'assigned_agent' // lead.ownedByAgent set (for agent-avatar variants) 22711 | 'tenant_awards' // tenant config carries awards list 22712 | 'tenant_logo'; // tenant.logoUrl set 22713 22714/** 22715 * Operator-editable content overrides per template, stored in the S3 22716 * config.json. When a field is present here, the placeholder builder uses it 22717 * instead of the platform-shipped TENANT_MARKETING defaults. Resolution 22718 * semantics per slot: 22719 * - field absent (undefined) â fall back to TENANT_MARKETING defaults 22720 * - field present, non-empty â use this value 22721 * - field present, empty array â render with NOTHING (explicit removal) 22722 * 22723 * Image URLs may be either the tenant's CloudFront asset URLs (from 22724 * \`bigm-email-templates/{tenant}/assets/\`) or direct Shopify CDN URLs. 22725 * Renderer Lambda pre-fetches each URL as a Satori-consumable data URL. 22726 */ 22727export interface MmsCardContent { 22728 /** Override the default preview agent. agentId UUID from the agents table. */ 22729 agentOverride?: string; 22730 /** Replace the â â â â â trust ribbon text. */ 22731 reviewBadge?: { 22732 rating: string; 22733 reviewCount: string; 22734 platform: string; 22735 }; 22736 /** Up to 4 gold-badge graphics. */ 22737 awardBadges?: Array<{ imageUrl: string; alt: string }>; 22738 /** Up to 4 "As featured on" media-network logos. */ 22739 featuredOnLogos?: Array<{ imageUrl: string; alt: string }>; 22740} 22741 22742export interface MmsCardTemplateDef { 22743 /** Stable identifier used on campaign.messages.mmsCard.templateId. */ 22744 id: string; 22745 /** Operator-facing label in dropdowns + catalog tiles. */ 22746 label: string; 22747 /** One-line description shown in catalog tiles + dropdown hover. */ 22748 description: string; 22749 /** Runtime requirements. Drives the "MMS won't fire" warning chip in the 22750 * workflow builder. Empty array = always fires. */ 22751 requires: MmsCardRequirement[]; 22752 /** Dispatch identifier consumed by render-for-draft.ts (which picks the 22753 * placeholder builder) and the renderer Lambda's buildVnodeAndKey() (which 22754 * picks the card layout). Multiple template ids MAY share a cardType if 22755 * they share the same layout + data shape (rare). Grows as new cards land. */ 22756 cardType: 'mms-savings' | 'draft-order-preview' | 'agent-avatar' | 'eofy-offer'; 22757 /** Operator-edited overrides for the card content. Edited via the MmsCardEditor 22758 * in /templates and stored in s3://bigm-email-templates/{tenant}/mms-card/{id}/config.json. 22759 * Missing on code-default entries â they always use TENANT_MARKETING defaults. */ 22760 content?: MmsCardContent; 22761} 22762 22763export const MMS_CARD_TEMPLATES: readonly MmsCardTemplateDef[] = [ 22764 { 22765 id: 'order-summary-v1', 22766 label: 'Order Summary', 22767 description: 'Product images with "YOU SAVE" hero. Fires when the workflow creates a draft order.', 22768 requires: ['draft_order'], 22769 cardType: 'mms-savings', 22770 }, 22771 { 22772 id: 'agent-avatar-awards-v1', 22773 label: 'Specialist + Awards', 22774 description: "Features the assigned specialist's photo + name alongside the order summary and a row of trust-signal badges (5â ProductReview, NDIS, warranty). Falls back to the tenant default specialist when no agent is assigned.", 22775 requires: ['draft_order', 'assigned_agent'], 22776 cardType: 'agent-avatar', 22777 }, 22778 { 22779 id: 'eofy-offer-v1', 22780 label: 'EOFY Offer (Chair of the Year)', 22781 description: 'End-of-Financial-Year ad card: the "Chair of the Year" hero image + "Up to 50% OFF", award badges, 5â reviews, "as seen on Seven/Nine", and your specialist Steve. Standalone brand card â fires on any opener (no draft order or assigned agent needed).', 22782 requires: [], 22783 cardType: 'eofy-offer', 22784 }, 22785] as const; 22786 22787export type MmsCardTemplateId = (typeof MMS_CARD_TEMPLATES)[number]['id']; 22788 22789/** 22790 * Lookup helper â reused by the workflow builder warning chip, render-for-draft 22791 * dispatch (variant 2+), and any future send-side caller that needs to resolve 22792 * a templateId to its full definition. 22793 *
22794 * Returns \`undefined\` for unknown / null / empty ids â callers MUST handle that 22795 * (don't assume the manifest is always synced with the live campaign-context row; 22796 * deprecated template ids may linger on existing workflows). 22797 */ 22798export function getMmsCardTemplate(id: string | undefined | null): MmsCardTemplateDef | undefined { 22799 if (!id) return undefined; 22800 return MMS_CARD_TEMPLATES.find((t) => t.id === id); 22801} 22802`,gn=`/** 22803 * Partner Referral System Type Definitions 22804 * 22805 * Partners (health specialists, personal trainers, etc.) share tracked discount links 22806 * with their clients. Offers link partners to Shopify discount codes per tenant/period. 22807 * 22808 * Tables: 22809 * - partners (PK: partnerId) 22810 * - partner-offers (PK: offerId) 22811 */ 22812 22813// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 22814// PARTNER 22815// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 22816 22817export type PartnerType = 'health_specialist' | 'personal_trainer' | 'influencer' | 'other'; 22818export type PartnerStatus = 'active' | 'inactive'; 22819 22820/** 22821 * Partner - a referral partner who shares discount links with their clients. 22822 * Partners are cross-tenant (one partner can work with multiple stores). 22823 */ 22824export interface Partner { 22825 /** Primary key - unique partner identifier (e.g., "ptr_m1abc_x7k2m9") */ 22826 partnerId: string; 22827 22828 /** Partner's full name (e.g., "Dr. Sarah Smith") */ 22829 name: string; 22830 22831 /** Contact email address */ 22832 email: string; 22833 22834 /** Contact phone number (optional) */ 22835 phone?: string; 22836 22837 /** Type of partner */ 22838 partnerType: PartnerType; 22839 22840 /** Tenant IDs this partner is associated with (e.g., ["store.myshopify.com"]) */ 22841 tenants: string[]; 22842 22843 /** Default commission percentage for reporting (e.g., 10 for 10%) */ 22844 defaultCommissionPercent: number; 22845 22846 /** Whether the partner is currently active */ 22847 status: PartnerStatus; 22848 22849 /** Admin notes about this partner */ 22850 notes?: string; 22851 22852 /** UTC timestamp when partner was created (ISO 8601) */ 22853 createdAt: string; 22854 22855 /** UTC timestamp when partner was last updated (ISO 8601) */ 22856 updatedAt: string; 22857 22858 /** User who created the partner (Cognito email) */ 22859 createdBy?: string; 22860 22861 /** User who last updated the partner */ 22862 updatedBy?: string; 22863} 22864 22865/** 22866 * Input for creating a new partner 22867 */ 22868export interface CreatePartnerInput { 22869 name: string; 22870 email: string; 22871 phone?: string; 22872 partnerType: PartnerType; 22873 tenants: string[]; 22874 defaultCommissionPercent: number; 22875 status?: PartnerStatus; 22876 notes?: string; 22877} 22878 22879/** 22880 * Input for updating an existing partner 22881 */ 22882export interface UpdatePartnerInput { 22883 name?: string; 22884 email?: string; 22885 phone?: string; 22886 partnerType?: PartnerType; 22887 tenants?: string[]; 22888 defaultCommissionPercent?: number; 22889 status?: PartnerStatus; 22890 notes?: string; 22891} 22892 22893// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 22894// PARTNER OFFER 22895// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 22896 22897export type PartnerOfferStatus = 'active' | 'expired' | 'revoked'; 22898 22899/** 22900 * Partner Offer - links a partner to a Shopify discount code for a specific tenant and period. 22901 * Each offer generates a tracked short link that the partner shares with their clients. 22902 */ 22903export interface PartnerOffer { 22904 /** Primary key - unique offer identifier (e.g., "pof_m1abc_x7k2m9") */ 22905 offerId: string; 22906 22907 /** FK to partners table */ 22908 partnerId: string; 22909 22910 /** Tenant/shop domain this offer is for */ 22911 tenantId: string; 22912 22913 /** Shopify discount code (must already exist in Shopify Admin) */ 22914 discountCode: string; 22915 22916 /** Human-readable description (e.g., "15% off all wellness supplements") */ 22917 discountDescription: string; 22918 22919 /** Shopify store path to redirect to (e.g., "/collections/wellness") */ 22920 redirectPath: string; 22921
22922 /** Offer period identifier (e.g., "2026-Q1" or "2026-03") */ 22923 period: string; 22924 22925 /** When the offer becomes valid (ISO 8601) */ 22926 validFrom: string; 22927 22928 /** When the offer expires (ISO 8601) */ 22929 validUntil: string; 22930 22931 /** Offer status */ 22932 status: PartnerOfferStatus; 22933 22934 /** Generated short code for /c/{code} URL */ 22935 shortCode?: string; 22936 22937 /** Full tracking URL (e.g., "https://redirect.domain/c/AbC7kM9") */ 22938 trackingUrl?: string; 22939 22940 /** UTC timestamp when offer was created (ISO 8601) */ 22941 createdAt: string; 22942 22943 /** UTC timestamp when offer was last updated (ISO 8601) */ 22944 updatedAt: string; 22945 22946 /** User who created the offer (Cognito email) */ 22947 createdBy?: string; 22948} 22949 22950/** 22951 * Input for creating a new partner offer 22952 */ 22953export interface CreatePartnerOfferInput { 22954 partnerId: string; 22955 tenantId: string; 22956 discountCode: string; 22957 discountDescription: string; 22958 redirectPath: string; 22959 period: string; 22960 validFrom: string; 22961 validUntil: string; 22962 status?: PartnerOfferStatus; 22963} 22964 22965/** 22966 * Input for updating an existing partner offer 22967 */ 22968export interface UpdatePartnerOfferInput { 22969 discountCode?: string; 22970 discountDescription?: string; 22971 redirectPath?: string; 22972 period?: string; 22973 validFrom?: string; 22974 validUntil?: string; 22975 status?: PartnerOfferStatus; 22976} 22977 22978// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 22979// HELPERS 22980// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 22981 22982/** 22983 * Generate a unique partner ID 22984 * Format: ptr_{timestamp}_{random} 22985 */ 22986export function generatePartnerId(): string { 22987 const timestamp = Date.now().toString(36); 22988 const random = Math.random().toString(36).substring(2, 8); 22989 return \`ptr_\${timestamp}_\${random}\`; 22990} 22991 22992/** 22993 * Generate a unique offer ID 22994 * Format: pof_{timestamp}_{random} 22995 */ 22996export function generateOfferId(): string { 22997 const timestamp = Date.now().toString(36); 22998 const random = Math.random().toString(36).substring(2, 8); 22999 return \`pof_\${timestamp}_\${random}\`; 23000} 23001 23002/** 23003 * Generate a URL-friendly slug from a partner name 23004 * "Dr. Sarah Smith" -> "dr-sarah-smith" 23005 */ 23006export function generatePartnerSlug(name: string): string { 23007 return name 23008 .toLowerCase() 23009 .replace(/[^a-z0-9\\s-]/g, '') 23010 .replace(/\\s+/g, '-') 23011 .replace(/-+/g, '-') 23012 .replace(/^-|-$/g, ''); 23013} 23014 23015/** 23016 * Build the Shopify destination URL with discount auto-apply and UTM params 23017 */ 23018export function buildPartnerDestinationUrl( 23019 storeDomain: string, 23020 discountCode: string, 23021 redirectPath: string, 23022 partnerSlug: string, 23023 period: string 23024): string { 23025 const cleanDomain = storeDomain.replace(/^https?:\\/\\//, ''); 23026 const cleanPath = redirectPath.startsWith('/') ? redirectPath : \`/\${redirectPath}\`; 23027 const encodedRedirect = encodeURIComponent(cleanPath); 23028 23029 const params = [ 23030 \`redirect=\${encodedRedirect}\`, 23031 'utm_source=partner', 23032 'utm_medium=referral', 23033 \`utm_campaign=\${encodeURIComponent(partnerSlug)}\`, 23034 \`utm_content=\${encodeURIComponent(period)}\`, 23035 ].join('&'); 23036 23037 return \`https://\${cleanDomain}/discount/\${encodeURIComponent(discountCode)}?\${params}\`; 23038} 23039 23040/** 23041 * Validate a partner for required fields 23042 */ 23043export function validatePartner( 23044 partner: Partial<Partner> 23045): { valid: boolean; errors: string[] } { 23046 const errors: string[] = []; 23047 23048 if (!partner.name?.trim()) errors.push('name is required'); 23049 if (!partner.email?.trim()) errors.push('email is required'); 23050 if (!partner.partnerType) errors.push('partnerType is required'); 23051 if (!partner.tenants || partner.tenants.length === 0) errors.push('at least one tenant is required'); 23052 if (partner.defaultCommissionPercent === undefined || partner.defaultCommissionPercent === null) { 23053 errors.push('defaultCommissionPercent is required'); 23054 } else if (partner.defaultCommissionPercent < 0 || partner.defaultCommissionPercent > 100) { 23055 errors.push('defaultCommissionPercent must be between 0 and 100'); 23056 } 23057 23058 // Basic email validation 23059 if (partner.email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(partner.email)) {
23060 errors.push('email format is invalid'); 23061 } 23062 23063 return { valid: errors.length === 0, errors }; 23064} 23065 23066/** 23067 * Validate a partner offer for required fields 23068 */ 23069export function validatePartnerOffer( 23070 offer: Partial<PartnerOffer> 23071): { valid: boolean; errors: string[] } { 23072 const errors: string[] = []; 23073 23074 if (!offer.partnerId) errors.push('partnerId is required'); 23075 if (!offer.tenantId) errors.push('tenantId is required'); 23076 if (!offer.discountCode?.trim()) errors.push('discountCode is required'); 23077 if (!offer.discountDescription?.trim()) errors.push('discountDescription is required'); 23078 if (!offer.redirectPath?.trim()) errors.push('redirectPath is required'); 23079 if (!offer.period?.trim()) errors.push('period is required'); 23080 if (!offer.validFrom) errors.push('validFrom is required'); 23081 if (!offer.validUntil) errors.push('validUntil is required'); 23082 23083 // Date validation 23084 if (offer.validFrom && offer.validUntil) { 23085 if (new Date(offer.validUntil) <= new Date(offer.validFrom)) { 23086 errors.push('validUntil must be after validFrom'); 23087 } 23088 } 23089 23090 return { valid: errors.length === 0, errors }; 23091} 23092`,hn=`/** 23093 * PII Feedback â SME labelling captured from ProfilePane review actions. 23094 * 23095 * This is the training signal for the prompt-eval loop. Every accept/reject/edit 23096 * an SME performs on a PII field writes one row to the \`pii-feedback\` table. 23097 * The evaluator Lambda (\`pii-prompt-evaluator\`) aggregates these by 23098 * \`(promptId, promptVersion)\` to compute precision, find top false positives, 23099 * and surface confirmed-positive examples for the next prompt revision. 23100 * 23101 * The shape is deliberately generalisable: \`fieldPath\` is a string, so the same 23102 * table holds review of any masterProfile field (ineligibilityReasons[N], 23103 * salesContext.primaryHurdle, financeProfile.employment[0], etc) without a 23104 * schema change. Per-field add a new entry to the dataApi route allowlist. 23105 */ 23106 23107import type { PromptId } from './pii-prompt-ids.js'; 23108 23109/** Action the SME took on the proposed item. */ 23110export type PiiReviewAction = 'accept' | 'reject' | 'edit'; 23111 23112/** 23113 * One row in the \`pii-feedback\` DynamoDB table. 23114 * 23115 * - PK: feedbackId (ULID) 23116 * - GSI feedbackByLead: leadId, createdAt 23117 * - GSI feedbackByPromptVersion: \`\${promptId}#\${promptVersion}\`, createdAt (eval rollup) 23118 * - GSI feedbackByCategory: category, createdAt (per-category slicing) 23119 */ 23120export interface PiiFeedbackRow { 23121 /** ULID â PK. */ 23122 feedbackId: string; 23123 23124 /** Lead this review was performed on. */ 23125 leadId: string; 23126 tenantName: string; 23127 23128 /** 23129 * Generalisable pointer into the Lead row. 23130 * e.g. \`'masterProfile.ineligibilityReasons[2]'\`, 23131 * \`'masterProfile.salesContext.primaryHurdle'\`, 23132 * \`'masterProfile.financeProfile.employment[0].status'\`. 23133 */ 23134 fieldPath: string; 23135 23136 /** 23137 * Optional category tag â copied from the reviewed item when available 23138 * (e.g. ineligibilityReasons[N].category). Powers the 23139 * \`feedbackByCategory\` GSI for per-category precision metrics. 23140 */ 23141 category?: string; 23142 23143 /** Prompt that produced the proposed value. */ 23144 promptId: PromptId; 23145 /** Prompt version pinned at extraction time â load-bearing for eval attribution. */ 23146 promptVersion: string; 23147 23148 /** 23149 * Composite key for the eval-rollup GSI. 23150 * Computed as \`\${promptId}#\${promptVersion}\` â saves a second query. 23151 */ 23152 promptIdVersion: string; 23153 23154 /** Call ID (when the proposed value came from a call). */ 23155 sourceCallId?: string; 23156 23157 /** The model's proposed value at extraction time. */ 23158 before: Record<string, unknown>; 23159 23160 /** 23161 * The SME-corrected value, OR null for a pure reject without a replacement. 23162 * For \`action: 'accept'\`, equals \`before\` (the SME confirmed the model). 23163 */ 23164 after: Record<string, unknown> | null; 23165 23166 /** What the SME did. */ 23167 action: PiiReviewAction; 23168 23169 /** 23170 * The training label: \`true\` for accept, \`false\` for reject/edit. 23171 * 23172 * This is what the evaluator counts to compute precision per 23173 * (promptId, promptVersion). Stored explicitly so eval queries don't 23174 * have to derive it from \`action\`. 23175 */ 23176 isCorrect: boolean; 23177 23178 /** Cognito sub of the reviewing SME. */ 23179 reviewedBy: string; 23180 /** Email â convenience for display in eval reports. */ 23181 reviewedByEmail?: string; 23182 /** ISO timestamp of the review. */ 23183 reviewedAt: string; 23184 23185 /** Free-text SME explanation. Visible in eval reports â encourage detail. */ 23186 smeNote?: string; 23187 23188 /** ISO timestamp the row was written. Sort key for time-series GSIs. */ 23189 createdAt: string; 23190} 23191 23192/** Input shape for \`writePiiFeedback()\` in @bigm/shared. */ 23193export interface CreatePiiFeedbackInput { 23194 leadId: string; 23195 tenantName: string; 23196 fieldPath: string; 23197 category?: string; 23198 promptId: PromptId; 23199 promptVersion: string; 23200 sourceCallId?: string; 23201 before: Record<string, unknown>; 23202 after: Record<string, unknown> | null; 23203 action: PiiReviewAction; 23204 reviewedBy: string; 23205 reviewedByEmail?: string; 23206 smeNote?: string; 23207} 23208 23209/** 23210 * Aggregated metrics computed by \`pii-prompt-evaluator\` and stored in 23211 * \`prompt-eval-runs\` (or surfaced via dataApi GET /data/pii/feedback/stats). 23212 */ 23213export interface PiiEvalMetrics { 23214 promptId: PromptId; 23215 promptVersion: string; 23216 totalReviews: number; 23217 accepts: number; 23218 rejects: number; 23219 edits: number; 23220 /** accepts / (accepts + rejects + edits). */ 23221 precision: number; 23222 /** Per-category precision â populated when \`category\` is present on rows. */ 23223 precisionByCategory: Record<string, { precision: number; total: number }>
23223; 23224 /** Top false positives: most-rejected \`before\` shapes (by hash). */ 23225 topRejects: Array<{ value: Record<string, unknown>; count: number }>; 23226 /** Top corrections: most-frequent \`before â after\` patterns. */ 23227 topCorrections: Array<{ 23228 before: Record<string, unknown>; 23229 after: Record<string, unknown>; 23230 count: number; 23231 }>; 23232 /** Top confirmed positives â gold few-shots for the next prompt revision. */ 23233 topAccepts: Array<{ value: Record<string, unknown>; count: number }>; 23234 /** Optional LLM-judge agreement rate, when calibration was run. */ 23235 judgeAgreementRate?: number; 23236} 23237`,fn=`/** 23238 * Prompt IDs that the PII feedback / eval pipeline knows about. 23239 * 23240 * Kept here (rather than importing from \`@bigm/prompts\`) so consumers of the 23241 * \`pii-feedback\` types don't pull in the prompts package transitively. The 23242 * canonical registry remains in \`@bigm/prompts/registry.ts\`; this is a typed 23243 * mirror of the subset that participates in feedback collection. 23244 */ 23245export type PromptId = 23246 | 'call-analysis/pii-extract.system' 23247 | 'text-pii/extract.system'; 23248`,yn=`/** 23249 * Placeholder Utility Functions 23250 * 23251 * Centralizes placeholder resolution logic with consistent default value handling. 23252 * Use these utilities in Lambda functions instead of hardcoding defaults. 23253 */ 23254 23255import { DEFAULT_PLACEHOLDER_VALUES, type MessagePlaceholderValues } from './message-placeholders.js'; 23256 23257/** 23258 * Resolve all placeholders in a template string using provided values with defaults 23259 * 23260 * @param template - The template string containing {{placeholder}} patterns 23261 * @param values - Partial values to use (defaults applied for missing values) 23262 * @returns The template with all placeholders replaced 23263 * 23264 * @example 23265 * \`\`\`typescript 23266 * const message = resolvePlaceholders( 23267 * 'Hi {{name}}, save {{discountPercent}}% with code {{discountCode}}!', 23268 * { name: 'John', discountPercent: '50', discountCode: 'SAVE50' } 23269 * ); 23270 * // Result: "Hi John, save 50% with code SAVE50!" 23271 * \`\`\` 23272 */ 23273export function resolvePlaceholders( 23274 template: string, 23275 values: Partial<MessagePlaceholderValues> 23276): string { 23277 const resolved = { ...DEFAULT_PLACEHOLDER_VALUES, ...values }; 23278 23279 return template 23280 .replace(/\\{\\{name\\}\\}/g, resolved.name) 23281 .replace(/\\{\\{firstName\\}\\}/g, resolved.firstName || resolved.name) 23282 // welcome email keys (Sep 2026) â the subject line is resolved here, the body by toolcall-send-email 23283 .replace(/\\{\\{orderNumber\\}\\}/g, resolved.orderNumber) 23284 .replace(/\\{\\{orderLink\\}\\}/g, resolved.orderLink) 23285 .replace(/\\{\\{deliveryDate\\}\\}/g, resolved.deliveryDate) 23286 .replace(/\\{\\{deliveryBlock\\}\\}/g, resolved.deliveryBlock) 23287 .replace(/\\{\\{productBlock\\}\\}/g, resolved.productBlock) 23288 .replace(/\\{\\{companionBlock\\}\\}/g, resolved.companionBlock) 23289 .replace(/\\{\\{link\\}\\}/g, resolved.link) 23290 .replace(/\\{\\{shortLink\\}\\}/g, resolved.link) 23291 .replace(/\\{\\{productName\\}\\}/g, resolved.productName) 23292 .replace(/\\{\\{savings\\}\\}/g, resolved.savings) 23293 .replace(/\\{\\{discountPercent\\}\\}/g, resolved.discountPercent) 23294 .replace(/\\{\\{discountCode\\}\\}/g, resolved.discountCode) 23295 .replace(/\\{\\{freeDiscountCode\\}\\}/g, resolved.freeDiscountCode) 23296 .replace(/\\{\\{freeItems\\}\\}/g, resolved.freeItems) 23297 .replace(/\\{\\{cartTotal\\}\\}/g, resolved.cartTotal) 23298 .replace(/\\{\\{discountDraftInvoiceTotal\\}\\}/g, resolved.discountDraftInvoiceTotal) 23299 .replace(/\\{\\{video\\}\\}/g, resolved.video) 23300 .replace(/\\{\\{agent\\}\\}/g, resolved.agent) 23301 .replace(/\\{\\{savingsLine\\}\\}/g, resolved.savingsLine) 23302 .replace(/\\{\\{freeItemsLine\\}\\}/g, resolved.freeItemsLine) 23303 .replace(/\\{\\{productHero\\}\\}/g, resolved.productHero) 23304 .replace(/\\{\\{productReview\\}\\}/g, resolved.productReview) 23305 .replace(/\\{\\{productAward\\}\\}/g, resolved.productAward) 23306 // Showroom / appointment lifecycle (showroom.* workflows) 23307 .replace(/\\{\\{appointmentDate\\}\\}/g, resolved.appointmentDate) 23308 .replace(/\\{\\{appointmentTime\\}\\}/g, resolved.appointmentTime) 23309 .replace(/\\{\\{locationName\\}\\}/g, resolved.locationName) 23310 .replace(/\\{\\{locationAddress\\}\\}/g, resolved.locationAddress) 23311 .replace(/\\{\\{mapsUrl\\}\\}/g, resolved.mapsUrl) 23312 .replace(/\\{\\{delayMinutes\\}\\}/g, resolved.delayMinutes) 23313 .replace(/\\{\\{locationLine\\}\\}/g, resolved.locationLine) 23314 .replace(/\\{\\{resellerContactName\\}\\}/g, resolved.resellerContactName); 23315} 23316 23317/** 23318 * Format a list of free items with natural-language joining. 23319 * 1 item â "X" 23320 * 2 items â "X and Y" 23321 * 3+ â "X, Y, and Z" (Oxford comma) 23322 * 23323 * Lifted from \`campaign-execution-engine/tools/generate-messages.ts:241-247\` 23324 * so the workflow path AND the operator dataApi endpoint produce identical 23325 * "Free X included." copy. 23326 */ 23327export function formatFreeItemsList(items: string[]): string { 23328 if (items.length === 0) return ''; 23329 if (items.length === 1) return items[0]!; 23330 if (items.length === 2) return \`\${items[0]} and \${items[1]}\`; 23331 return \`\${items.slice(0, -1).join(', ')}, and \${items[items.length - 1]}\`; 23332} 23333 23334/** 23335 * Resolve placeholders in HTML email template 23336 * 23337 * Same as resolvePlaceholders but wraps {{link}} in an anchor tag 23338 * 23339 * @param template - The HTML template string 23340 * @param values - Partial values to use (defaults applied for missing values) 23341 * @returns The HTML template with all placeholders replaced 23342 */ 23343export function resolvePlaceholdersHtml( 23344 template: string, 23345 values: Partial<MessagePlaceholderValues> 23346): string { 23347 const resolved = { ...DEFAULT_PLACEHOLDER_VALUES, ...values }; 23348 23349 // For HTML, wrap link in anchor tag 23350 const linkHtml = resolved.link 23351 ? \`<a href="\${resolved.link}">\${resolved.link}</a>\` 23352 : ''; 23353 23354 // productHero is stored as a raw image URL (like link is a raw URL) â wrap as 23355 // an <img> for email. productReview/productAward are passed through as the 23356 // resolver-supplied display strings. 23357 const productHeroHtml = resolved.productHero 23358 ? \`<img src="\${resolved.productHero}" alt="" style="max-width:100%;height:auto;display:block;" />\` 23359 : ''; 23360 23361 return template 23362 .replace(/\\{\\{name\\}\\}/g, resolved.name) 23363 .replace(/\\{\\{firstName\\}\\}/g, resolved.firstName || resolved.name) 23364 // welcome email keys (Sep 2026) â the subject line is resolved here, the body by toolcall-send-email 23365 .replace(/\\{\\{orderNumber\\}\\}/g, resolved.orderNumber) 23366 .replace(/\\{\\{orderLink\\}\\}/g, resolved.orderLink) 23367 .replace(/\\{\\{deliveryDate\\}\\}/g, resolved.deliveryDate) 23368 .replace(/\\{\\{deliveryBlock\\}\\}/g, resolved.deliveryBlock) 23369 .replace(/\\{\\{productBlock\\}\\}/g, resolved.productBlock) 23370 .replace(/\\{\\{companionBlock\\}\\}/g, resolved.companionBlock) 23371 .replace(/\\{\\{link\\}\\}/g, linkHtml) 23372 .replace(/\\{\\{shortLink\\}\\}/g, linkHtml) 23373 .replace(/\\{\\{productName\\}\\}/g, resolved.productName) 23374 .replace(/\\{\\{savings\\}\\}/g, resolved.savings) 23375 .replace(/\\{\\{discountPercent\\}\\}/g, resolved.discountPercent) 23376 .replace(/\\{\\{discountCode\\}\\}/g, resolved.discountCode) 23377 .replace(/\\{\\{freeDiscountCode\\}\\}/g, resolved.freeDiscountCode) 23378 .replace(/\\{\\{freeItems\\}\\}/g, resolved.freeItems) 23379 .replace(/\\{\\{cartTotal\\}\\}/g, resolved.cartTotal) 23380 .replace(/\\{\\{discountDraftInvoiceTotal\\}\\}/g, resolved.discountDraftInvoiceTotal) 23381 .replace(/\\{\\{video\\}\\}/g, resolved.video) 23382 .replace(/\\{\\{agent\\}\\}/g, resolved.agent) 23383 .replace(/\\{\\{savingsLine\\}\\}/g, resolved.savingsLine) 23384 .replace(/\\{\\{freeItemsLine\\}\\}/g, resolved.freeItemsLine) 23385 .replace(/\\{\\{productHero\\}\\}/g, productHeroHtml) 23386 .replace(/\\{\\{productReview\\}\\}/g, resolved.productReview) 23387 .replace(/\\{\\{productAward\\}\\}/g, resolved.productAward) 23388 // Showroom / appointment lifecycle (showroom.* workflows) 23389 .replace(/\\{\\{appointmentDate\\}\\}/g, resolved.appointmentDate) 23390 .replace(/\\{\\{appointmentTime\\}\\}/g, resolved.appointmentTime) 23391 .replace(/\\{\\{locationName\\}\\}/g, resolved.locationName) 23392 .replace(/\\{\\{locationAddress\\}\\}/g, resolved.locationAddress) 23393 .replace(/\\{\\{mapsUrl\\}\\}/g, resolved.mapsUrl) 23394 .replace(/\\{\\{delayMinutes\\}\\}/g, resolved.delayMinutes) 23395 .replace(/\\{\\{locationLine\\}\\}/g, resolved.locationLine) 23396 .replace(/\\{\\{resellerContactName\\}\\}/g, resolved.resellerContactName); 23397} 23398 23399/** 23400 * Get a single default placeholder value by key 23401 * 23402 * @param key - The placeholder key 23403 * @returns The default value for that placeholder 23404 * 23405 * @example 23406 * \`\`\`typescript 23407 * const defaultName = getDefaultValue('name'); // 'there' 23408 * const customerName = actualName || getDefaultValue('name'); 23409 * \`\`\` 23410 */ 23411export function getDefaultValue<K extends keyof MessagePlaceholderValues>( 23412 key: K 23413): MessagePlaceholderValues[K] { 23414 return DEFAULT_PLACEHOLDER_VALUES[key]; 23415} 23416 23417/**
23418 * Build placeholder values object from execution context data 23419 * 23420 * Extracts values from common Lambda execution context patterns 23421 * and applies defaults for missing values. 23422 * 23423 * @param context - Object containing lead, checkout, campaign, and discount data 23424 * @returns Complete MessagePlaceholderValues with all values resolved 23425 */ 23426export function buildPlaceholderValues(context: { 23427 customerName?: string; 23428 /** Shopify order name (welcome SMS/email \`#INV\`), e.g. \`#31363\` */ 23429 orderNumber?: string; 23430 /** Short link to the order status page (welcome SMS \`{{orderLink}}\`) */ 23431 orderLink?: string; 23432 link?: string; 23433 productName?: string; 23434 totalSavings?: number | null; 23435 discountPercent?: number | null; 23436 discountCode?: string; 23437 freeDiscountCode?: string; 23438 freeItemsAdded?: string[]; 23439 cartTotal?: number | null; 23440 discountDraftInvoiceTotal?: number | null; 23441 video?: string; 23442 agentName?: string; 23443 storeDomain?: string; 23444 productImageUrl?: string; 23445 variantTitle?: string; 23446 subtotal?: number | null; 23447 total?: number | null; 23448 originalTotal?: number | null; 23449 currency?: string; 23450 logoUrl?: string; 23451 /** Target product's hero image URL (from media-assets resolveCreativeBundle). */ 23452 productHero?: string; 23453 /** Channel-ready testimonial string (plain for SMS, display text for email). */ 23454 productReview?: string; 23455 /** Channel-ready award string (name for SMS, display text/img markup for email). */ 23456 productAward?: string; 23457 // Showroom / appointment lifecycle â from the triggering showroom.* event. 23458 appointmentDate?: string; 23459 appointmentTime?: string; 23460 locationName?: string; 23461 locationAddress?: string; 23462 mapsUrl?: string; 23463 delayMinutes?: string; 23464 /** Mode aware "where" sentence for reminder templates (own leading space). */ 23465 locationLine?: string; 23466 /** Reseller showroom contact person ("Ask for Brett") â reseller_appointment.* events. */ 23467 resellerContactName?: string; 23468}): MessagePlaceholderValues { 23469 // Format currency values 23470 const formatCurrency = (amount: number | null | undefined): string => { 23471 if (amount === null || amount === undefined || amount === 0) { 23472 return '$0'; 23473 } 23474 return amount.toLocaleString('en-AU', { 23475 style: 'currency', 23476 currency: 'AUD', 23477 minimumFractionDigits: 0, 23478 maximumFractionDigits: 0, 23479 }); 23480 }; 23481 23482 const formatCurrencyWithCents = (amount: number | null | undefined): string => { 23483 if (amount === null || amount === undefined) return '$0.00'; 23484 return amount.toLocaleString('en-AU', { 23485 style: 'currency', 23486 currency: context.currency || 'AUD', 23487 minimumFractionDigits: 2, 23488 maximumFractionDigits: 2, 23489 }); 23490 }; 23491 23492 // Derive phrase placeholders. 23493 // savingsLine: suppress the percent when < 20% (low percents undersell big $ 23494 // figures â "$2,520 off (14% saving)" reads weaker than "$2,520 off"). 23495 // Threshold is a deliberate copywriting choice. 23496 const SMALL_PERCENT_THRESHOLD = 20; 23497 const savingsLine = 23498 context.totalSavings != null && context.totalSavings > 0 23499 ? context.discountPercent != null && context.discountPercent >= SMALL_PERCENT_THRESHOLD 23500 ? \`We've taken \${formatCurrency(context.totalSavings)} off (\${context.discountPercent}% saving). \` 23501 : \`We've taken \${formatCurrency(context.totalSavings)} off. \` 23502 : ''; 23503 const freeItemsLine = 23504 context.freeItemsAdded && context.freeItemsAdded.length > 0 23505 ? \`Free \${formatFreeItemsList(context.freeItemsAdded)} included. \` 23506 : ''; 23507 23508 return { 23509 name: context.customerName || DEFAULT_PLACEHOLDER_VALUES.name, 23510 // Welcome email keys â supplied by resolve_welcome_content on context.welcome and 23511 // spread over these defaults by send_email; here they only carry the defaults. 23512 firstName: context.customerName || DEFAULT_PLACEHOLDER_VALUES.firstName, 23513 orderNumber: context.orderNumber || DEFAULT_PLACEHOLDER_VALUES.orderNumber, 23514 orderLink: context.orderLink || DEFAULT_PLACEHOLDER_VALUES.orderLink, 23515 deliveryDate: DEFAULT_PLACEHOLDER_VALUES.deliveryDate, 23516 deliveryBlock: DEFAULT_PLACEHOLDER_VALUES.deliveryBlock, 23517 productBlock: DEFAULT_PLACEHOLDER_VALUES.productBlock, 23518 companionBlock: DEFAULT_PLACEHOLDER_VALUES.companionBlock,
23519 link: context.link || DEFAULT_PLACEHOLDER_VALUES.link, 23520 productName: context.productName || DEFAULT_PLACEHOLDER_VALUES.productName, 23521 savings: formatCurrency(context.totalSavings), 23522 discountPercent: context.discountPercent != null ? String(context.discountPercent) : DEFAULT_PLACEHOLDER_VALUES.discountPercent, 23523 discountCode: context.discountCode || DEFAULT_PLACEHOLDER_VALUES.discountCode, 23524 freeDiscountCode: context.freeDiscountCode || DEFAULT_PLACEHOLDER_VALUES.freeDiscountCode, 23525 freeItems: context.freeItemsAdded?.join(', ') || DEFAULT_PLACEHOLDER_VALUES.freeItems, 23526 cartTotal: formatCurrency(context.cartTotal), 23527 discountDraftInvoiceTotal: formatCurrency(context.discountDraftInvoiceTotal), 23528 video: context.video || DEFAULT_PLACEHOLDER_VALUES.video, 23529 agent: context.agentName || DEFAULT_PLACEHOLDER_VALUES.agent, 23530 storeDomain: context.storeDomain || DEFAULT_PLACEHOLDER_VALUES.storeDomain, 23531 productImageUrl: context.productImageUrl || DEFAULT_PLACEHOLDER_VALUES.productImageUrl, 23532 variantTitle: context.variantTitle || DEFAULT_PLACEHOLDER_VALUES.variantTitle, 23533 subtotal: context.subtotal != null ? formatCurrencyWithCents(context.subtotal) : DEFAULT_PLACEHOLDER_VALUES.subtotal, 23534 total: context.total != null ? formatCurrencyWithCents(context.total) : DEFAULT_PLACEHOLDER_VALUES.total, 23535 originalTotal: context.originalTotal != null ? formatCurrencyWithCents(context.originalTotal) : DEFAULT_PLACEHOLDER_VALUES.originalTotal, 23536 currency: context.currency || DEFAULT_PLACEHOLDER_VALUES.currency, 23537 logoUrl: context.logoUrl || DEFAULT_PLACEHOLDER_VALUES.logoUrl, 23538 savingsLine, 23539 freeItemsLine, 23540 productHero: context.productHero || DEFAULT_PLACEHOLDER_VALUES.productHero, 23541 productReview: context.productReview || DEFAULT_PLACEHOLDER_VALUES.productReview, 23542 productAward: context.productAward || DEFAULT_PLACEHOLDER_VALUES.productAward, 23543 appointmentDate: context.appointmentDate || DEFAULT_PLACEHOLDER_VALUES.appointmentDate, 23544 appointmentTime: context.appointmentTime || DEFAULT_PLACEHOLDER_VALUES.appointmentTime, 23545 locationName: context.locationName || DEFAULT_PLACEHOLDER_VALUES.locationName, 23546 locationAddress: context.locationAddress || DEFAULT_PLACEHOLDER_VALUES.locationAddress, 23547 mapsUrl: context.mapsUrl || DEFAULT_PLACEHOLDER_VALUES.mapsUrl, 23548 delayMinutes: context.delayMinutes || DEFAULT_PLACEHOLDER_VALUES.delayMinutes, 23549 locationLine: context.locationLine || DEFAULT_PLACEHOLDER_VALUES.locationLine, 23550 resellerContactName: context.resellerContactName || DEFAULT_PLACEHOLDER_VALUES.resellerContactName, 23551 }; 23552} 23553 23554`,bn=`/** 23555 * Global Pricing Configuration Types 23556 * 23557 * Pricing is stored in DynamoDB \`pricing\` table with one row per category. 23558 * All values are in cents (or cents per 1K tokens for AI models). 23559 */ 23560 23561/** 23562 * Token pricing for AI models (cents per 1K tokens) 23563 */ 23564export interface TokenPricing { 23565 input: number; 23566 output: number; 23567} 23568 23569/** 23570 * Directional pricing (inbound/outbound) 23571 */ 23572export interface DirectionalPricing { 23573 outbound: number; 23574 inbound: number; 23575} 23576 23577/** 23578 * Phone number pricing by country and type 23579 */ 23580export interface PhoneNumberPricing { 23581 AU_MOBILE: number; 23582 AU_LOCAL: number; 23583 AU_TOLL_FREE: number; 23584 AU_NATIONAL: number; 23585 US_LOCAL: number; 23586 US_MOBILE: number; 23587 US_TOLL_FREE: number; 23588} 23589 23590/** 23591 * Compute pricing by workload type 23592 */ 23593export interface ComputePricingConfig { 23594 fargateRecordingFetch: number; 23595 ec2DiarizeTranscribe: number; 23596 fargateVoiceAgent: number; 23597} 23598 23599/** 23600 * Global pricing configuration assembled from DynamoDB pricing table. 23601 * Each category is stored as a separate DynamoDB item for easy viewing and atomic updates. 23602 * 23603 * PRICING MODEL: 23604 * - Root level fields (twilioSms, moonshot, etc.) = OUR COSTS (what we pay providers) 23605 * - customerPrices section = CUSTOMER PRICES (what customers pay us) 23606 * 23607 * Customer prices are REQUIRED for billing. If missing, an error will be thrown. 23608 */ 23609export interface GlobalPricing { 23610 /** Twilio SMS cost (cents per segment) - WHAT WE PAY */ 23611 twilioSms: DirectionalPricing; 23612 23613 /** Twilio Voice cost (cents per minute) - WHAT WE PAY */ 23614 twilioVoice: DirectionalPricing; 23615 23616 /** Twilio Phone Number monthly fees (cents per month) - WHAT WE PAY */ 23617 twilioPhoneNumbers: PhoneNumberPricing; 23618 23619 /** Compute cost (cents per minute of audio) - WHAT WE PAY */ 23620 compute: ComputePricingConfig; 23621 23622 /** OpenAI model cost (cents per 1K tokens) - WHAT WE PAY */ 23623 openai: Record<string, TokenPricing>; 23624 23625 /** MoonShot/Kimi model cost (cents per 1K tokens) - WHAT WE PAY */ 23626 moonshot: Record<string, TokenPricing>; 23627 23628 /** Tavily web search cost - WHAT WE PAY */ 23629 tavily: { 23630 perSearch: number; // cents per search 23631 }; 23632 23633 /** Email (SES) cost - WHAT WE PAY */ 23634 email: { 23635 perEmail: number; // cents per email 23636 }; 23637 23638 /** 23639 * CUSTOMER PRICES - Fixed prices charged to customers. 23640 * These are REQUIRED for billing calculations. 23641 */ 23642 customerPrices?: { 23643 twilioSms?: DirectionalPricing; 23644 twilioVoice?: DirectionalPricing; 23645 twilioPhoneNumbers?: PhoneNumberPricing; 23646 compute?: ComputePricingConfig; 23647 openai?: Record<string, TokenPricing>; 23648 moonshot?: Record<string, TokenPricing>; 23649 tavily?: { perSearch: number }; 23650 email?: { perEmail: number }; 23651 }; 23652} 23653 23654/**
23655 * Individual pricing category types (matching DynamoDB items) 23656 */ 23657export interface TwilioSmsPricing { 23658 pricingId: 'twilioSms'; 23659 outbound: number; 23660 inbound: number; 23661} 23662 23663export interface TwilioVoicePricing { 23664 pricingId: 'twilioVoice'; 23665 outbound: number; 23666 inbound: number; 23667} 23668 23669export interface ComputePricing { 23670 pricingId: 'compute'; 23671 fargateRecordingFetch: number; 23672 ec2DiarizeTranscribe: number; 23673 fargateVoiceAgent: number; 23674} 23675 23676export interface OpenAIPricing { 23677 pricingId: 'openai'; 23678 [model: string]: TokenPricing | string; // string for pricingId 23679} 23680 23681export interface MoonshotPricing { 23682 pricingId: 'moonshot'; 23683 [model: string]: TokenPricing | string; // string for pricingId 23684} 23685 23686export interface TavilyPricing { 23687 pricingId: 'tavily'; 23688 perSearch: number; 23689} 23690 23691export interface EmailPricing { 23692 pricingId: 'email'; 23693 perEmail: number; 23694} 23695 23696export interface TwilioPhoneNumberPricing { 23697 pricingId: 'twilioPhoneNumbers'; 23698 // Per-type pricing (cents per month) 23699 AU_MOBILE: number; // AU mobile numbers (+614xx) 23700 AU_LOCAL: number; // AU local/landline numbers 23701 AU_TOLL_FREE: number; // AU 1800 toll-free numbers 23702 AU_NATIONAL: number; // AU 1300 national rate numbers 23703 US_LOCAL: number; // US local numbers 23704 US_MOBILE: number; // US mobile numbers 23705 US_TOLL_FREE: number; // US 800/888/etc toll-free numbers 23706 // Legacy fields for backward compatibility 23707 AU?: number; 23708 US?: number; 23709} 23710 23711// Customer price items (what we charge customers) 23712// These use "Price" suffix to distinguish from cost items 23713 23714export interface TwilioSmsPricingPrice { 23715 pricingId: 'twilioSmsPrice'; 23716 outbound: number; 23717 inbound: number; 23718} 23719 23720export interface TwilioVoicePricingPrice { 23721 pricingId: 'twilioVoicePrice'; 23722 outbound: number; 23723 inbound: number; 23724} 23725 23726export interface TwilioPhoneNumberPricingPrice { 23727 pricingId: 'twilioPhoneNumbersPrice'; 23728 AU_MOBILE: number; 23729 AU_LOCAL: number; 23730 AU_TOLL_FREE: number; 23731 AU_NATIONAL: number; 23732 US_LOCAL: number; 23733 US_MOBILE: number; 23734 US_TOLL_FREE: number; 23735} 23736 23737export interface ComputePricingPrice { 23738 pricingId: 'computePrice'; 23739 fargateRecordingFetch: number; 23740 ec2DiarizeTranscribe: number; 23741 fargateVoiceAgent: number; 23742} 23743 23744export interface OpenAIPricingPrice { 23745 pricingId: 'openaiPrice'; 23746 [model: string]: TokenPricing | string; // string for pricingId 23747} 23748 23749export interface MoonshotPricingPrice { 23750 pricingId: 'moonshotPrice'; 23751 [model: string]: TokenPricing | string; // string for pricingId 23752} 23753 23754export interface TavilyPricingPrice { 23755 pricingId: 'tavilyPrice'; 23756 perSearch: number; 23757} 23758 23759export interface EmailPricingPrice { 23760 pricingId: 'emailPrice'; 23761 perEmail: number; 23762} 23763 23764/** Union of all pricing item types (costs and customer prices) */ 23765export type PricingItem = 23766 // Cost items (what we pay)
23767 | TwilioSmsPricing 23768 | TwilioVoicePricing 23769 | TwilioPhoneNumberPricing 23770 | ComputePricing 23771 | OpenAIPricing 23772 | MoonshotPricing 23773 | TavilyPricing 23774 | EmailPricing 23775 // Customer price items (what we charge) 23776 | TwilioSmsPricingPrice 23777 | TwilioVoicePricingPrice 23778 | TwilioPhoneNumberPricingPrice 23779 | ComputePricingPrice 23780 | OpenAIPricingPrice 23781 | MoonshotPricingPrice 23782 | TavilyPricingPrice 23783 | EmailPricingPrice; 23784`,vn=`/** 23785 * Product pricing bands, floor units and the catalog snapshot the AI sales 23786 * workload reads (piece B of the sales AI programme, 20 Sep 2026). 23787 * 23788 * Source of truth for the editable fields is three Shopify VARIANT metafields, 23789 * written by the ShopDash Inventory write-through (dataApi 23790 * \`/data/inventory/pricing\` and \`/data/inventory/floor-units\`) and mirrored 23791 * onto the \`ProductCatalog\` row (PK \`store\` = \`SHOP#<shop>\`, SK \`variantId\`) 23792 * exactly as the welcome-name fields are, so the engine never calls Shopify 23793 * on the send path: 23794 * 23795 * custom.rrp number_decimal â ProductCatalog.rrp 23796 * custom.floor_price number_decimal â ProductCatalog.floorPrice 23797 * custom.floor_units json â ProductCatalog.floorUnits 23798 * 23799 * The WEBSITE price is Shopify's own \`variant.price\`, already mirrored as 23800 * \`ProductCatalog.price\` by the product sync; there is no metafield for it. 23801 * \`compareAtPrice\` stays mirrored as-is but is NOT the RRP (measured 20 Sep 23802 * 2026: populated on under half the MMC/MHC rows, often equal to price, and 23803 * below price on some rows). 23804 * 23805 * â \`floorPrice\` and a floor unit's price are the discount limit staff may go 23806 * to. They are exposed to guards through provider \`facts\` only and are never 23807 * rendered into a model prompt. 23808 */ 23809 23810/** 23811 * Sales facts per PRODUCT (plan WP4, 21 Sep 2026): what a rep needs to answer "would it suit 23812 * my 160 kg husband", "how tall can you be", "what warranty", "is that an older model" without 23813 * inventing. Source of truth = the Shopify PRODUCT metafield \`custom.sales_facts\` (json) for 23814 * Shopify tenants, written by the ShopDash Inventory panel (same roles as RRP), mirrored onto 23815 * every variant row of the product by the product sync; written directly on the rows for 23816 * catalog-only tenants. Defaults were mined from two months of call transcripts 23817 * (\`aws-scripts/product-sales-facts-2026-09-21/seed-defaults.csv\`, citations per figure). 23818 */ 23819export interface ProductSalesFacts { 23820 /** Warranty weight limit for the user, kg. */ 23821 weightLimitKg?: number; 23822 /** Usable user height range, cm. */ 23823 userHeightCm?: { min?: number; max?: number }; 23824 warrantyYears?: number; 23825 /** Plain words a rep would say about the warranty ("lifetime upgrade available through the team"). */ 23826 warrantyNotes?: string; 23827 /** current = on the current range; older = still sold but superseded; runout = last stock. */ 23828 generation?: 'current' | 'older' | 'runout'; 23829 /** SKU (or product title) of the model the team recommends instead of an older one. */ 23830 successorSku?: string; 23831 successorTitle?: string; 23832 dimensions?: { uprightCm?: { l?: number; w?: number; h?: number }; reclinedCm?: { l?: number; w?: number; h?: number }; chairWeightKg?: number; wallClearanceCm?: number }; 23833 /** Up to 5 short selling points, ⤠80 chars each. */ 23834 keyPoints?: string[]; 23835 /** Where the current values came from: the seed or an operator. */ 23836 source?: 'seed' | 'operator'; 23837 /** Citations for seeded figures (callId#turnIndex â¦), shown in the panel. */ 23838 evidence?: string; 23839 updatedAt?: string; 23840 updatedBy?: string; 23841} 23842/** 23843 * One line the AI may add to a draft order alongside the product: the tenant's own 23844 * warranty / delivery / concierge variant, at the price the agents actually charge. 23845 */ 23846export interface OfferComponent { 23847 /** The Shopify variant id of the component product. */ 23848 variantId: string; 23849 /** Its title, for the draft-order line and the SMS ("Kerbside Delivery"). */ 23850 title: string; 23851 /** What the agents charge for it. 0 = included. */ 23852 price: number; 23853 /** Add it to the draft by default (the seed sets this from the attach rate). */ 23854 attach: boolean; 23855 /** Share of this product's orders that carried it, 0..1 â evidence for \`attach\`. */ 23856 attachRate?: number; 23857} 23858 23859/** 23860 * Whether the AI may sell this product by text, and what rides along when it does 23861 * (plan \`sales-ai-mhc-ai-sale-mode.md\`, Phase 1). Product level, mirrored onto every 23862 * variant row like \`salesFacts\`; seeded from real orders by 23863 * \`aws-scripts-process/seed-offer-config-2026-09/seed.mjs\` and edited on the 23864 * Inventory tab. 23865 * 23866 * â There is no price field here on purpose. The first prod seed (MHC, 90 days, 104 23867 * paid orders) found 33 of 34 variants sold at exactly their catalog price, so the 23868 * price the AI quotes is the catalog price the provider already renders. The floor 23869 * stays \`ProductCatalogPricing.floorPrice\` and never reaches a prompt. 23870 */ 23871/** 23872 * What a package built around this product normally SELLS for, and what that saves. 23873 * 23874 * â THIS IS NOT THE LINE PRICE, AND THAT DISTINCTION IS THE WHOLE POINT. The plan's 23875 * finding A1 concluded "there is no discount" because it measured line items, and line 23876 * items never move: a Physio+ line reads $7,855 on every order ever written. The 23877 * discount is applied to the DRAFT ORDER, so it only exists in the order total. Over 21 23878 * days, 82% of MMC orders and 72% of MHC orders were paid BELOW the sum of their own 23879 * lines, averaging $4,239 off. 23880 * 23881 * Measured over 180 days of PAID single-product packages (\`seed-offer-config\` reading 23882 * order totals, not line prices): 23883 * 23884 * Physio+ 557 packages listed $9,440 paid $5,477 (42% off) 23885 * Remedial Deluxe+ 110 listed $14,380 paid $8,477 (41%) 23886 * Therapeutic Dual-Pro 32 listed $20,580 paid $10,500 (49%) 23887 * Restore+ 20 listed $8,995 paid $3,877 (57%) 23888 * accessories listed = paid (0%) 23889 * 23890 * So big-ticket items carry a large, consistent package discount and accessories carry 23891 * none. \`packagePrice\` is the MEDIAN of what was actually paid, never the floor: the 23892 * floor is the lowest staff may agree to (Physio+ $5,000, the p25) and quoting it by 23893 * default would give away the whole negotiating range on the first message. 23894 */ 23895export interface ProductPackagePrice { 23896 /** Median total actually paid for a package built on this product. */ 23897 price: number; 23898 /** Median of the line-item sum for those same packages: the honest "was". */ 23899 listed: number; 23900 /** Paid packages behind the figure. Below ~5 this is not a price, it is an anecdote. */ 23901 orders: number; 23902 /** The spread actually paid, so an operator can see the negotiating range. */ 23903 p25?: number; 23904 p75?: number; 23905} 23906 23907/** 23908 * The finance package price for one product, and the terms staff quote on it. 23909 * 23910 * â \`price\` is the MODE of paid finance orders on the product (Chris, 29 Sep 2026: "use the most common 23911 * amount not average, regardless of too few"), so \`orders\` can be 1; the Inventory panel shows the count so a 23912 * thin figure reads as thin. Deposit, term and provider are what staff actually say in texts and calls for 23913 * this product; absent = the tenant's \`aiAgent.finance\` terms. 23914 */ 23915export interface ProductFinancePrice { 23916 price: number; 23917 orders: number; 23918 source?: string; 23919 deposit?: number;
23920 termMonths?: number; 23921 provider?: string; 23922} 23923export type ProductDeliveryAreas = 'metro_only' | 'metro_and_regional'; 23924 23925export interface ProductOfferConfig { 23926 /** May the AI recommend and sell this product by text. Accessories stay false. */ 23927 offerable: boolean; 23928 /** What a package on this product normally sells for (see \`ProductPackagePrice\`). */ 23929 packagePrice?: ProductPackagePrice; 23930 /** 23931 * What finance buyers (Humm, Payright, Zip, Afterpay) actually paid for a package on this product: the MODE of paid 23932 * single-product finance orders (\`aws-scripts-process/measured-prices-2026-09\`, Chris 27 Sep 2026: usable at 10+ 23933 * orders and mode >= 25%, or 50+ orders). Absent = not enough real finance orders: the AI never quotes a finance 23934 * price for this product and a finance question goes to a person. Physio+ $5,977 (195 orders) vs outright $5,477. 23935 */ 23936 financePrice?: ProductFinancePrice; 23937 /** 23938 * May the AI offer finance on this product at all. Absent = offered when a \`financePrice\` exists (the 23939 * behaviour before 29 Sep 2026). Seeded from real paid finance orders; \`false\` means finance questions on 23940 * this product go to a person. 23941 */ 23942 financeAllowed?: boolean; 23943 /** 23944 * Where the AI may sell this product by text, checked against the LIVE Winnings calendar answer for the 23945 * customer's address (never a stored postcode list: Chris, 29 Sep 2026). \`metro_only\` (the default when 23946 * absent) sends a regional buyer to a person, which is the behaviour before this field existed. 23947 */ 23948 deliveryAreas?: ProductDeliveryAreas; 23949 /** Who last confirmed the seeded values in Inventory. Absent on a seed nobody has reviewed. */ 23950 reviewedBy?: string; 23951 reviewedAt?: string; 23952 /** 23953 * The variant the team actually sells, by anchor count. 23954 * 23955 * â Without this the AI picks the CHEAPEST variant, because that is the order 23956 * \`offerableProducts\` sorts in, and the draft then does not match a real one. Measured: 23957 * the AI built a Plunge Ice Bath draft at $347 where every real draft is $1,197, and an 23958 * EverGlow at $4,477 where the team's median package lists $9,877. A customer picking a 23959 * specific variant overrides this; it only decides the DEFAULT. 23960 */ 23961 anchorVariantId?: string; 23962 warranty?: OfferComponent; 23963 delivery?: OfferComponent; 23964 concierge?: OfferComponent; 23965 /** Operator notes, shown to the model with the product's facts. */ 23966 notes?: string; 23967 source?: 'seed' | 'operator'; 23968 /** How the seed decided (order counts), shown in the Inventory panel. */ 23969 evidence?: string; 23970 updatedAt?: string; 23971 updatedBy?: string; 23972} 23973 23974export const OFFER_CONFIG_METAFIELD = { namespace: 'custom', key: 'offer_config' } as const; 23975 23976const offerComponent = (raw: unknown): OfferComponent | undefined => { 23977 if (!raw || typeof raw !== 'object') return undefined; 23978 const o = raw as Record<string, unknown>; 23979 const variantId = typeof o.variantId === 'string' ? o.variantId.trim() : String(o.variantId ?? '').trim(); 23980 const title = typeof o.title === 'string' ? o.title.trim() : ''; 23981 if (!variantId || !title) return undefined; 23982 const price = Number(o.price); 23983 if (!Number.isFinite(price) || price < 0) return undefined; 23984 const rate = Number(o.attachRate); 23985 return { 23986 variantId, title: title.slice(0, 120), price, 23987 attach: o.attach === true, 23988 ...(Number.isFinite(rate) && rate >= 0 && rate <= 1 ? { attachRate: rate } : {}), 23989 }; 23990}; 23991 23992/** Parse a \`custom.offer_config\` value (or any untrusted object) into a clean config, or undefined. */ 23993export function parseOfferConfig(raw: unknown): ProductOfferConfig | undefined { 23994 let o: Record<string, unknown> | undefined; 23995 if (typeof raw === 'string') { try { o = JSON.parse(raw); } catch { return undefined; } } else if (raw && typeof raw === 'object') o = raw as Record<string, unknown>; 23996 if (!o) return undefined; 23997 const c: ProductOfferConfig = { offerable: o.offerable === true }; 23998 if (typeof o.anchorVariantId === 'string' && o.anchorVariantId.trim()) c.anchorVariantId = o.anchorVariantId.trim(); 23999 const pp = o.packagePrice as Record<string, unknown> | undefined; 24000 if (pp && typeof pp === 'object') { 24001 const price = Number(pp.price); const listed = Number(pp.listed); const orders = Number(pp.orders); 24002 // A SEEDED package price with no orders behind it is a guess, and a guess here becomes a 24003 // number quoted to a customer. Require the evidence or drop the field. 24004 // 24005 // â An OPERATOR's number is not a guess, it is the decision the Inventory tab exists to 24006 // record, so \`source: 'operator'\` carries its own evidence. Dropping what a staff member 24007 // typed would be the worst outcome available: they save, the figure vanishes, and nothing
24008 // says why. The panel shows the order count beside it so a thin figure still reads as thin. 24009 const operatorSet = o.source === 'operator'; 24010 if (Number.isFinite(price) && price > 0 && Number.isFinite(listed) && listed > 0 && Number.isFinite(orders) && (orders >= 5 || operatorSet)) { 24011 c.packagePrice = { 24012 price, listed, orders: Math.max(0, Math.round(orders)), 24013 ...(Number.isFinite(Number(pp.p25)) && Number(pp.p25) > 0 ? { p25: Number(pp.p25) } : {}), 24014 ...(Number.isFinite(Number(pp.p75)) && Number(pp.p75) > 0 ? { p75: Number(pp.p75) } : {}), 24015 }; 24016 } 24017 } 24018 // Measured finance price (27 Sep 2026): 10+ paid finance orders or it is not a price. â Every reader of the offer 24019 // config goes through this parser, product-sync included: a field missing here is silently stripped on the next 24020 // product webhook (the stale-Lambda trap), so a new field is added here FIRST. 24021 const fp = o.financePrice as Record<string, unknown> | undefined; 24022 if (fp && typeof fp === 'object') { 24023 const price = Number(fp.price); const orders = Number(fp.orders); 24024 // 29 Sep 2026: any real order count (was 10+); an operator's figure stands on its own. 24025 if (Number.isFinite(price) && price > 0 && Number.isFinite(orders) && (orders >= 1 || o.source === 'operator')) { 24026 const dep = Number(fp.deposit); const term = Number(fp.termMonths); 24027 c.financePrice = { 24028 price, orders: Math.max(0, Math.round(orders)), 24029 ...(typeof fp.source === 'string' && fp.source.trim() ? { source: fp.source.trim().slice(0, 200) } : {}), 24030 ...(Number.isFinite(dep) && dep >= 0 ? { deposit: dep } : {}), 24031 ...(Number.isFinite(term) && term > 0 && term <= 120 ? { termMonths: Math.round(term) } : {}), 24032 ...(typeof fp.provider === 'string' && fp.provider.trim() ? { provider: fp.provider.trim().slice(0, 40) } : {}), 24033 }; 24034 } 24035 } 24036 if (typeof o.financeAllowed === 'boolean') c.financeAllowed = o.financeAllowed; 24037 if (o.deliveryAreas === 'metro_only' || o.deliveryAreas === 'metro_and_regional') c.deliveryAreas = o.deliveryAreas; 24038 if (typeof o.reviewedBy === 'string' && o.reviewedBy.trim()) c.reviewedBy = o.reviewedBy.trim().slice(0, 120); 24039 if (typeof o.reviewedAt === 'string') c.reviewedAt = o.reviewedAt; 24040 const w = offerComponent(o.warranty); if (w) c.warranty = w; 24041 const d = offerComponent(o.delivery); if (d) c.delivery = d; 24042 const g = offerComponent(o.concierge); if (g) c.concierge = g; 24043 if (typeof o.notes === 'string' && o.notes.trim()) c.notes = o.notes.trim().slice(0, 500); 24044 if (o.source === 'seed' || o.source === 'operator') c.source = o.source; 24045 if (typeof o.evidence === 'string' && o.evidence.trim()) c.evidence = o.evidence.trim().slice(0, 500); 24046 if (typeof o.updatedAt === 'string') c.updatedAt = o.updatedAt; 24047 if (typeof o.updatedBy === 'string') c.updatedBy = o.updatedBy; 24048 return c; 24049} 24050 24051/** The components an offer for this product attaches by default, cheapest presentation first. */ 24052export function attachedComponents(c: ProductOfferConfig | undefined): OfferComponent[] { 24053 if (!c) return []; 24054 return [c.delivery, c.warranty, c.concierge].filter((x): x is OfferComponent => !!x && x.attach); 24055} 24056 24057export const SALES_FACTS_METAFIELD = { namespace: 'custom', key: 'sales_facts' } as const; 24058export const SALES_FACTS_KEY_POINTS_MAX = 5; 24059export const SALES_FACTS_KEY_POINT_CHARS = 80; 24060const num = (v: unknown): number | undefined => { const n = typeof v === 'string' ? Number(v) : typeof v === 'number' ? v : NaN; return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined; }; 24061const dims = (v: unknown) => { if (!v || typeof v !== 'object') return undefined; const o = v as Record<string, unknown>; const d = { ...(num(o.l) ? { l: num(o.l) } : {}), ...(num(o.w) ? { w: num(o.w) } : {}), ...(num(o.h) ? { h: num(o.h) } : {}) }; return Object.keys(d).length ? d : undefined; }; 24062/** Parse a \`custom.sales_facts\` value (or any untrusted object) into a clean ProductSalesFacts, or undefined when nothing usable. */ 24063export function parseSalesFacts(raw: unknown): ProductSalesFacts | undefined { 24064 let o: Record<string, unknown> | undefined;
24065 if (typeof raw === 'string') { try { o = JSON.parse(raw); } catch { return undefined; } } else if (raw && typeof raw === 'object') o = raw as Record<string, unknown>; 24066 if (!o) return undefined; 24067 const f: ProductSalesFacts = {}; 24068 const w = num(o.weightLimitKg); if (w) f.weightLimitKg = w; 24069 if (o.userHeightCm && typeof o.userHeightCm === 'object') { const h = o.userHeightCm as Record<string, unknown>; const r = { ...(num(h.min) ? { min: num(h.min) } : {}), ...(num(h.max) ? { max: num(h.max) } : {}) }; if (Object.keys(r).length) f.userHeightCm = r; } 24070 const wy = num(o.warrantyYears); if (wy) f.warrantyYears = wy; 24071 if (typeof o.warrantyNotes === 'string' && o.warrantyNotes.trim()) f.warrantyNotes = o.warrantyNotes.trim().slice(0, 200); 24072 if (o.generation === 'current' || o.generation === 'older' || o.generation === 'runout') f.generation = o.generation; 24073 if (typeof o.successorSku === 'string' && o.successorSku.trim()) f.successorSku = o.successorSku.trim().slice(0, 60); 24074 if (typeof o.successorTitle === 'string' && o.successorTitle.trim()) f.successorTitle = o.successorTitle.trim().slice(0, 80); 24075 if (o.dimensions && typeof o.dimensions === 'object') { const d = o.dimensions as Record<string, unknown>; const out = { ...(dims(d.uprightCm) ? { uprightCm: dims(d.uprightCm) } : {}), ...(dims(d.reclinedCm) ? { reclinedCm: dims(d.reclinedCm) } : {}), ...(num(d.chairWeightKg) ? { chairWeightKg: num(d.chairWeightKg) } : {}), ...(num(d.wallClearanceCm) ? { wallClearanceCm: num(d.wallClearanceCm) } : {}) }; if (Object.keys(out).length) f.dimensions = out; } 24076 if (Array.isArray(o.keyPoints)) { const k = o.keyPoints.filter((x): x is string => typeof x === 'string').map((x) => x.trim().slice(0, SALES_FACTS_KEY_POINT_CHARS)).filter(Boolean).slice(0, SALES_FACTS_KEY_POINTS_MAX); if (k.length) f.keyPoints = k; } 24077 if (o.source === 'seed' || o.source === 'operator') f.source = o.source; 24078 if (typeof o.evidence === 'string' && o.evidence.trim()) f.evidence = o.evidence.trim().slice(0, 2000); 24079 if (typeof o.updatedAt === 'string') f.updatedAt = o.updatedAt; 24080 if (typeof o.updatedBy === 'string') f.updatedBy = o.updatedBy; 24081 return Object.keys(f).some((k) => !['source', 'evidence', 'updatedAt', 'updatedBy'].includes(k)) ? f : undefined; 24082} 24083/** One line a rep would say about a product's facts (no prices; the catalog line carries those). */
24084export function renderSalesFacts(f: ProductSalesFacts | undefined): string { 24085 if (!f) return ''; 24086 const bits: string[] = []; 24087 if (f.weightLimitKg) bits.push(\`user weight limit \${f.weightLimitKg} kg (warranty void above it)\`); 24088 if (f.userHeightCm?.max || f.userHeightCm?.min) bits.push(\`users \${f.userHeightCm.min ? \`\${f.userHeightCm.min} to \` : 'up to '}\${f.userHeightCm.max ?? ''} cm\`.replace(/ to $/, '')); 24089 if (f.warrantyYears) bits.push(\`\${f.warrantyYears} year warranty\${f.warrantyNotes ? \` (\${f.warrantyNotes})\` : ''}\`); else if (f.warrantyNotes) bits.push(f.warrantyNotes); 24090 if (f.generation === 'older') bits.push(\`older model\${f.successorTitle ? \`, superseded by the \${f.successorTitle}\` : ''}\`); 24091 if (f.generation === 'runout') bits.push(\`run-out model, last stock\${f.successorTitle ? \`, replaced by the \${f.successorTitle}\` : ''}\`); 24092 if (f.dimensions?.wallClearanceCm) bits.push(\`needs \${f.dimensions.wallClearanceCm} cm from the wall\`); 24093 if (f.dimensions?.uprightCm?.l && f.dimensions.uprightCm.w) bits.push(\`\${f.dimensions.uprightCm.l} x \${f.dimensions.uprightCm.w} cm footprint\${f.dimensions.reclinedCm?.l ? \`, \${f.dimensions.reclinedCm.l} cm reclined\` : ''}\`); 24094 if (f.keyPoints?.length) bits.push(f.keyPoints.join('; ')); 24095 return bits.join('; '); 24096} 24097 24098/** Pricing fields mirrored onto a ProductCatalog variant row. Strings with two decimals, like \`price\`. */ 24099export interface ProductCatalogPricing { 24100 /** Product-level sales facts (plan WP4), mirrored onto every variant row of the product. */ 24101 salesFacts?: ProductSalesFacts; 24102 /** Product-level offer config (AI-sale plan, Phase 1), mirrored the same way. */ 24103 offerConfig?: ProductOfferConfig; 24104 /** Recommended retail price (custom.rrp). Absent = not set. */ 24105 rrp?: string; 24106 /** Lowest price staff may agree to for this variant (custom.floor_price). Never in a prompt. */ 24107 floorPrice?: string;
24108 /** Physical ex display units sold under this variant (custom.floor_units). */ 24109 floorUnits?: FloorUnit[]; 24110 /** Audit of the last Inventory write-through. */ 24111 pricingUpdatedAt?: string; 24112 pricingUpdatedBy?: string; 24113} 24114 24115export type FloorUnitStatus = 'available' | 'held' | 'sold'; 24116 24117/** 24118 * One physical unit on a showroom floor, recorded under its parent variant 24119 * (MMC's existing "⦠Floor Model" Shopify variants; no new variant per unit). 24120 * \`unitId\` is the code that goes into \`ExternalOrderProperties.saleDetails.sale.floorModel\` 24121 * on a floor sale, e.g. \`TDP24-BL-FL-10\`. 24122 */ 24123export interface FloorUnit { 24124 /** \`<SKU or handle>-FL-<n>\`, upper case, unique within the tenant. */ 24125 unitId: string; 24126 /** Where the unit stands (showroom name or reseller key). */ 24127 location?: string; 24128 /** Condition notes shown to staff ("ex display, small scuff on left arm"). */ 24129 condition?: string; 24130 /** Asking price for THIS unit. Never in a prompt. */ 24131 price?: string; 24132 status: FloorUnitStatus; 24133 /** media-assets \`assetId\`s whose \`appliesTo.floorUnitIds\` name this unit. */ 24134 photoAssetIds?: string[]; 24135 updatedAt?: string; 24136 updatedBy?: string; 24137} 24138 24139/** Regex a floor unit id must match. */ 24140export const FLOOR_UNIT_ID_RE = /^[A-Z0-9][A-Z0-9-]*-FL-\\d+$/; 24141 24142// âââ Catalog snapshot (preloaded input for the \`catalog\` context provider) âââ 24143 24144export interface CatalogSnapshotVariant { 24145 variantId: string; 24146 title: string; 24147 sku?: string; 24148 /** Website price (Shopify variant price). */ 24149 price: string; 24150 /** 24151 * Shopify \`compareAtPrice\`: the "was" price the storefront strikes through. 24152 * 24153 * â This is where the SAVING lives, and it is the whole discount story for these 24154 * tenants. Measured over 180 days of paid orders, the sold price equals the website 24155 * price on essentially every variant, so there is no discount in the usual sense. 24156 * What the customer saves is price against compare-at, and it is large: Physio+ was 24157 * $9,995 now $7,855, EverGlow was $9,995 now $4,477, Aspen was $12,995 now $5,877, 24158 * which is 20% to 69% across the products that actually sell. The agents already 24159 * frame it exactly this way ("Usually $11,580, currently down to $5,477", 47 leads). 24160 */ 24161 compareAtPrice?: string; 24162 rrp?: string; 24163 /** Present for guards only; the provider never renders it. */ 24164 floorPrice?: string; 24165 available: boolean; 24166 floorUnits?: FloorUnit[]; 24167} 24168 24169export interface CatalogSnapshotProduct { 24170 productId: string; 24171 title: string; 24172 handle?: string; 24173 /** 1 = best seller, from ProductOrders order counts. */ 24174 rank: number; 24175 orderCount: number; 24176 variants: CatalogSnapshotVariant[]; 24177 /** Product-level sales facts (plan WP4), rendered by the catalog provider. */ 24178 salesFacts?: ProductSalesFacts; 24179 /** Product-level offer config: whether the AI may sell it and what rides along. */ 24180 offerConfig?: ProductOfferConfig; 24181 /** Physical specs from the live page of a product that SOLD (sold catalog), one line. */ 24182 specs?: string; 24183} 24184 24185/** 24186 * A tenant's SOLD CATALOG (\`s3://bigm-knowledge/tenants/<tenant>/products/sold-catalog.json\`, 24187 * built by \`aws-scripts-process/sold-catalog-2026-09/build-sold-catalog.mjs\`). When present the 24188 * AI sees only these variants, from paid orders, and these specs, from the live pages of what 24189 * sold. Chris, 24 Sep 2026: "base your product knowledge off real sellable products that have 24190 * actually been sold." 24191 */ 24192export interface SoldCatalog { 24193 tenant: string; 24194 generatedAt: string; 24195 rule?: string; 24196 variants: Array<{ variantId: string; productId: string; paidLines?: number }>; 24197 /** productId -> one line of physical specs. */ 24198 specs?: Record<string, string>; 24199} 24200 24201/** 24202 * Everything the \`catalog\` provider needs, loaded ONCE per turn by the caller 24203 * (\`loadCatalogSnapshot\` in @bigm/shared) so the provider itself stays a pure 24204 * synchronous function over its input. 24205 */ 24206export interface CatalogSnapshot { 24207 tenantName: string; 24208 builtAt: string; 24209 products: CatalogSnapshotProduct[]; 24210 /** The S3 knowledge markdown for the tenant, already capped by the loader. */ 24211 knowledge?: string; 24212} 24213`,wn=`/** 24214 * Promotional Offer Types 24215 * 24216 * Types for the centralized promotional offer service that handles
24217 * draft order creation and cart permalink generation across all event types. 24218 * 24219 * This service replaces the separate workflow steps: 24220 * - evaluate_discount 24221 * - add_free_items 24222 * - create_draft_order 24223 * - generate_cart_permalink 24224 * 24225 * With a unified approach that works with any event where products are known. 24226 */ 24227 24228import type { DiscountRule, DiscountRuleReference } from './discount-rules.js'; 24229import type { ShopifyDraftOrderLineItem, FreeItemAction } from './draft-order-builder.js'; 24230import type { Lead } from './lead.js'; 24231 24232// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24233// LINE ITEM SOURCE 24234// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24235 24236/** 24237 * Source of line items for promotional offer creation 24238 */ 24239export type LineItemSourceType = 24240 | 'checkout_event' // checkout.abandoned, cart.abandoned 24241 | 'product_viewed' // product.viewed event 24242 | 'campaign_products' // campaign.cartProducts[] config 24243 | 'custom'; // Custom line items provided directly 24244 24245/** 24246 * Line item extracted from any source 24247 */ 24248export interface ResolvedLineItem { 24249 /** Shopify variant ID (numeric) */ 24250 variantId: number; 24251 /** Shopify product ID (numeric) â for product-level matching */ 24252 productId?: number; 24253 /** Quantity of this variant */ 24254 quantity: number; 24255 /** Product title (for display) */ 24256 title?: string; 24257 /** Original price per unit (in cents/smallest currency unit) */ 24258 price?: number; 24259 /** Compare-at price per unit (for discount calculations) */ 24260 compareAtPrice?: number; 24261} 24262 24263/** 24264 * Configuration for resolving line items from an event 24265 */ 24266export interface LineItemSourceConfig { 24267 /** Source type that determines resolution logic */ 24268 source: LineItemSourceType; 24269 24270 /** Event data (for checkout_event, product_viewed) */ 24271 eventData?: Record<string, unknown>; 24272 24273 /** Campaign cart products (for campaign_products source) */ 24274 campaignCartProducts?: Array<{ 24275 variantId: string | number; 24276 quantity: number; 24277 title?: string; 24278 /** 24279 * Optional Shopify product ID. When set, skips the ProductCatalog 24280 * lookup. Useful when the campaign author already knows the productId 24281 * and wants to avoid an extra DDB read at fire time. 24282 */ 24283 productId?: number; 24284 }>; 24285 24286 /** Direct line items (for custom source) */ 24287 customLineItems?: ResolvedLineItem[]; 24288 24289 /** 24290 * Tenant identifier (shop domain). Required for \`campaign_products\` source 24291 * when free-item gating via \`eligibleProductIds\` is in play â the resolver 24292 * uses it to query ProductCatalog and populate \`ResolvedLineItem.productId\` 24293 * from each variantId. Without it, resolved items have \`productId: undefined\` 24294 * and product-level free-item gating is silently disabled (legacy behaviour). 24295 */ 24296 tenantId?: string; 24297} 24298 24299// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24300// PROMOTIONAL OFFER INPUT 24301// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24302 24303/** 24304 * Input for creating a promotional offer 24305 */ 24306export interface CreatePromotionalOfferInput { 24307 /** Tenant identifier (shop domain) */ 24308 tenantId: string; 24309 24310 /** Campaign ID for attribution */ 24311 campaignId: string; 24312 24313 /** 24314 * Reference to a discount rule in the discount-rules table 24315 * Mutually exclusive with inlineDiscount 24316 */ 24317 discountRuleId?: string; 24318 24319 /** 24320 * Inline discount configuration (for backward compatibility) 24321 * Used if discountRuleId is not provided 24322 */ 24323 inlineDiscount?: DiscountRuleReference['overrides'] & { 24324 category: 'discount_code' | 'draft_order'; 24325 type: 'PERCENTAGE' | 'FIXED_AMOUNT'; 24326 value: number; 24327 code?: string; 24328 draftOrderLevel?: 'order' | 'line_item'; 24329 }; 24330 24331 /** Lead information for customer data */ 24332 lead?: Partial<Lead>; 24333 24334 /** Lead ID for attribution */ 24335 leadId?: string; 24336 24337 /** Line item source configuration */ 24338 lineItemSource: LineItemSourceConfig; 24339 24340 /** 24341 * Discount calculation mode 24342 * - 'best': Use MAX(existing discount, configured discount) 24343 * - 'config': Always use configured discount value 24344 * @default 'best' 24345 */ 24346 discountMode?: 'best' | 'config'; 24347 24348 /** 24349 * Dry run mode - returns what would be created without API calls 24350 * @default false 24351 */ 24352 dryRun?: boolean; 24353 24354 /** 24355 * Agent attribution for draft order metafields. 24356 * When provided, sets agent metafields on the created draft order 24357 * so they're visible in Shopify Admin. 24358 */ 24359 agentAttribution?: { 24360 /** Agent IDs (MaxContact UserIDs) */ 24361 agentIds: string[]; 24362 }; 24363 24364 /** 24365 * Automated-source identity for the metafield stamp when no human agent 24366 * is yet on record. The value is prepended to \`custom.sales_agents\` with a 24367 * kind-prefix convention: \`workflow:<slug>\`, \`ai:<toolName>\`, \`system:<name>\`. 24368 * Pair with \`automatedSourceName\` for the human-readable label. When a 24369 * human confirm-saves the resulting order later, their MaxContact IDs 24370 * append to the list. 24371 */ 24372 automatedSource?: string; 24373 /** Human-readable label paired with \`automatedSource\` (e.g. workflow \`name\`). */ 24374 automatedSourceName?: string; 24375 24376 /** 24377 * Discount codes already applied in the abandoned checkout. 24378 * Used to avoid double-applying a discount code on the draft order. 24379 */ 24380 checkoutDiscountCodes?: string[]; 24381 24382 /** 24383 * Checkout price totals for computing effective line item prices. 24384 * Used to mirror checkout pricing on draft orders (never higher than cart). 24385 */ 24386 checkoutTotals?: { 24387 /** Final checkout total (after all discounts) */ 24388 totalPrice: number; 24389 /** Sum of line item prices before checkout-level discounts */ 24390 totalLineItemsPrice: number; 24391 }; 24392 24393 /** 24394 * Raw checkout event data for extracting customer/address details. 24395 * Used to pre-populate draft orders with billing/shipping addresses 24396 * and Shopify customer ID from the abandoned checkout. 24397 */ 24398 checkoutEventData?: Record<string, unknown>; 24399} 24400 24401// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24402// PROMOTIONAL OFFER RESULT 24403// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24404 24405/** 24406 * Type of promotional offer created 24407 */ 24408export type PromotionalOfferType = 'draft_order' | 'cart_permalink'; 24409 24410/** 24411 * Draft order details 24412 */ 24413export interface DraftOrderResult { 24414 /** Shopify draft order ID */ 24415 draftOrderId: number; 24416 /** Invoice URL for customer payment */ 24417 invoiceUrl: string; 24418 /** Admin URL to view draft order */ 24419 adminUrl?: string; 24420 /** Actual line items from Shopify draft order response */ 24421 lineItems?: Array<{ 24422 title: string; 24423 variantTitle?: string; 24424 variantId: number; 24425 quantity: number; 24426 price: string; 24427 sku?: string; 24428 }>; 24429 /** Actual total price from Shopify */ 24430 totalPrice?: string; 24431 /** Actual subtotal price from Shopify */ 24432 subtotalPrice?: string; 24433 /** Currency code */ 24434 currency?: string; 24435 /** 24436 * Raw Shopify draft-order JSON from the POST /admin/draft_orders.json response. 24437 * Used by callers that need to emit a canonical \`draft_order.created\` event via 24438 * \`buildDraftOrderEventPayload\` without re-fetching from Shopify. 24439 */ 24440 rawShopifyDraftOrder?: Record<string, unknown>; 24441} 24442 24443/** 24444 * Cart permalink details for promotional offers 24445 */ 24446export interface PromotionalOfferCartPermalink { 24447 /** Full cart permalink with discount code */ 24448 permalink: string; 24449 /** Direct checkout URL (bypasses cart page) */ 24450 checkoutUrl: string; 24451} 24452 24453/** 24454 * Free item processing result 24455 */ 24456export interface FreeItemResult { 24457 /** Variant ID of the free item */ 24458 variantId: number; 24459 /** Display title */ 24460 title: string; 24461 /** Action taken */ 24462 action: FreeItemAction; 24463 /** Whether item was added to the offer */ 24464 added: boolean; 24465} 24466 24467/** 24468 * Discount summary for display 24469 */ 24470export interface DiscountSummary { 24471 /** Total savings amount in cents */ 24472 totalSavings: number | null; 24473 /** Name of primary product (for messaging) */ 24474 primaryProduct: string; 24475 /** Effective discount percentage applied */ 24476 effectiveDiscountPercent: number; 24477 /** Number of eligible items */ 24478 eligibleItemCount: number; 24479} 24480 24481/** 24482 * Result of creating a promotional offer 24483 */ 24484export interface PromotionalOfferResult { 24485 /** Type of offer created */ 24486 offerType: PromotionalOfferType; 24487 24488 /** Draft order result (if offerType === 'draft_order') */ 24489 draftOrder?: DraftOrderResult; 24490 24491 /** Cart permalink result (if offerType === 'cart_permalink') */ 24492 cartPermalink?: PromotionalOfferCartPermalink; 24493 24494 /** Primary link for the offer (invoiceUrl or permalink) */ 24495 link: string; 24496 24497 /** Line items included in the offer */ 24498 lineItems: ShopifyDraftOrderLineItem[]; 24499 24500 /** Free items that were added */
24501 freeItemsAdded: string[]; 24502 24503 /** Detailed free item results */ 24504 freeItemResults?: FreeItemResult[]; 24505 24506 /** Discount summary for display/messaging */ 24507 discountSummary?: DiscountSummary; 24508 24509 /** The resolved discount rule that was applied */ 24510 appliedRule?: DiscountRule; 24511 24512 /** Error message if offer creation failed */ 24513 error?: string; 24514} 24515 24516// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24517// HELPERS FOR CONTEXT 24518// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 24519 24520/** 24521 * Checkout event data structure (subset of Shopify webhook) 24522 */ 24523export interface CheckoutEventData { 24524 /** Cart token */ 24525 token?: string; 24526 cartToken?: string; 24527 cart_token?: string; 24528 24529 /** Line items in the checkout */ 24530 lineItems?: Array<{ 24531 variant_id?: string | number; 24532 variantId?: string | number; 24533 product_id?: string | number; 24534 productId?: string | number; 24535 quantity?: number; 24536 title?: string; 24537 price?: string | number; 24538 line_price?: string | number; 24539 final_line_price?: string | number; 24540 original_line_price?: string | number; 24541 compare_at_price?: string | number; 24542 }>; 24543 line_items?: Array<{ 24544 variant_id?: string | number; 24545 variantId?: string | number; 24546 product_id?: string | number; 24547 productId?: string | number; 24548 quantity?: number; 24549 title?: string; 24550 price?: string | number; 24551 line_price?: string | number; 24552 final_line_price?: string | number; 24553 original_line_price?: string | number; 24554 compare_at_price?: string | number; 24555 }>; 24556 24557 /** Pricing totals */ 24558 total_discounts?: string | number; 24559 totalDiscounts?: string | number; 24560 total_line_items_price?: string | number; 24561 totalLineItemsPrice?: string | number; 24562} 24563 24564/** 24565 * Product viewed event data structure 24566 */ 24567export interface ProductViewedEventData { 24568 /** Product information */ 24569 product?: { 24570 id?: string | number; 24571 title?: string; 24572 handle?: string; 24573 variants?: Array<{ 24574 id?: string | number; 24575 title?: string; 24576 price?: string | number; 24577 compareAtPrice?: string | number; 24578 compare_at_price?: string | number; 24579 }>; 24580 }; 24581 24582 /** Selected variant (if specified) */ 24583 variant?: { 24584 id?: string | number; 24585 title?: string; 24586 price?: string | number; 24587 compareAtPrice?: string | number; 24588 compare_at_price?: string | number; 24589 }; 24590} 24591 24592/** 24593 * Type guard for checkout event data 24594 */ 24595export function isCheckoutEventData( 24596 data: unknown 24597): data is CheckoutEventData { 24598 if (!data || typeof data !== 'object') return false; 24599 const d = data as Record<string, unknown>; 24600 return !!( 24601 d.lineItems || 24602 d.line_items || 24603 d.token || 24604 d.cartToken || 24605 d.cart_token 24606 ); 24607} 24608 24609/** 24610 * Type guard for product viewed event data 24611 */ 24612export function isProductViewedEventData( 24613 data: unknown 24614): data is ProductViewedEventData { 24615 if (!data || typeof data !== 'object') return false; 24616 const d = data as Record<string, unknown>; 24617 return !!(d.product || d.variant); 24618} 24619`,Sn=`/** 24620 * Report-aggregates row types â the canonical shape of a row in the 24621 * \`report-aggregates\` DynamoDB table (the daily closed-day store behind the 24622 * /dashboard/change-log timeline columns: Max, Missed Calls, Sales, 24623 * Cancellations, Refunds, New Leads, Shopzen). 24624 * 24625 * SINGLE SOURCE OF TRUTH. Previously this shape was hand-duplicated in three 24626 * places (the report-aggregate-builder Lambda, the dataApi 24627 * \`computeLiveAggregateRow\` which returned \`any\`, and the ShopDash 24628 * \`useDailyAggregates\` composable). All three now import from here so the 24629 * stored-builder path and the live-today path can never silently diverge. 24630 * 24631 * \`splits\` is intentionally generic (\`Record<string, number>\`) â the split-key 24632 * convention is per-reportType and documented in each registry entry 24633 * (e.g. inbound/outbound for calls, fired/closed for Shopzen, both/phoneOnly/ 24634 * emailOnly for New Leads). Do NOT type split keys per report. 24635 * 24636 * For \`valueMode: 'sum'\` report types (Sales/Cancellations/Refunds) the numeric 24637 * fields (\`total\`, bucket \`count\`) hold summed dollar amounts rather than event 24638 * counts â same shape, different unit. 24639 */ 24640 24641/** One bucketed sub-row within an aggregate (e.g. one Max List, one lead source). */ 24642export interface AggregateBucket { 24643 /** Human label, resolved once at build time. */ 24644 name: string; 24645 /** Event count, OR summed value for \`valueMode: 'sum'\` report types. */ 24646 count: number; 24647 /** Per-bucket split tallies (key convention is per-reportType). */ 24648 splits: Record<string, number>; 24649 /** Up to ~20 sample leadIds for the drilldown modal. */
24650 sampleLeadIds: string[]; 24651} 24652 24653/** One persisted row in \`report-aggregates\`: a single (reportType, tenant, date). */ 24654export interface AggregateRow { 24655 /** PK â \`\${reportType}#\${tenant}#\${date}\`. */ 24656 aggregateKey: string; 24657 reportType: string; 24658 /** \`\${reportType}#\${tenant}\` (kept for query convenience). */ 24659 reportTypeAndTenant: string; 24660 tenant: string; 24661 /** YYYY-MM-DD, Melbourne local day. */ 24662 date: string; 24663 /** Day total â event count, OR summed value for \`valueMode: 'sum'\`. */ 24664 total: number; 24665 /** Top-level split tallies across all buckets. */ 24666 splits: Record<string, number>; 24667 /** bucketId â bucket. Empty for leaf (non-expandable) report types. */ 24668 buckets: Record<string, AggregateBucket>; 24669 /** Number of source events scanned (diagnostic). */ 24670 eventCount: number; 24671 /** Distinct \`leadId\`s contributing to this row's events (any bucket/split). 24672 * For Sales this is "number of Leads with a confirmed order that day". 24673 * Optional â older rows persisted before this field was added omit it. */ 24674 distinctLeads?: number; 24675 /** True once the day has closed (date < today Melbourne). Today is never final. */ 24676 isFinal: boolean; 24677 /** ISO timestamp of this computation. */ 24678 computedAt: string; 24679 /** Schema version for evolution. */ 24680 version: number; 24681} 24682 24683/** 24684 * One per-(tenant, ad, day) row in the \`meta-ads-cache\` DynamoDB table. 24685 * Mirrors the \`google-ads-cache\` shape (PK tenantId, SK resourceKey) so the 24686 * dataApi read + ChangeLogTab aggregation reuse the existing per-ad-daily 24687 * grouping logic. \`resourceKey\` = \`spend#\${date}#\${adId}\`. 24688 */ 24689export interface MetaAdsCacheRow { 24690 tenantId: string; 24691 resourceKey: string; 24692 /** YYYY-MM-DD in the Meta ad-account timezone (as reported by Meta). */ 24693 date: string; 24694 adId: string; 24695 campaignId?: string; 24696 campaignName?: string; 24697 adsetId?: string; 24698 adsetName?: string; 24699 adName?: string; 24700 thumbnailUrl?: string; 24701 /** Spend in account currency. */ 24702 spend: number; 24703 syncedAt: string; 24704 /** Unix epoch seconds for DynamoDB TTL. */ 24705 ttl?: number; 24706} 24707`,Cn=`/** 24708 * Reporting Type Definitions 24709 * 24710 * Based on Terraform schema: terraform/SHARED/create-dynamo-reporting-table 24711 * 24712 * Reporting table - Flexible table for storing aggregated reporting data 24713 * PRIMARY KEY = reportId (composite: {reportType}#{date}#{dimension1}#{dimension2}) + createdAt (range) 24714 * 24715 * GSIs: 24716 * - reportsByType: Query by reportType and date 24717 * - reportsByTenant: Query by tenant and date 24718 * - reportsByTypeAndTenant: Query by reportTypeAndTenant composite and date 24719 * - reportsByDate: Query by date and createdAt 24720 */ 24721 24722/** 24723 * Report Type Enum 24724 */ 24725export type ReportType = 24726 // Lead table report types 24727 | 'leadsByShop' 24728 | 'leadsByShopCategory' 24729 | 'leadsByCategory' 24730 // Lead table lifetime totals (date='lifetime') 24731 | 'totalLeadsByTenant' 24732 | 'totalLeadsByTenantCategory' 24733 // Events-customer table report types 24734 | 'eventsByShopType' 24735 | 'eventsByShop' 24736 | 'eventsByType' 24737 | 'eventsByLeadStatus' 24738 | 'unassignedEventsByShopType' 24739 // Event-email table report types 24740 | 'emailsByShopDomain' 24741 | 'emailsByShop' 24742 | 'emailsByShopType' 24743 | 'emailsByCampaign' 24744 | 'emailsByToDomain' 24745 | 'emailsByShopStatus' 24746 | 'emailsByCampaignStatus' 24747 // Shopify Events table report types 24748 | 'shopifyEventsByShop' 24749 | 'shopifyEventsByShopType'; 24750 24751/** 24752 * Reporting record stored in DynamoDB Reporting table 24753 */ 24754export interface ReportingRecord { 24755 /** Primary key - Composite: {reportType}#{date}#{tenant}#{dimension1} */ 24756 reportId: string; 24757 24758 /** Sort key - ISO timestamp when record was created */ 24759 createdAt: string; 24760 24761 /** ISO timestamp when record was last updated */ 24762 updatedAt: string; 24763 24764 /** Report type discriminator */ 24765 reportType: ReportType; 24766 24767 /** Composite value for GSI: {reportType}#{tenant} */ 24768 reportTypeAndTenant: string; 24769 24770 /** Tenant identifier */ 24771 tenant: string; 24772 24773 /** Report date in YYYY-MM-DD format */ 24774 date: string; 24775 24776 /** Aggregated count value */ 24777 count: number; 24778 24779 /** First dimension (eventType, underlyingDomain, etc.) - optional */ 24780 dimension1?: string; 24781 24782 /** Second dimension (if needed) - optional */ 24783 dimension2?: string; 24784 24785 /** JSON map for type-specific fields - optional */ 24786 metadata?: Record<string, unknown> | null; 24787 24788 /** Source DynamoDB table name (Event, EventEmail, Lead) */ 24789 sourceTable: string; 24790} 24791 24792/** 24793 * Input type for creating a reporting record 24794 */ 24795export interface CreateReportingRecordInput { 24796 reportType: ReportType; 24797 tenant: string; 24798 date: string; 24799 count: number; 24800 dimension1?: string; 24801 dimension2?: string; 24802 metadata?: Record<string, unknown> | null; 24803 sourceTable: string; 24804} 24805 24806/** 24807 * Generate reportId from components 24808 * Format: {reportType}#{date}#{tenant}#{dimension1}#{dimension2} 24809 */ 24810export function generateReportId( 24811 reportType: ReportType, 24812 date: string, 24813 tenant: string, 24814 dimension1?: string, 24815 dimension2?: string 24816): string { 24817 const parts = [reportType, date, tenant]; 24818 if (dimension1) { 24819 parts.push(dimension1); 24820 } 24821 if (dimension2) { 24822 parts.push(dimension2); 24823 } 24824 return parts.join('#'); 24825} 24826 24827/** 24828 * Generate reportTypeAndTenant composite value 24829 * Format: {reportType}#{tenant} 24830 */ 24831export function generateReportTypeAndTenant( 24832 reportType: ReportType, 24833 tenant: string 24834): string { 24835 return \`\${reportType}#\${tenant}\`; 24836} 24837 24838/** 24839 * @deprecated Use generateReportTypeAndTenant instead 24840 * Generate reportTypeAndShop composite value (legacy name) 24841 */ 24842export function generateReportTypeAndShop( 24843 reportType: ReportType, 24844 shopifyDomain: string 24845): string { 24846 return generateReportTypeAndTenant(reportType, shopifyDomain); 24847} 24848 24849/** 24850 * Create a ReportingRecord from input 24851 */ 24852export function createReportingRecord( 24853 input: CreateReportingRecordInput 24854): ReportingRecord { 24855 const now = new Date().toISOString(); 24856 const reportId = generateReportId( 24857 input.reportType, 24858 input.date, 24859 input.tenant, 24860 input.dimension1, 24861 input.dimension2 24862 ); 24863 const reportTypeAndTenant = generateReportTypeAndTenant( 24864 input.reportType, 24865 input.tenant 24866 ); 24867 24868 return { 24869 reportId, 24870 createdAt: now, 24871 updatedAt: now, 24872 reportType: input.reportType, 24873 reportTypeAndTenant, 24874 tenant: input.tenant, 24875 date: input.date, 24876 count: input.count,
24877 sourceTable: input.sourceTable, 24878 ...(input.dimension1 && { dimension1: input.dimension1 }), 24879 ...(input.dimension2 && { dimension2: input.dimension2 }), 24880 ...(input.metadata && { metadata: input.metadata }), 24881 }; 24882} 24883 24884`,An=`/** 24885 * Reseller / showroom registry â transcribed from Steve's "Showroom Locations & 24886 * Reseller Information" doc (email 2026-07-23, msg 19f8d2d3545e995d) plus the 24887 * standard wholesale chair price ladder from the same email. 24888 * 24889 * Code-const by operator decision: updating when Steve's doc changes is a code 24890 * edit here (BOTH copies â BigM source of truth + shopdash mirror) + rebuild. 24891 * 24892 * Invariants: 24893 * - \`name\` is the primary key everywhere (picker, custom.reseller metafield, 24894 * matchReseller). The 10 pre-registry names are frozen verbatim â historical 24895 * metafields reference them. 24896 * - \`poCode\` is data only, never a key ('SLU' is shared by Superior Lifestyle 24897 * and its partner Right Choice Mobility). 24898 * - RESELLERS in external-orders.ts stays hand-written \`as const\` (preserves 24899 * the ResellerName literal union); the parity test in __tests__/resellers 24900 * asserts it stays in sync with this registry (minus own-showroom entries). 24901 * - Array ORDER matters: matchReseller is first-match \`includes\`, so existing 24902 * entries stay first and 'Hartley Wells' stays ahead of the generic 24903 * 'betta home living' token on Footscray. 24904 */ 24905import type { DispatchingState } from './external-orders'; 24906import type { AppointmentHoursConfig } from './appointment.js'; 24907 24908export type ResellerStatus = 'active' | 'closed' | 'former' | 'restricted'; 24909 24910/** Per-showroom booking switch. Undefined means 'active' â see \`ResellerLocation.bookingStatus\`. */ 24911export type ResellerLocationBookingStatus = 'active' | 'paused'; 24912 24913/** 24914 * One named human at a showroom. 24915 * 24916 * \`ResellerLocation.contacts\` (the legacy name-only string list, still the thing 24917 * \`formatResellerDetailsSms\` puts in a nearest-showroom SMS) is DERIVED from 24918 * \`people[]\` by \`applyResellerContactDerivation\` on every write. Never edit the 24919 * two independently or they drift â that derivation is the single writer. 24920 * 24921 * \`people[]\` is OPTIONAL: registry rows seeded before this existed carry only 24922 * \`contacts[]\`, and \`resellerPeopleFromContacts\` turns those into an editable 24923 * list so the first human save is what writes \`people[]\` for that row. 24924 */ 24925export interface ResellerPerson { 24926 /** Required. A person with no name is not a contact, and the API rejects one. */ 24927 name: string; 24928 /** Free text as the showroom says it, e.g. 'Store manager', 'Owner'. */ 24929 role?: string; 24930 /** E.164 without the '+', the shape \`normalizePhone\` returns (61412345678). */ 24931 mobile?: string; 24932 /** Landline, same E.164-without-'+' shape. */ 24933 landline?: string; 24934 email?: string; 24935 /** The one to ask for first. Sorts to the front of the derived \`contacts[]\`. */ 24936 primary?: boolean; 24937 /** 24938 * Reserved for the appointment slice. NOTHING SENDS AN SMS TODAY â this flag 24939 * is stored and shown, and no code path reads it to message anyone. 24940 */ 24941 receivesAppointmentSms?: boolean; 24942} 24943 24944export interface ResellerLocation { 24945 /** Distinguishes multi-location resellers (e.g. Bad Backs Fairfield/Cammeray/Nedlands). */ 24946 label?: string; 24947 /** Per-location purchase-order code where it differs by site (BBF/BBCN/BBN). */ 24948 poCode?: string; 24949 address: string; 24950 suburb: string; 24951 state: DispatchingState; 24952 postcode: string; 24953 phone?: string; 24954 hours?: string; 24955 /** Per-location email where it differs from the reseller-level \`emails[]\`. */ 24956 email?: string; 24957 /** 24958 * Who to ask for at this showroom, names only. DERIVED from \`people[]\` on 24959 * write â see \`applyResellerContactDerivation\`. Rows that predate \`people[]\` 24960 * keep whatever was transcribed from Steve's doc until someone saves them. 24961 */ 24962 contacts?: string[]; 24963 /** The named humans behind \`contacts[]\`, with their own phone/email/role. */ 24964 people?: ResellerPerson[]; 24965 /** 24966 * Per-location booking hours, same weekday/weekend shape a tenant uses. 24967 * Reserved for the appointment slice â nothing books against it today. 24968 */ 24969 appointmentHours?: AppointmentHoursConfig; 24970 /** 24971 * Does this SHOWROOM take bookings? Undefined means 'active': being a reseller 24972 * is the agreement, so managing the data IS the enablement, and adding this 24973 * field never changes an existing row's behaviour. 24974 * 24975 * 'paused' is a HUMAN DECISION, not a data gap. Steve deferred six showrooms 24976 * on 10 Sep 2026 ("not now") in the same reply that sent mobiles for nine 24977 * others, and the two must not look alike to an operator. It lives on the 24978 * LOCATION because his answers split inside one reseller â Bad Backs Cammeray 24979 * and Nedlands yes, Fairfield not now â which \`Reseller.status\` c
24979annot say. 24980 * 24981 * â Gates BOOKINGS only. \`nearestResellers\` (the \`send_reseller_details\` SMS) 24982 * deliberately still lists a paused showroom's address: Steve deferred taking 24983 * bookings there, not being listed. 24984 */ 24985 bookingStatus?: ResellerLocationBookingStatus; 24986 /** Why bookings are paused, e.g. 'Steve: not now, 10 Sep 2026'. Operator-facing. */ 24987 bookingStatusNote?: string; 24988 /** Approximate suburb-level coordinates for nearest-showroom ranking. */ 24989 latitude?: number; 24990 longitude?: number; 24991} 24992 24993export interface Reseller { 24994 name: string; 24995 /** matchReseller tokens â lowercase, tested with \`includes\` in array order. */ 24996 match: string[]; 24997 /** Reseller-level PO code for single-location resellers. */ 24998 poCode?: string; 24999 status: ResellerStatus; 25000 /** Human note explaining a 'restricted' status. */ 25001 restrictions?: string; 25002 contactPolicy?: { doNotCallCustomer?: boolean; appointmentOnly?: boolean }; 25003 deliveryDefault?: 'store' | 'customer' | 'per_po'; 25004 deliveryNotes?: string; 25005 emails?: string[]; 25006 website?: string; 25007 onlineOnly?: boolean; 25008 /** The merchant's own showroom â excluded from the reseller picker, included in nearest-showroom routing. */ 25009 isOwnShowroom?: boolean; 25010 /** 25011 * Which tenants may offer this showroom (plan \`sales-ai-mhc-ai-sale-mode.md\`, A10). 25012 * The \`resellers\` table is shared by every tenant and had NO tenant key, so MHC's 25013 * nearest-showroom line would have named massage-chair resellers that stock no ice 25014 * bath. Absent â every tenant (the behaviour before this field, for MMC's rows). 25015 */ 25016 tenants?: string[]; 25017 /** Chair models stocked, as listed in the doc (legacy labels; kept in sync by the editor for the 25018 * PO pipeline's \`chairFamily\` filter). */ 25019 chairs?: string[]; 25020 /** Models on the floor, picked from the catalogue in the ShopDash editor (plan WP5, 22 Sep 2026). 25021 * When present it is the truth \`stockedModelsFor\` renders; \`chairs[]\` mirrors its labels. */ 25022 stockedProducts?: StockedModel[]; 25023 /** 25024 * LEGACY, no longer read by eligibility (removed 8 Sep 2026 â Chris: "of 25025 * course resellers expect appointments, they have agreed to sell our 25026 * product"). Bookability is derived from the data (active + contactable + 25027 * a person with a mobile). Kept on the type so old rows round-trip. 25028 */ 25029 appointmentsEnabled?: boolean; 25030 /** Slot length for reseller bookings, minutes (default 25031 * DEFAULT_RESELLER_DURATION_MINUTES in appointment.ts). */ 25032 appointmentDurationMinutes?: number; 25033 /** 25034 * Dev/test fixture marker (\`__TEST__\` name prefix by convention). The 25035 * \`resellers\` table is ONE table shared by dev and prod, so every prod-facing 25036 * read path (picker, nearestResellers, /messages reseller inbox) MUST filter 25037 * these out; only dev tooling with an explicit flag may see them. 25038 */ 25039 testOnly?: boolean; 25040 locations: ResellerLocation[]; 25041} 25042 25043export const RESELLER_REGISTRY: Reseller[] = [ 25044 // ââ Pre-registry entries (names frozen; order preserved for matchReseller) ââ 25045 { 25046 name: 'Superior Lifestyle', match: ['superior lifestyle'], poCode: 'SLU', status: 'active', 25047 deliveryDefault: 'per_po', 25048 deliveryNotes: 'DELIVER TO STORE by default; read the PO email instructions for reseller vs customer delivery.', 25049 emails: ['[email protected]', '[email protected]'], 25050 chairs: ['HP', 'PP', 'UC', 'RDP', 'TDP', 'TheraMax', 'Niseko Lux Ice Bath'], 25051 locations: [{ 25052 address: '84 Parramatta Road', suburb: 'Underwood', state: 'QLD', postcode: '4119', 25053 phone: '1300 825 931', hours: 'Mon to Fri 9.00 to 4.00, Sat 9.00 to 3.00, Sun by appointment 10.00 to 1.00', 25054 contacts: ['Belinda'], latitude: -27.608, longitude: 153.111, 25055 }], 25056 }, 25057 { 25058 name: 'Healthezone Pty Ltd', match: ['healthezone'], status: 'active', 25059 deliveryNotes: 'Legal entity of Bad Backs â POs arrive under this name (e.g. PO-43780). Showrooms are listed under Bad Backs.', 25060 locations: [], 25061 }, 25062 { 25063 name: 'Sleeptime', match: ['sleeptime'], poCode: 'STP', status: 'active', 25064 deliveryDefault: 'customer', 25065 deliveryNotes: 'DELIVER TO CUSTOMER; customer delivery details in the PO Order Comments section.', 25066 chairs: ['PP', 'UC', 'RDP', 'TDP', 'HP'], 25067 locations: [{ 25068 address: 'Unit 12, 16 Bernera Road', suburb: 'Prestons', state: 'NSW', postcode: '2170', 25069 phone: '1800 753 378', hours: 'Mon to Sat 9.00 to 5.00, Sun 10.00 to 4.00',
25070 contacts: ['Narelle', 'Scott'], latitude: -33.941, longitude: 150.871, 25071 }], 25072 }, 25073 { 25074 name: 'Wellness Pillars Club', match: ['wellness pillars'], poCode: 'WPCS', status: 'active', 25075 contactPolicy: { appointmentOnly: true }, 25076 chairs: ['PP', 'RDP', 'TDP', 'TheraMax'], 25077 locations: [{ 25078 address: '5/5 Powell Street', suburb: 'Homebush', state: 'NSW', postcode: '2140', 25079 phone: '0411 948 232', hours: 'Tue, Wed, Fri 11.00 to 7.00, Sat 11.00 to 5.00', 25080 contacts: ['Merit Williams'], latitude: -33.865, longitude: 151.082, 25081 }], 25082 }, 25083 { 25084 name: 'Bad Backs', match: ['bad backs'], status: 'active', 25085 chairs: ['PP', 'UC', 'RDP', 'TDP', 'TheraMax', 'HP'], 25086 locations: [ 25087 { 25088 label: 'Fairfield', poCode: 'BBF', address: '324 Darebin Rd', suburb: 'Fairfield', state: 'VIC', postcode: '3078', 25089 phone: '(03) 9020 2080', hours: 'Tue to Fri 10.00 to 5.00, Sat 10.00 to 4.00', 25090 contacts: ['Brian'], latitude: -37.779, longitude: 145.017, 25091 }, 25092 { 25093 label: 'Cammeray', poCode: 'BBCN', address: '1 Abbott Lane', suburb: 'Cammeray', state: 'NSW', postcode: '2062', 25094 phone: '(02) 8014 5696', hours: 'Tue to Fri 10.00 to 5.30, Sat 10.00 to 4.00', 25095 contacts: ['Jonathan'], latitude: -33.821, longitude: 151.214, 25096 }, 25097 { 25098 label: 'Nedlands', poCode: 'BBN', address: '1/174 Stirling Highway', suburb: 'Nedlands', state: 'WA', postcode: '6009', 25099 phone: '(08) 9386 7788', hours: 'Mon to Fri 9.00 to 5.00, Sat 9.00 to 4.00', 25100 contacts: ['Andrew', 'Alex'], latitude: -31.982, longitude: 115.807, 25101 }, 25102 ], 25103 }, 25104 { 25105 // Trading name only (Chris, 3 Oct 2026): \`name\` reaches customers ("Your closest showroom is â¦"). Legal entity: Kadnet Pty Ltd. 25106 name: 'Forty Winks', match: ['kadnet', 'forty winks', 'forty winks cannington'], poCode: 'FWC', status: 'active', 25107 deliveryDefault: 'customer', 25108 deliveryNotes: 'DELIVER TO CUSTOMER; customer delivery details in the PO Order Comments section.', 25109 chairs: ['PP', 'UC', 'RDP', 'TDP'], 25110 locations: [{ 25111 label: 'Cannington', address: '21 William St', suburb: 'Cannington', state: 'WA', postcode: '6107', 25112 phone: '(08) 9451 9331', hours: 'Mon to Fri 9.00 to 5.00 (Thu to 7.00), Sat 9.00 to 5.00, Sun 11.00 to 5.00', 25113 contacts: ['Frazer', 'Kieran'], latitude: -32.017, longitude: 115.934, 25114 }], 25115 }, 25116 { 25117 name: 'Thriftway Furniture', match: ['thriftway'], poCode: 'TFG', status: 'active', 25118 chairs: ['PP', 'RDP'], 25119 locations: [{ 25120 label: 'Geelong', address: '181-185 Bellarine Hwy', suburb: 'Newcomb', state: 'VIC', postcode: '3219', 25121 phone: '(03) 5248 8333', hours: 'Mon to Fri 9.00 to 5.30, Sat 10.00 to 5.00, Sun 10.00 to 4.00', 25122 contacts: ['Troy', 'Angela'], latitude: -38.162, longitude: 144.396, 25123 }], 25124 }, 25125 { 25126 name: 'Hartley Wells', match: ['hartley wells', 'leongatha'], poCode: 'BHLL', status: 'active', 25127 deliveryDefault: 'store', 25128 deliveryNotes: 'Trades as Betta Home Living Leongatha. DELIVER TO STORE unless otherwise stated.', 25129 chairs: ['PP', 'UC', 'RDP'], 25130 locations: [{ 25131 label: 'Leongatha', address: '2 Allison Street', suburb: 'Leongatha', state: 'VIC', postcode: '3953', 25132 phone: '(03) 5662 2930', hours: 'Mon to Fri 9.00 to 5.00, Sat 9.00 to 1.00', 25133 contacts: ['Darren'], latitude: -38.476, longitude: 145.945, 25134 }], 25135 }, 25136 { 25137 name: 'Footscray Betta Home Living', match: ['footscray betta', 'betta home living'], poCode: 'BHLF', status: 'active', 25138 chairs: ['PP Cream', 'UC Latte', 'RDP Rose Gold', 'TDP Black'], 25139 locations: [{ 25140 address: '216-226 Barkly Street', suburb: 'Footscray', state: 'VIC', postcode: '3011', 25141 phone: '(03) 9689 9511', hours: 'Mon to Fri 9.30 to 5.30, Sat 9.30 to 5.00, Sun 10.00 to 5.00', 25142 contacts: ['Kuan', 'Anna'], latitude: -37.8, longitude: 144.9, 25143 }], 25144 }, 25145 { 25146 name: 'Legends Wellness Hunter', match: ['legends wellness'], poCode: 'LBSC', status: 'active', 25147 chairs: ['PP', 'RDP'], 25148 locations: [{ 25149 address: '7 Vincent Street', suburb: 'Cessnock', state: 'NSW', postcode: '2325', 25150 phone: '(02) 4990 2272', hours: 'Mon to Fri 8.30 to 5.00, Sat 8.30 to 12.30; Thu evenings and Sat afternoons by appointment (0432 245 578)', 25151 contacts: ['Graham'], latitude: -32.832, longitude: 151.355, 25152 }], 25153 }, 25154 // ââ New entries from the 2026-07-23 doc ââ 25155 { 25156 name: 'Masseuse Massage Chairs', match: [], poCode: 'MMC', status: 'active', isOwnShowroom: true, 25157 chairs: ['PP', 'UC', 'RDP', 'TDP', 'TheraMax'], 25158 locations: [{ 25159 address: '117 York Street', suburb: 'South Melbourne', state: 'VIC', postcode: '3205', 25160 phone: '1300 054 055', hours: 'Mon to Fri 9.00 to 8.00, Sat to Sun 10.00 to 5.00', 25161 latitude: -37.8316, longitude: 144.9569, 25162 }], 25163 }, 25164 { 25165 name: 'Beds for Backs', match: ['beds for backs', 'b4b'], poCode: 'B4BN', status: 'active', 25166 deliveryNotes: 'Relocated from Nunawading to Hawthorn.', 25167 chairs: ['PP', 'UC', 'RDP', 'TDP'], 25168 locations: [{ 25169 label: 'Hawthorn', address: '408 Burwood Rd', suburb: 'Hawthorn', state: 'VIC', postcode: '3122', 25170 phone: '(03) 9878 4400', hours: 'Mon to Sun 10.00 to 5.00', 25171 contacts: ['Sean', 'Rick'], latitude: -37.8221, longitude: 145.0356, 25172 }], 25173 }, 25174 { 25175 name: 'Back To Sleep', match: ['back to sleep'], poCode: 'BTS', status: 'restricted', 25176 restrictions: 'DO NOT send customers to this showroom unless discussed with Steve first. Appointment only, no walk ins. Installer must photograph the chair after installation and send to the reseller.', 25177 contactPolicy: { appointmentOnly: true }, 25178 chairs: ['TDP'], 25179 locations: [{ 25180 address: '313-315 Whitehorse Road', suburb: 'Balwyn', state: 'VIC', postcode: '3103', 25181 phone: '1300 891 358', hours: 'Mon to Fri 9.00 to 4.00, by appointment only', 25182 contacts: ['George', 'Rob'], latitude: -37.8093, longitude: 145.0837, 25183 }], 25184 }, 25185 { 25186 name: 'Right Choice Mobility', match: ['right choice'], poCode: 'SLU', status: 'active', 25187 deliveryDefault: 'per_po', 25188 deliveryNotes: 'Partner of Superior Lifestyle (shares SLU PO code). Read the PO email instructions for reseller vs customer delivery.', 25189 chairs: ['PP', 'UC', 'RDP'], 25190 locations: [{ 25191 label: 'Craigieburn', address: '39 Interlink Drive', suburb: 'Craigieburn', state: 'VIC', postcode: '3064', 25192 phone: '0478 815 552', hours: 'Mon to Fri 10.00 to 4.00', 25193 contacts: ['Meena'], latitude: -37.6, longitude: 144.941, 25194 }], 25195 }, 25196 { 25197 name: 'Glory Box Furniture', match: ['glory box'], poCode: 'GBFM', status: 'active', 25198 contactPolicy: { doNotCallCustomer: true },
25199 deliveryDefault: 'store', 25200 deliveryNotes: 'DO NOT CALL CUSTOMER. Usually deliver to 720-722 Fifteenth St, Mildura; do not install.', 25201 chairs: ['PP Black', 'RDP Cream'], 25202 locations: [{ 25203 label: 'Mildura', address: '720-722 Fifteenth Street', suburb: 'Mildura', state: 'VIC', postcode: '3500', 25204 phone: '0447 332 611', hours: 'Mon to Fri 9.00 to 5.30, Sat 9.00 to 4.00, Sun 11.00 to 4.00', 25205 contacts: ['Antonella', 'Nathan'], latitude: -34.1889, longitude: 142.1583, 25206 }], 25207 }, 25208 { 25209 name: 'Joe Calvi Fine Furniture', match: ['joe calvi'], poCode: 'JCFFB', status: 'active', 25210 chairs: ['PP Black', 'RDP Cream'], 25211 locations: [{ 25212 label: 'Bairnsdale', address: '53 Macleod St', suburb: 'Bairnsdale', state: 'VIC', postcode: '3875', 25213 phone: '(03) 5153 0080', hours: 'Mon to Fri 9.00 to 5.00, Sat 9.00 to 1.00', 25214 contacts: ['Joe'], latitude: -37.8226, longitude: 147.611, 25215 }], 25216 }, 25217 { 25218 name: 'Central Coast Adjustable Beds', match: ['central coast adjustable'], poCode: 'CCAB', status: 'closed', 25219 chairs: ['PP', 'UC', 'RDP', 'TDP'], 25220 locations: [{ 25221 address: '1/384 The Entrance Rd', suburb: 'Long Jetty', state: 'NSW', postcode: '2261', 25222 phone: '1800 806 420', contacts: ['Craig'], latitude: -33.362, longitude: 151.479, 25223 }], 25224 }, 25225 { 25226 name: 'Le-Gees Furniture', match: ['le-gees', 'le gees', 'legees'], poCode: 'LGFB', status: 'active', 25227 chairs: ['PP'], 25228 locations: [{ 25229 label: 'Balranald', address: '94 Market St', suburb: 'Balranald', state: 'NSW', postcode: '2715', 25230 phone: '0427 200 589', hours: 'Mon to Fri 8.45 to 5.30, Sat 9.00 to 1.00', 25231 contacts: ['Leanne'], latitude: -34.636, longitude: 143.562, 25232 }], 25233 }, 25234 { 25235 name: 'Liberty Healthcare', match: ['liberty healthcare'], poCode: 'LHCV', status: 'active', 25236 chairs: ['PP', 'UC', 'RDP', 'TDP', 'TheraMax'], 25237 locations: [{ 25238 label: 'Brisbane', address: '1774 Sandgate Road', suburb: 'Virginia', state: 'QLD', postcode: '4014', 25239 phone: '(07) 3630 4400', hours: 'Mon to Fri 8.00 to 4.00', 25240 contacts: ['Truls'], latitude: -27.382, longitude: 153.062, 25241 }], 25242 }, 25243 { 25244 name: 'Easysleep', match: ['easysleep', 'easy sleep'], poCode: 'ESC', status: 'active', 25245 contactPolicy: { doNotCallCustomer: true }, 25246 deliveryDefault: 'store', 25247 deliveryNotes: 'DO NOT CALL CUSTOMER. Usually deliver to 156 Aumuller St, Bungalow QLD 4870; do not install.', 25248 emails: ['[email protected]'], 25249 chairs: ['PP', 'RDP', 'TDP'], 25250 locations: [{ 25251 label: 'Cairns', address: '1/156 Aumuller St', suburb: 'Bungalow', state: 'QLD', postcode: '4870', 25252 phone: '(07) 4028 3553', hours: 'Mon to Fri 9.00 to 4.00, Sat 9.00 to 12.00', 25253 contacts: ['Denis Murray'], latitude: -16.937, longitude: 145.753, 25254 }], 25255 }, 25256 { 25257 name: 'Massage Chairs Mandurah', match: ['massage chairs mandurah', 'mandurah'], poCode: 'MCM', status: 'active', 25258 chairs: ['PP', 'UC', 'RDP', 'TDP', 'Niseko Ice Baths', 'Plunge Ice Baths'], 25259 locations: [{ 25260 address: 'Shop 11, 4 Guava Way', suburb: 'Halls Head', state: 'WA', postcode: '6210', 25261 phone: '(08) 6559 2424', hours: 'Mon to Fri 10.00 to 4.00, Sat 10.00 to 2.00', 25262 contacts: ['Brett', 'Ken', 'Paul'], latitude: -32.545, longitude: 115.698, 25263 }], 25264 }, 25265 { 25266 name: 'The Sleep Centre', match: ['sleep centre'], poCode: 'TSC', status: 'active', 25267 deliveryDefault: 'customer', 25268 deliveryNotes: 'DELIVER TO CUSTOMER; customer delivery details in the PO Order Comments section.', 25269 chairs: ['PP', 'UC', 'RDP', 'TDP', 'HP'], 25270 locations: [{ 25271 label: 'Noarlunga', address: '2/70 Dyson Road', suburb: 'Noarlunga', state: 'SA', postcode: '5168', 25272 phone: '(08) 8326 4966', hours: 'Mon to Fri 9.30 to 5.00, Sat 9.30 to 4.00, Sun 11.00 to 4.00', 25273 contacts: ['Lynda'], latitude: -35.14, longitude: 138.495, 25274 }], 25275 }, 25276 { 25277 name: 'The Posture Care Chair Company', match: ['posture care'], poCode: 'TPCCC', status: 'former', 25278 deliveryNotes: 'No longer a showroom partner.', 25279 chairs: ['TDP', 'RDP', 'UC', 'PP'], 25280 locations: [{ 25281 address: '107 Sturt Street', suburb: 'Adelaide', state: 'SA', postcode: '5000', 25282 phone: '(08) 8361 3344', contacts: ['Bill'], latitude: -34.9285, longitude: 138.6007, 25283 }], 25284 }, 25285 { 25286 name: 'Sleepdoctor Fyshwick', match: ['sleepdoctor', 'sleep doctor'], poCode: 'SDF', status: 'active', 25287 contactPolicy: { doNotCallCustomer: true },
25288 deliveryDefault: 'store', 25289 deliveryNotes: 'DO NOT CALL CUSTOMER. Usually deliver to 96 Wollongong St, Fyshwick; do not install.', 25290 chairs: ['PP', 'RDP', 'TDP'], 25291 locations: [{ 25292 address: '96 Wollongong St', suburb: 'Fyshwick', state: 'ACT', postcode: '2609', 25293 phone: '(02) 6239 1999', hours: 'Mon to Fri 9.00 to 5.00, Sat 9.00 to 4.00, Sun 10.00 to 4.00', 25294 contacts: ['Luke', 'Dee'], latitude: -35.328, longitude: 149.165, 25295 }], 25296 }, 25297 { 25298 name: 'APE Medical', match: ['ape medical'], poCode: 'APEMED', status: 'active', onlineOnly: true, 25299 deliveryNotes: 'Online only distributor to physio and chiro clinics.', 25300 emails: ['[email protected]'], website: 'https://www.apemedical.com.au/', 25301 locations: [], 25302 }, 25303 { 25304 name: 'Fitness Warehouse', match: ['fitness warehouse'], poCode: 'FWA', status: 'active', onlineOnly: true, 25305 deliveryNotes: 'Online only distributor to gymnasiums (Adelaide).', 25306 website: 'https://fitnesswarehousecommercial.com.au/', 25307 locations: [], 25308 }, 25309 { 25310 name: 'Pulse Point Recovery', match: ['pulse point'], poCode: 'PPR', status: 'active', onlineOnly: true, 25311 deliveryNotes: 'Online only reseller.', 25312 website: 'https://pulsepointrecovery.com.au/', 25313 locations: [], 25314 }, 25315 // ââ Wholesale channels outside Steve's showroom doc (Xero ACCREC trade 25316 // customers, 2026-07-24 audit). NZ MUST stay ahead of AU: matchReseller is 25317 // first-match and AU carries the generic 'costco' token. ââ 25318 { 25319 name: 'Costco Wholesale NZ', match: ['costco wholesale new zealand', 'costco nz'], status: 'active', 25320 onlineOnly: true, deliveryDefault: 'per_po', 25321 deliveryNotes: 'Costco NZ purchase orders; instore POs ship to a Costco depot, online POs ship to the member.', 25322 chairs: ['Chiro NZ'], 25323 locations: [], 25324 }, 25325 { 25326 name: 'Costco Wholesale AU', match: ['costco'], status: 'active', 25327 onlineOnly: true, deliveryDefault: 'per_po', 25328 deliveryNotes: 'Costco AU purchase orders; instore POs ship to a Costco depot (no consumer), online POs ship to the member. Wholesale pricing differs from the standard reseller ladder (COSTCO_PRICE in salesorder-ingest).', 25329 chairs: ['Terapeutica', 'Remedial Deluxe', 'Chiro Plus', 'Chiro24', 'Revitalise', 'Health Plus', 'Pinnacle Plus'], 25330 locations: [], 25331 }, 25332]; 25333 25334/** A chair label's best-guess Shopify product mapping (MMC catalog). */ 25335export interface ChairProductRef { 25336 productTitle: string; 25337 sku: string; 25338} 25339 25340/** 25341 * Chair label â Shopify product name + representative variant SKU, best-guess 25342 * mapped from the LIVE MMC active catalog (2026-07-24). Keys are normalized 25343 * (uppercase, colour retained) â resolve labels via chairToShopify(), never by 25344 * direct lookup. Colour-specific labels map to that colour's variant; bare 25345 * family codes map to the family's lead variant. Costco-exclusive models 25346 * (Chiro Plus/Chiro24/Chiro NZ/Revitalise/Pinnacle Plus) and the Niseko/Plunge 25347 * ice baths are NOT Shopify products on MMC â they intentionally stay unmapped. 25348 */ 25349export const CHAIR_SHOPIFY_MAP: Record<string, ChairProductRef> = { 25350 'HP': { productTitle: 'Health+® Massage Chair', sku: 'HP25-AU-CR' }, 25351 'HEALTH PLUS': { productTitle: 'Health+® Massage Chair', sku: 'HP25-AU-CR' }, 25352 'PP': { productTitle: 'Physio+®', sku: 'PP24-AU-BL-HC-V2' }, 25353 'PP BLACK': { productTitle: 'Physio+®', sku: 'PP24-AU-BL-HC-V2' }, 25354 'PP CREAM': { productTitle: 'Physio+®', sku: 'PP24-AU-CR-HC-V2' }, 25355 'UC': { productTitle: 'Ultimate Chiro®', sku: 'UC-AU-KA-V2' }, 25356 'UC LATTE': { productTitle: 'Ultimate Chiro®', sku: 'UC-AU-KA-V2' }, 25357 'RDP': { productTitle: 'Remedial Deluxe+®', sku: 'RDP24-AU-BL-V2' }, 25358 'REMEDIAL DELUXE': { productTitle: 'Remedial Deluxe+®', sku: 'RDP24-AU-BL-V2' }, 25359 'RDP CREAM': { productTitle: 'Remedial Deluxe+®', sku: 'RDP24-AU-CR-V2' }, 25360 'RDP ROSE GOLD': { productTitle: 'Remedial Deluxe+®', sku: 'RDP24-AU-RG-V2' }, 25361 'TDP': { productTitle: 'Therapeutic Dual-Pro®', sku: 'MAS-TDP24-AU-BL' }, 25362 'TDP BLACK': { productTitle: 'Therapeutic Dual-Pro®', sku: 'MAS-TDP24-AU-BL' }, 25363 'THERAMAX': { productTitle: 'TheraMax®', sku: 'TM24-AU-BL' }, 25364 'TERAPEUTICA': { productTitle: 'Terapeutica', sku: 'Terapeutica-AU-CO-BL' }, 25365}; 25366 25367/** 25368 * Resolve a reseller \`chairs[]\` label to its Shopify product, tolerating the 25369 * doc's decorations: "(new)", "x 2", "from 29/06/26". Null when the label has 25370 * no MMC product (Costco-exclusive models, ice baths). 25371 */
25372export function chairToShopify(label: string): ChairProductRef | null { 25373 const key = label 25374 .toUpperCase() 25375 .replace(/\\(NEW\\)/g, ' ') 25376 .replace(/\\bFROM \\d.*$/g, ' ') 25377 .replace(/\\bX \\d+\\b/g, ' ') 25378 .replace(/[^A-Z0-9+ ]/g, ' ') 25379 .replace(/\\s+/g, ' ') 25380 .trim(); 25381 return CHAIR_SHOPIFY_MAP[key] ?? null; 25382} 25383 25384/** One stocked model on a showroom floor, resolved from a \`chairs[]\` label (plan WP5, 21 Sep 2026). */ 25385export interface StockedModel { 25386 /** The label as the reseller row carries it ("PP Cream", "TheraMax", "Niseko Ice Baths"). */ 25387 label: string; 25388 /** Canonical product title when the label maps to a Shopify product; the label itself otherwise. */ 25389 title: string; 25390 sku?: string; 25391 /** True when the model is a catalogue product (picked, or resolved by \`chairToShopify\`); false for pass-through labels. */ 25392 mapped: boolean; 25393 /** Set when picked from the catalogue (numeric Shopify ids as strings). */ 25394 productId?: string; 25395 variantId?: string; 25396 variantTitle?: string; 25397} 25398 25399/** 25400 * The models a reseller stocks, for the AI's showroom list and the stock check: every
25401 * \`chairs[]\` label through \`chairToShopify\`, deduped by title, unmapped labels (ice baths, 25402 * Costco-only models) kept as text so nothing the reseller carries is silently dropped. 25403 * Pure; derived at read time so all rows benefit without a bulk write to the shared table. 25404 * A reseller with \`stockedProducts[]\` persisted by the editor (WP5 shopdash) takes that first. 25405 */ 25406export function stockedModelsFor(reseller: Pick<Reseller, 'chairs' | 'stockedProducts'>): StockedModel[] { 25407 if (Array.isArray(reseller.stockedProducts) && reseller.stockedProducts.length) { 25408 // One entry per product title (a showroom with the black and the cream Physio+ stocks one model). 25409 const seen = new Set<string>(); const out: StockedModel[] = []; 25410 for (const m of reseller.stockedProducts) { const k = String(m.title || m.label).replace(/[®â¢]/g, '').toLowerCase(); if (!k || seen.has(k)) continue; seen.add(k); out.push({ ...m, title: String(m.title || m.label).replace(/[®â¢]/g, '') }); } 25411 return out; 25412 } 25413 const out: StockedModel[] = []; 25414 const seen = new Set<string>(); 25415 for (const raw of reseller.chairs ?? []) { 25416 const label = String(raw ?? '').trim(); 25417 if (!label) continue; 25418 const ref = chairToShopify(label); 25419 const title = ref ? ref.productTitle.replace(/[®â¢]/g, '') : label; 25420 const key = title.toLowerCase(); 25421 if (seen.has(key)) continue; 25422 seen.add(key); 25423 out.push({ label, title, ...(ref ? { sku: ref.sku } : {}), mapped: !!ref }); 25424 } 25425 return out; 25426} 25427 25428/** 25429 * Deterministic match order for TABLE-loaded reseller rows: DynamoDB Scan 25430 * order is arbitrary, but matchReseller semantics are first-match â so every 25431 * dynamic matcher MUST sort rows to the const-registry order first (UI-added 25432 * rows sort after, alphabetically). Keeps 'Hartley Wells' ahead of the generic 25433 * 'betta home living' token and 'Costco NZ' ahead of the generic 'costco'. 25434 */ 25435export function sortResellersForMatching<T extends { name: string }>(rows: T[]): T[] { 25436 const order = new Map(RESELLER_REGISTRY.map((r, i) => [r.name, i])); 25437 return [...rows].sort((a, b) => { 25438 const ia = order.get(a.name) ?? Number.MAX_SAFE_INTEGER; 25439 const ib = order.get(b.name) ?? Number.MAX_SAFE_INTEGER; 25440 return ia - ib || a.name.localeCompare(b.name); 25441 }); 25442} 25443 25444/** 25445 * Per-user write grants for the reseller registry, ON TOP OF the role gate 25446 * (tenant_operations / tenant_admin / app_admin). Steve owns this data and is 25447 * not an operator role, so he is named. 25448 * 25449 * ONE list, deliberately: the dataApi \`/data/resellers\` PUT+DELETE gate and the 25450 * ShopDash /resellers screen both import it, so adding a person is one edit. 25451 * It used to be a literal in both files, which is how a UI can offer an Edit 25452 * button the API then refuses. 25453 */ 25454export const RESELLER_EDIT_USER_GRANTS: readonly string[] = ['[email protected]']; 25455 25456/** True when \`email\` carries a per-user reseller-edit grant (case + space insensitive). */ 25457export function hasResellerEditGrant(email?: string | null): boolean { 25458 return RESELLER_EDIT_USER_GRANTS.includes((email || '').trim().toLowerCase()); 25459} 25460 25461/** 25462 * Names for a location's \`contacts[]\`, derived from its \`people[]\`: primaries 25463 * first, then declaration order, blanks dropped, case-insensitively deduped. 25464 * The SMS formatter renders these as "Ask for Andrew or Alex", so order is the 25465 * order a customer is told to ask in. 25466 */ 25467export function deriveResellerContacts(people?: ResellerPerson[]): string[] { 25468 if (!Array.isArray(people)) return []; 25469 const ordered = [...people.filter((p) => p?.primary), ...people.filter((p) => !p?.primary)]; 25470 const seen = new Set<string>(); 25471 const out: string[] = []; 25472 for (const p of ordered) { 25473 const name = (p?.name || '').trim(); 25474 if (!name) continue; 25475 const key = name.toLowerCase(); 25476 if (seen.has(key)) continue; 25477 seen.add(key); 25478 out.push(name); 25479 } 25480 return out; 25481} 25482 25483/** 25484 * THE SINGLE WRITER of \`contacts[]\`. Every path that persists a reseller runs 25485 * this first, so \`contacts[]\` can never disagree with \`people[]\`. 25486 * 25487 * A location with NO \`people\` array is left exactly as it is â that is a row 25488 * transcribed from Steve's doc that no human has opened yet, and rewriting its 25489 * \`contacts[]\` from an absent list would silently erase real names. 25490 */ 25491export function applyResellerContactDerivation<T extends Reseller>(reseller: T): T { 25492 const locations = (reseller.locations || []).map((l) => { 25493 if (!Array.isArray(l?.people)) return l; 25494 const derived = deriveResellerContacts(l.people); 25495 const next: ResellerLocation = { ...l, contacts: derived }; 25496 if (!derived.length) delete next.contacts; 25497 return next; 25498 }); 25499 return { ...reseller, locations }; 25500} 25501 25502/** 25503 * Seed an editable \`people[]\` from a legacy \`contacts[]\` so the editor has rows 25504 * to fill in. Used for DISPLAY ONLY on a row that has never been saved: there is 25505 * no bulk backfill, because \`resellers\` is one table shared by dev and prod, so 25506 * the first human save is what writes \`people[]\` for that row. First name listed 25507 * becomes the primary, which is the order the doc was transcribed in. 25508 */ 25509export function resellerPeopleFromContacts(contacts?: string[]): ResellerPerson[] { 25510 return (contacts || []) 25511 .map((n) => (n || '').trim()) 25512 .filter(Boolean) 25513 .map((name, i) => ({ name, primary: i === 0 })); 25514} 25515 25516/** 25517 * Why reseller appointments cannot be turned on for this reseller, or null when 25518 * they can. Shared by the UI (which explains the blocked toggle rather than 25519 * showing a dead switch) and the API (which rejects the write), so the two can 25520 * never disagree about what is allowed. 25521 * 25522 * â LEGACY. Its only caller is the \`appointmentsEnabled\` branch of the dataApi's 25523 * \`validateResellerPayload\`, and no live row carries that field except the dev 25524 * \`__TEST__\` fixture. \`resellerBookingEligibility\` is what decides bookability, 25525 * and it no longer reads \`doNotCallCustomer\` at all (11 Sep 2026) â that flag is 25526 * a delivery instruction. Kept so a legacy payload still validates the way it 25527 * always did; do not wire it into anything new. 25528 */ 25529export function resellerAppointmentBlock(reseller: Pick<Reseller, 'contactPolicy'>): string | null { 25530 if (reseller?.contactPolicy?.doNotCallCustomer) { 25531 return 'This reseller is set to "do not call customer", so we cannot book their customers in.'; 25532 } 25533 return null; 25534} 25535 25536// âââ Booking eligibility (2026-09 reseller appointment slice) ââââââââââââââââ 25537 25538/** Machine-readable reasons a reseller (or one of its locations) cannot take a 25539 * booking. Each maps to its OWN human reason string â a blocked booking must 25540 * say why, never a generic "not available". */ 25541export type ResellerBookingBlockCode = 25542 | 'not_active' 25543 | 'restricted' 25544 /** 25545 * â NO LONGER EMITTED (11 Sep 2026). \`doNotCallCustomer\` is a DELIVERY 25546 * instruction, not a booking rule â Steve, 10 Sep 2026: "that is for chair 25547 * deliveries. Yes, we want to book showroom visits." Every flagged row's own 25548 * \`deliveryNotes\` says the same thing ("DO NOT CALL CUSTOMER. Usually deliver 25549 * to <address>; do not install"). Kept in the union and the reason map so old 25550 * payloads and tests still resolve; nothing produces it. 25551 */ 25552 | 'do_not_call' 25553 | 'online_only' 25554 | 'own_showroom' 25555 | 'location_paused' 25556 | 'no_contact_mobile' 25557 | 'test_only'; 25558 25559export interface ResellerBookingEligibility { 25560 bookable: boolean; 25561 code?: ResellerBookingBlockCode; 25562 reason?: string; 25563} 25564 25565const BOOKING_BLOCK_REASONS: Record<ResellerBookingBlockCode, string> = { 25566 not_active: 'This reseller is not active (closed or former).', 25567 restricted: 25568 'This reseller is restricted. Do not send customers here unless it has been discussed with Steve first.', 25569 do_not_call: 25570 'This reseller is set to "do not call customer", so we cannot book their customers in.', 25571 online_only: 'This reseller is online only and has no showroom to visit.', 25572 own_showroom: 'This is our own showroom. Book it with the Showroom mode, not Reseller.', 25573 location_paused: 'This showroom is not taking bookings yet.', 25574 no_contact_mobile: 25575 'No contact person at this location has a mobile number loaded, so nobody can be texted the booking.', 25576 test_only: 'This is a test fixture, not a real reseller.', 25577}; 25578 25579/** 25580 * The person the booking SMS goes to at a location: the primary person with a 25581 * mobile, else the first person with one. Null when nobody has a mobile â 25582 * which makes the location unbookable (\`no_contact_mobile\`). 25583 */ 25584export function resellerAppointmentContact(location?: ResellerLocation | null): Re
25584sellerPerson | null { 25585 const people = Array.isArray(location?.people) ? location!.people! : []; 25586 const withMobile = people.filter((p) => (p?.mobile || '').trim()); 25587 if (!withMobile.length) return null; 25588 return withMobile.find((p) => p.primary) ?? withMobile[0]; 25589} 25590 25591/** 25592 * Can a reseller appointment be booked at \`location\`? Every §3.3 rule with its 25593 * own code + reason. Shared by the ShopDash calendar picker (which explains a 25594 * blocked reseller instead of hiding the why) and the dataApi (which rejects 25595 * the write with the same string), so the two can never disagree. 25596 * 25597 * When \`opts.includeTestOnly\` is set (dev tooling only), \`testOnly\` rows pass 25598 * the fixture gate but every other rule still applies. 25599 */ 25600export function resellerBookingEligibility( 25601 reseller: Reseller, 25602 location?: ResellerLocation | null, 25603 opts?: { includeTestOnly?: boolean }, 25604): ResellerBookingEligibility { 25605 const block = (code: ResellerBookingBlockCode, detail?: string): ResellerBookingEligibility => ({ 25606 bookable: false, 25607 code, 25608 reason: detail ? \`\${BOOKING_BLOCK_REASONS[code]} \${detail}\` : BOOKING_BLOCK_REASONS[code], 25609 }); 25610 if (reseller.testOnly && !opts?.includeTestOnly) return block('test_only'); 25611 if (reseller.isOwnShowroom) return block('own_showroom'); 25612 if (reseller.onlineOnly) return block('online_only'); 25613 if (reseller.status === 'restricted') return block('restricted'); 25614 if (reseller.status !== 'active') return block('not_active'); 25615 // â \`contactPolicy.doNotCallCustomer\` is deliberately NOT a booking block 25616 // (11 Sep 2026). It is a DELIVERY instruction â Steve, 10 Sep 2026: "that is 25617 // for chair deliveries. Yes, we want to book showroom visits, so we can use 25618 // the phone number on the document." Do not re-add it here; if a showroom 25619 // should not take bookings, that is \`location.bookingStatus = 'paused'\`. 25620 // 25621 // A paused showroom is checked BEFORE the mobile, so the operator is told the 25622 // decision rather than a data gap they cannot fix. 25623 if (location?.bookingStatus === 'paused') { 25624 return block('location_paused', (location.bookingStatusNote || '').trim() || undefined); 25625 } 25626 // â There is deliberately NO appointmentsEnabled opt-in (Chris, 8 Sep 2026: 25627 // the toggle was removed from the editor). Bookability is DERIVED from the 25628 // data: an active, contactable reseller whose location has a person with a 25629 // mobile is bookable. Managing the data IS the enablement. 25630 if (!resellerAppointmentContact(location)) return block('no_contact_mobile'); 25631 return { bookable: true }; 25632} 25633 25634/** 25635 * Standard wholesale chair prices from Steve's email â the chair price that 25636 * appears on the majority of reseller purchase orders. Keys MUST equal the 25637 * salesorder-ingest FAMILY map values (resolve.ts) â that lambda uses this 25638 * table to disambiguate chair models by price. 25639 */ 25640export const WHOLESALE_CHAIR_PRICES: Record<string, number> = { 25641 'Health+': 1980, 25642 'Physio+': 2600,
25643 'Ultimate Chiro': 2966, 25644 'Remedial Deluxe+': 4650, 25645 'Therapeutic Dual-Pro': 7100, 25646 TheraMax: 8400, 25647}; 25648 25649/** 25650 * Chair family (salesorder-ingest FAMILY values) â the EXACT canonical MMC 25651 * product title for reseller sales. Pins resolution to one product: a 25652 * substring match on "Ultimate Chiro" also hits "Ultimate Chiro® Special 25653 * Offer" ($11,085 retail), and the store carries two active "Restore+" 25654 * listings at different prices. salesorder-ingest prefers an exact-title 25655 * candidate set when the family is listed here; substring matching stays as 25656 * the fallback for unlisted chairs and non-matching titles (dev clones keep 25657 * the same titles, so this pins there too). 25658 */ 25659export const CHAIR_FAMILY_PRODUCT_TITLES: Record<string, string> = { 25660 'Health+': 'Health+® Massage Chair', 25661 'Physio+': 'Physio+®', 25662 'Ultimate Chiro': 'Ultimate Chiro®', 25663 'Remedial Deluxe+': 'Remedial Deluxe+®', 25664 'Therapeutic Dual-Pro': 'Therapeutic Dual-Pro®', 25665 TheraMax: 'TheraMax®', 25666 'Vitality Pro-Flex': 'Vitality Pro-Flex', 25667 Terapeutica: 'Terapeutica', 25668}; 25669`,kn=`/** 25670 * Revenue Ideas Type Definitions 25671 * 25672 * Tenant-scoped revenue growth strategies. 25673 * 25674 * Table: revenue-ideas 25675 * PK: tenantName (e.g., "example.myshopify.com") 25676 * SK: id (UUID) 25677 */ 25678 25679/** 25680 * Revenue Idea Status 25681 */ 25682export type RevenueIdeaStatus = 'pending' | 'in-progress' | 'completed' | 'rejected'; 25683 25684/** 25685 * Revenue Idea Priority 25686 */ 25687export type RevenueIdeaPriority = 'low' | 'medium' | 'high' | 'critical'; 25688 25689/** 25690 * Revenue Idea Impact - T-shirt sizing 25691 */ 25692export type RevenueIdeaImpact = 'XS' | 'S' | 'M' | 'L' | 'XL'; 25693 25694/** 25695 * Revenue Category - free-form string for categorization 25696 */ 25697export type RevenueCategory = string; 25698 25699/** 25700 * Revenue Idea record 25701 */ 25702export interface RevenueIdea { 25703 /** Partition key - tenant domain (e.g., "example.myshopify.com") */ 25704 tenantName: string; 25705 25706 /** Sort key - UUID */ 25707 sk: string; 25708 25709 /** Unique idea identifier (UUID) */ 25710 id: string; 25711 25712 /** Idea title */ 25713 title: string; 25714 25715 /** Detailed description of the revenue strategy */ 25716 description: string; 25717 25718 /** Category of revenue strategy (free-form) */ 25719 category: RevenueCategory; 25720 25721 /** Current status */ 25722 status: RevenueIdeaStatus; 25723 25724 /** Priority level */ 25725 priority: RevenueIdeaPriority; 25726 25727 /** Impact level - T-shirt sizing */ 25728 impact: RevenueIdeaImpact; 25729 25730 /** Rationale for the impact sizing */ 25731 impactLogic: string; 25732 25733 /** Additional notes */ 25734 notes?: string; 25735 25736 /** Blockers preventing progress */ 25737 blockers?: string; 25738 25739 /** ISO 8601 timestamp when created */ 25740 createdAt: string; 25741 25742 /** ISO 8601 timestamp when last updated */ 25743 updatedAt: string; 25744 25745 /** ISO 8601 timestamp when completed (optional) */ 25746 completedAt?: string; 25747} 25748 25749/** 25750 * Create revenue idea input (omits auto-generated fields) 25751 */ 25752export interface CreateRevenueIdeaInput { 25753 title: string; 25754 impact: RevenueIdeaImpact; 25755 impactLogic: string; 25756 description?: string; 25757 category?: string; 25758 status?: RevenueIdeaStatus; 25759 priority?: RevenueIdeaPriority; 25760 notes?: string; 25761 blockers?: string; 25762} 25763 25764/** 25765 * Update revenue idea input (only updatable fields) 25766 */ 25767export interface UpdateRevenueIdeaInput { 25768 title?: string; 25769 impact?: RevenueIdeaImpact; 25770 impactLogic?: string; 25771 description?: string; 25772 category?: string; 25773 status?: RevenueIdeaStatus; 25774 priority?: RevenueIdeaPriority; 25775 notes?: string; 25776 blockers?: string; 25777} 25778 25779// ============================================ 25780// Constants 25781// ============================================ 25782 25783/** Valid idea status values */ 25784export const VALID_REVENUE_IDEA_STATUS: RevenueIdeaStatus[] = [ 25785 'pending', 25786 'in-progress', 25787 'completed', 25788 'rejected', 25789]; 25790 25791/** Valid idea priority values */ 25792export const VALID_REVENUE_IDEA_PRIORITY: RevenueIdeaPriority[] = [ 25793 'low', 25794 'medium', 25795 'high', 25796 'critical', 25797]; 25798 25799/** Valid idea impact values (T-shirt sizing) */ 25800export const VALID_REVENUE_IDEA_IMPACT: RevenueIdeaImpact[] = [ 25801 'XS', 25802 'S', 25803 'M', 25804 'L', 25805 'XL', 25806]; 25807 25808// ============================================ 25809// Helper Functions 25810// ============================================ 25811 25812/** 25813 * Type guard for RevenueIdeaStatus 25814 */ 25815export function isValidRevenueIdeaStatus(status: string): status is RevenueIdeaStatus { 25816 return VALID_REVENUE_IDEA_STATUS.includes(status as RevenueIdeaStatus); 25817} 25818 25819/** 25820 * Type guard for RevenueIdeaPriority 25821 */ 25822export function isValidRevenueIdeaPriority(priority: string): priority is RevenueIdeaPriority { 25823 return VALID_REVENUE_IDEA_PRIORITY.includes(priority as RevenueIdeaPriority); 25824} 25825 25826/** 25827 * Type guard for RevenueIdeaImpact 25828 */ 25829export function isValidRevenueIdeaImpact(impact: string): impact is RevenueIdeaImpact { 25830 return VALID_REVENUE_IDEA_IMPACT.includes(impact as RevenueIdeaImpact); 25831} 25832`,Tn=`/** 25833 * Sales Attribution â input + result types for the shared stampSalesAttribution 25834 * utility in @bigm/shared. 25835 * 25836 * The utility writes \`custom.sales_agents\`, \`custom.sales_agent_names\`, 25837 * \`custom.payment_method\`, and \`custom.shopzen_lead\` Shopify metafields on
25838 * either an order or a draft order (draft-order metafields carry over when 25839 * the draft is completed, which is what makes workflow-created drafts useful). 25840 * 25841 * Namespace \`custom\` matches Shopify Admin UI's default for merchant-owned 25842 * definitions â avoids a namespace mismatch between backend-written values 25843 * and merchant-created pinned definitions. 25844 */ 25845 25846export type SalesAttributionMetafieldKey = 25847 | 'sales_agents' 25848 | 'sales_agent_names' 25849 | 'sales_agent_splits' 25850 | 'payment_method' 25851 | 'shopzen_lead'; 25852 25853export type SalesAttributionOwnerType = 'order' | 'draft_order'; 25854 25855/** 25856 * Skipped-field reasons so callers can log or surface them if they care. 25857 */ 25858export interface SalesAttributionSkip { 25859 key: SalesAttributionMetafieldKey; 25860 reason: string; 25861} 25862 25863/** 25864 * Result summary. Utility never throws â callers inspect \`skipped[]\` for 25865 * partial failures. 25866 */ 25867export interface StampSalesAttributionResult { 25868 stamped: SalesAttributionMetafieldKey[]; 25869 skipped: SalesAttributionSkip[]; 25870 /** MaxContact IDs resolved from the agentIds input, in input order. */ 25871 resolvedMaxIds: string[]; 25872 /** Display names resolved from the agentIds input, index-aligned to resolvedMaxIds. */ 25873 resolvedNames: string[]; 25874} 25875`,In=`/** 25876 * Sales Order Type Definitions 25877 * 25878 * Based on Terraform schema: terraform/SHARED/create-dynamo-sales-orders 25879 * 25880 * Sales-orders table - Stores sales order records imported from Excel 25881 * PRIMARY KEY = orderId (hash), sku (range) 25882 * TIMESTAMPS = createdAt, updatedAt 25883 * 25884 * GSIs: 25885 * - ordersByDate: Query orders by date (sorted by orderId) 25886 * - ordersBySource: Query orders by attribution source (sorted by date) 25887 * - ordersByRep: Query orders by sales rep (sorted by date) 25888 * - ordersByType: Query orders by product type (sorted by date) 25889 * - ordersByState: Query orders by state (sorted by date) 25890 * - ordersByPayment: Query orders by payment method (sorted by date) 25891 */ 25892 25893// ============================================================================ 25894// Enums/Union Types 25895// ============================================================================ 25896 25897/** Which sheet/channel the order came from */ 25898export type SalesChannel = 'sales-board' | 'ecom'; 25899 25900/** Product type categories */ 25901export type SalesProductType = 25902 | 'Massage Chair' 25903 | 'Icebath Cedar' 25904 | 'Sauna' 25905 | 'Accessories' 25906 | 'Chair' 25907 | 'Icebath Accessory' 25908 | 'Masssage Chair'; 25909 25910// ============================================================================ 25911// Main Entity Type 25912// ============================================================================ 25913 25914/** Sales Order record stored in DynamoDB */ 25915export interface SalesOrder { 25916 // Keys 25917 /** PK - Shopify order number (mixed: "28772", "MHC1455", "PO-0116") */ 25918 orderId: string; 25919 /** SK - Product SKU (e.g. "PP24-AU-BL-WITH-HC", "NSK-G-AU") */ 25920 sku: string; 25921 25922 // Customer 25923 customerName: string; 25924 email?: string; 25925 state: string; 25926 postcode: number; 25927 25928 // Product 25929 model: string; 25930 colour: string; 25931 productType: string; 25932 25933 // Sale 25934 amount: number; 25935 gpPercent?: number; 25936 source: string; 25937 salesBy: string; 25938 paymentMethod: string; 25939 channel: SalesChannel; 25940 our?: number; 25941 25942 // Date 25943 orderDate: string; 25944 dayOfWeek: string; 25945 hourOfDay: number; 25946 25947 // Add-ons 25948 shippingQuoted?: number; 25949 wgi?: number; 25950 cushion?: number; 25951 em?: number; 25952 cover?: number; 25953 theraNeck?: number; 25954 financeAmount?: number; 25955 25956 // Finance 25957 hummPercent?: number; 25958 amtDeposited?: number; 25959 contractRef?: string; 25960 25961 // Fulfillment 25962 dispatched?: boolean | string; 25963 actualShipping?: number; 25964 actualInstallation?: number; 25965 isReturn?: boolean; 25966 replacementShipping?: number; 25967 replacementInstallation?: number; 25968 25969 // Metadata 25970 tvAdDate?: string; 25971 notes?: string; 25972 createdAt: string; 25973 updatedAt: string; 25974 25975 /** 25976 * Owning Shopzen tenant (shop domain, e.g. "masseuse-massage-store.myshopify.com"). 25977 * Used for tenant isolation: the ShopDash dataApi scopes sales-order reads to the 25978 * caller's accessible tenants. Rows without it are hidden from non-admin operators 25979 * (fail closed). Seed/import + any future ingest MUST stamp this. 25980 */ 25981 tenantName?: string; 25982} 25983`,_n=`/** 25984 * Sales training â courses, quizzes and attempts. 25985 * 25986 * â NOT to be confused with \`/admin/training/*\` in ShopDash or 25987 * \`@bigm/shared/training-retrieval\`, both of which are AI MODEL training 25988 * (corpus, prompt evals, shadow compare). This is humans learning products. 25989 * Everything here is named \`sales*\` or \`course*\` for that reason. 25990 * 25991 * The rule the whole feature exists for: a participant cannot complete a course 25992 * until every question is answered. That is enforced in \`isSubmittable\` for the 25993 * button and again server side on submit, because a disabled button is a 25994 * courtesy, not a control. 25995 */ 25996 25997export type QuizQuestionType = 'single' | 'multi' | 'ordering' | 'free_text'; 25998 25999export interface QuizQuestion { 26000 id: string; 26001 /** The section this question came from, so a wrong answer can name it. */ 26002 sectionId: string; 26003 type: QuizQuestionType; 26004 prompt: string; 26005 /** Present for every type except \`free_text\`. */ 26006 options?: { id: string; label: string }[]; 26007 /** 26008 * \`single\` â one option id. \`multi\` â every correct id, order irrelevant. 26009 * \`ordering\` â the ids in their correct sequence. \`free_text\` â absent. 26010 */ 26011 answer?: string[]; 26012 /** Free-text questions are marked against this, never against an exact string. */ 26013 rubric?: string; 26014 /** 26015 * Getting this wrong fails the attempt whatever the score â reserved for 26016 * answers that cost a sale or create a hazard (three-phase power, warranty 26017 * terms, what may be claimed about a health outcome). 26018 */ 26019 mustPass?: boolean; 26020} 26021 26022export interface CourseSection { 26023 id: string; 26024 title: string; 26025 /** Markdown. */ 26026 body: string; 26027 /** Real CDN URLs; validated to resolve before a course may publish. */ 26028 images?: { url: string; caption?: string }[]; 26029 documents?: { url: string; label: string }[]; 26030} 26031 26032/** 26033 * What kind of module this is. The board groups by category, and a per-agent 26034 * allocation can switch a whole category on or off for one person. 26035 */ 26036export type CourseCategory = 'product' | 'compliance' | 'induction' | 'systems'; 26037 26038export interface Course { 26039 courseId: string; 26040 version: number; 26041 title: string; 26042 category: CourseCategory; 26043 /** Tenants whose agents are assigned this course BY DEFAULT. */ 26044 tenants: string[]; 26045 summary: string; 26046 sections: CourseSection[]; 26047 questions: QuizQuestion[]; 26048 /** Correct answers needed to pass, before \`mustPass\` is applied. */ 26049 passMark: number; 26050 /** How long a pass stays valid. */ 26051 renewalDays: number; 26052 /** 26053 * The commercial figures this course teaches, declared so they can be checked 26054 * against the live storefront on a schedule. 26055 * 26056 * â Declare them rather than scraping the prose. Authoring the first two 26057 * courses, a regex over the body flagged "$1,000" out of the sentence "a rung 26058 * roughly every $1,000" and missed that the Niseko entry price was genuinely 26059 * wrong. A check nobody trusts gets switched off. 26060 * 26061 * Only figures the storefront actually publishes belong here. Page-level 26062 * upgrade options (the Harvia XENIO prices) are real but do not appear in 26063 * products.json, so they stay in the body and out of this list. 26064 */ 26065 priceFacts?: { label: string; amount: number }[]; 26066} 26067 26068/** One answer as submitted. \`value\` is option ids, or the text for \`free_text\`. */ 26069export interface SubmittedAnswer { 26070 questionId: string; 26071 value: string[] | string; 26072} 26073 26074export interface CourseAttempt { 26075 attemptId: string; 26076 agentId: string; 26077 courseId: string; 26078 courseVersion: number; 26079 startedAt: string; 26080 submittedAt?: string; 26081 answers: SubmittedAnswer[]; 26082 score?: number; 26083 passed?: boolean; 26084 /** Which \`mustPass\` questions were missed, if any. */ 26085 failedMustPass?: string[]; 26086} 26087 26088export interface QuestionOutcome { 26089 questionId: string; 26090 sectionId: string; 26091 correct: boolean; 26092 mustPass: boolean; 26093 /** Set for free_text, which a marker resolves rather than an exact match. */ 26094 needsMarking?: boolean; 26095} 26096 26097export interface ScoreResult { 26098 score: number; 26099 total: number; 26100 passed: boolean; 26101 failedMustPass: string[]; 26102 outcomes: QuestionOutcome[]; 26103 /** True when at least one free-text answer still needs a marker. */ 26104 pendingMarking: boolean; 26105} 26106`,En=`/** 26107 * SAP OData API Response Types 26108 * 26109 * Types matching the SAP Developer Hub OData service schemas. 26110 * Services: ZSD_INVENTORY_STOCK_SRV, ZSD_SALES_ORDER_GET_SRV, 26111 * ZSD_CART_DET_SRV, ZSD_PURCHASE_ORDER_SRV 26112 */ 26113 26114// ============================================================================ 26115// Inventory Stock (ZSD_INVENTORY_STOCK_SRV) 26116// ============================================================================ 26117 26118/** Australian state facility codes */ 26119export type SapFacilityCode = '2000' | '3000' | '4000' | '5000' | '6000' | '7000' | '8000'; 26120 26121export const SAP_FACILITY_LABELS: Record<SapFacilityCode, string> = { 26122 '2000': 'NSW', 26123 '3000': 'VIC', 26124 '4000': 'QLD', 26125 '5000': 'SA', 26126 '6000': 'WA', 26127 '7000': 'TAS', 26128 '8000': 'NT', 26129}; 26130 26131export interface SapInventoryStock { 26132 Material: string; 26133 Facility: string; 26134 StorageLocation: string; 26135 CustomerSKU: string; 26136 SKUDescription: string; 26137 FacilityDesc: string; 26138 Division: string; 26139 CreatedOn: string; 26140 InstallLeadTime: number; 26141 BaseUnit: string; 26142 UnrestrictedUse: string; 26143 QualityInspection: string; 26144 Blocked: string; 26145 Restricted: string; 26146 Returns: string; 26147 SOInTransit: string; 26148 STOInTransit: string; 26149 ReservedDelivery: string; 26150 OnPurchaseOrder: string; 26151 TotalStock: string; 26152} 26153 26154// ============================================================================ 26155// Sales Order GET (ZSD_SALES_ORDER_GET_SRV) 26156// ============================================================================ 26157 26158export interface SapSalesOrder { 26159 SalesOrder: string; 26160 CustomerReference: string | null; 26161 DocType: string | null; 26162 CustomerName: string | null; 26163 CustomerNumber: string | null; 26164 NetValue: string | null; 26165 CreatedOn: string | null; 26166 CreatedBy: string | null; 26167 DeliveryDate: string | null; 26168 HeaderNote: string | null;
26169 ReturnReason: string | null; 26170 ReferenceDocument: string | null; 26171 SupplyingPlant: string | null; 26172 ReceivingPlant: string | null; 26173 SalesOrg: string | null; 26174 LineItems: string | null; 26175 DeliveryOutcomes: string | null; 26176 ShippingDetails: string | null; 26177 ServiceCharges: string | null; 26178} 26179 26180// ============================================================================ 26181// Delivery Calendar (ZSD_CART_DET_SRV) 26182// ============================================================================ 26183 26184export interface SapDeliveryCalendarRequest { 26185 Code: string; 26186 Suburb: string; 26187 State: 'NSW' | 'VIC' | 'QLD' | 'SA' | 'WA' | 'TAS' | 'NT' | 'ACT'; 26188 Paymentcode?: string; 26189 Sku: string; 26190 Quantity?: string; 26191 Type: 'ITEM' | 'SERVICE'; 26192 Installs: 'Y' | 'N'; 26193 Source?: string; 26194 blocked_dateSet: SapBlockedDate[]; 26195} 26196 26197export interface SapBlockedDate { 26198 BlockedDate: string; 26199 Reason: string; 26200} 26201 26202// ============================================================================ 26203// Purchase Orders (ZSD_PURCHASE_ORDER_SRV) 26204// ============================================================================ 26205 26206export interface SapPurchaseOrderItem { 26207 purchaseorder: string; 26208 poitem: string; 26209 customermat: string; 26210 quantity: string; 26211 our_reference: string; 26212 action: string; 26213} 26214 26215export interface SapPurchaseOrder { 26216 purchaseorder: string; 26217 vendor: string; 26218 plant: string; 26219 delivery_date: string; 26220 comments: string; 26221 our_reference: string; 26222 action: string; 26223 doc_type?: string; 26224 supplying_plant?: string; 26225 container_type?: string; 26226 shipment_type?: string; 26227 ET_POItemSet?: { 26228 results: SapPurchaseOrderItem[]; 26229 }; 26230} 26231 26232// ============================================================================ 26233// OData Response Wrappers 26234// ============================================================================ 26235 26236export interface SapODataCollectionResponse<T> { 26237 d: { 26238 __count?: string; 26239 results: T[]; 26240 }; 26241} 26242 26243export interface SapODataSingleResponse<T> { 26244 d: T; 26245} 26246`,xn=`/** 26247 * Service Health Type Definitions 26248 * 26249 * Based on Terraform schema: terraform/SHARED/create-dynamo-service-health 26250 * 26251 * Service-health table - Stores current health status of AWS services 26252 * PRIMARY KEY = PK (hash key only) - Format: {serviceType}#{serviceName} 26253 * 26254 * GSI: 26255 * - statusByType: Query all services of a type, sorted by status 26256 */ 26257
26258/** 26259 * Types of AWS services monitored 26260 */ 26261export type ServiceType = 'SQS' | 'EVENTBRIDGE_PIPE' | 'LAMBDA' | 'DYNAMODB'; 26262 26263/** 26264 * Health status levels 26265 */ 26266export type ServiceStatus = 'healthy' | 'degraded' | 'error'; 26267 26268/** 26269 * Metrics specific to SQS queues 26270 */ 26271export interface SQSMetrics { 26272 /** Number of messages visible in the queue */ 26273 messagesVisible: number; 26274 /** Number of messages currently being processed (in flight) */ 26275 messagesNotVisible?: number; 26276 /** Whether this queue is a Dead Letter Queue */ 26277 isDLQ: boolean; 26278 /** Oldest message age in seconds (optional) */ 26279 oldestMessageAgeSeconds?: number; 26280 /** Last time a message was sent or received (ISO 8601) */ 26281 lastActivityAt?: string; 26282} 26283 26284/** 26285 * Metrics specific to EventBridge Pipes 26286 */ 26287export interface EventBridgePipeMetrics { 26288 /** Current pipe state: RUNNING, STOPPED, STARTING, STOPPING, etc. */ 26289 state: string; 26290 /** Source ARN (DynamoDB stream typically) */ 26291 sourceArn?: string; 26292 /** Target ARN (SQS queue typically) */ 26293 targetArn?: string; 26294 /** Last time the pipe processed an event (ISO 8601) */ 26295 lastActivityAt?: string; 26296} 26297 26298/** 26299 * Metrics specific to Lambda functions 26300 */ 26301export interface LambdaMetrics { 26302 /** Error count in the last 15 minutes */ 26303 errorCount15m: number; 26304 /** Throttle count in the last 15 minutes */ 26305 throttleCount15m: number; 26306 /** Invocation count in the last 15 minutes (optional) */ 26307 invocationCount15m?: number; 26308 /** Last time the Lambda was invoked (ISO 8601) */ 26309 lastActivityAt?: string; 26310} 26311 26312/** 26313 * Metrics specific to DynamoDB tables 26314 */ 26315export interface DynamoDBMetrics { 26316 /** Current table status: ACTIVE, CREATING, UPDATING, DELETING, etc. */ 26317 tableStatus: string; 26318 /** Whether DynamoDB Streams is enabled */ 26319 streamEnabled?: boolean; 26320 /** Throttled requests in the last 15 minutes */ 26321 throttledRequests15m: number; 26322 /** Last time a write (put/update) occurred on the table (ISO 8601) */ 26323 lastActivityAt?: string; 26324} 26325 26326/** 26327 * Union type for all service metrics 26328 */ 26329export type ServiceMetrics = SQSMetrics | EventBridgePipeMetrics | LambdaMetrics | DynamoDBMetrics; 26330 26331/** 26332 * Service Health record stored in DynamoDB 26333 */ 26334export interface ServiceHealthRecord { 26335 /** Primary key - Composite format: {serviceType}#{serviceName} */ 26336 PK: string; 26337 26338 /** Service type for GSI queries */ 26339 serviceType: ServiceType; 26340 26341 /** The resource name (queue name, pipe name, function name, table name) */ 26342 serviceName: string; 26343 26344 /** Current health status */ 26345 status: ServiceStatus; 26346 26347 /** Count of errors/issues found */ 26348 errorCount: number; 26349 26350 /** Human-readable descriptions of issues found */ 26351 findings: string[]; 26352 26353 /** Service-specific metrics */ 26354 metrics: ServiceMetrics; 26355 26356 /** UTC timestamp when the health check ran (ISO 8601) */ 26357 lastCheckedAt: string; 26358 26359 /** UTC timestamp when record was last updated (ISO 8601) */ 26360 updatedAt: string; 26361} 26362 26363/** 26364 * Service Health creation input 26365 */ 26366export interface CreateServiceHealthInput { 26367 serviceType: ServiceType; 26368 serviceName: string; 26369 status: ServiceStatus; 26370 errorCount: number; 26371 findings: string[]; 26372 metrics: ServiceMetrics; 26373} 26374 26375/** 26376 * Service Health update input 26377 */ 26378export interface UpdateServiceHealthInput { 26379 status?: ServiceStatus; 26380 errorCount?: number; 26381 findings?: string[]; 26382 metrics?: ServiceMetrics; 26383 lastCheckedAt?: string; 26384 updatedAt?: string; 26385} 26386 26387/** 26388 * Service Health GSI attributes 26389 */ 26390export interface ServiceHealthGSIAttributes { 26391 /** For statusByType GSI */ 26392 serviceType: ServiceType; 26393 status: ServiceStatus; 26394} 26395`,Dn=`/** 26396 * Supplier reconciliation registry â wires which tenants reconcile their Shopify 26397 * catalog against which supplier inventory dataset, plus the canonicalisation 26398 * rules + override map for that supplier. Onboarding a tenant/supplier = editing 26399 * this constant (no code change elsewhere). 26400 * 26401 * Consumed by the \`inventory-reconciliation-builder\` Lambda. \`canonicalRules\` is 26402 * structurally compatible with \`CanonicalRules\` in \`@bigm/utils\` (the matcher), 26403 * declared here too so \`@bigm/types\` stays dependency-free. 26404 */ 26405 26406import { WINNINGS_INVENTORY_TENANTS } from './inventory-snapshot.js'; 26407 26408/** Mirror of \`@bigm/utils\` CanonicalRules (kept dependency-free here). */ 26409export interface CanonicalRules { 26410 stripPrefixes?: string[]; 26411 marketTokens?: string[]; 26412 cartonPattern?: string; 26413 tokenAliases?: Record<string, string>; 26414} 26415 26416export interface SupplierReconciliation { 26417 /** Stable id, e.g. 'winnings'. */ 26418 id: string; 26419 label: string; 26420 /** Canonicalisation rules for this supplier's SKUs (+ the Shopify side). */ 26421 canonicalRules: CanonicalRules; 26422 /** S3 location of the operator-editable override/bridge map. */ 26423 overrideS3Bucket: string; 26424 overrideS3Key: string; 26425 /** Tenant domains (prod + dev) that reconcile against this supplier. */ 26426 tenants: string[]; 26427} 26428 26429/** Version stamp for the deterministic rules (bump on rule changes). */ 26430export const RECONCILIATION_RULES_VERSION = 'v1'; 26431 26432const WINNINGS_CANONICAL_RULES: CanonicalRules = { 26433 stripPrefixes: ['MAS-'], 26434 marketTokens: ['AU'], 26435 cartonPattern: '[-_](BOX|BX)\\\\s*\\\\d+$', 26436 tokenAliases: { CHOCO: 'CHOC', BLU: 'BL', GREY: 'GR', CREAM: 'CR' }, 26437}; 26438 26439export const SUPPLIER_RECONCILIATION: Record<string, SupplierReconciliation> = { 26440 winnings: { 26441 id: 'winnings', 26442 label: 'Winnings SAP', 26443 canonicalRules: WINNINGS_CANONICAL_RULES, 26444 overrideS3Bucket: 'bigm-knowledge', 26445 overrideS3Key: 'suppliers/winnings/sku-overrides.json', 26446 tenants: WINNINGS_INVENTORY_TENANTS, 26447 }, 26448}; 26449 26450/** Sentinel PK prefix for a reconciliation overlay row in \`inventory-snapshots\`. */ 26451export const RECON_PK_PREFIX = '__recon__#'; 26452
26453// --- Persisted overlay (one item per tenant per snapshot day) --- 26454 26455export interface InventoryReconciliationMatch { 26456 shopifyVariantId: string; 26457 /** Raw Shopify variant SKU the row matched against (absent on pre-2026-07 overlays). */ 26458 shopifySku?: string; 26459 shopifyTitle: string; 26460 shopifyImageUrl?: string; 26461 /** 'explicit' = matched via the \`winnings.parent_sku\` custom-ID metafield; 'inferred' = legacy canonical/override match. */ 26462 matchSource?: 'explicit' | 'inferred'; 26463} 26464 26465export interface InventoryReconciliationSummary { 26466 matched: number; 26467 winningsOnly: number; 26468 shopifyOnly: number; 26469 /** Shopify variants with a blank SKU (excluded from the comparison). */ 26470 noSku: number; 26471 /** Rows matched via the \`winnings.parent_sku\` custom-ID metafield. */ 26472 explicitMatches?: number; 26473 /** Rows matched via the legacy canonical/override path. */ 26474 inferredMatches?: number; 26475} 26476 26477export interface InventoryReconciliationShopifyOnly { 26478 sku: string; 26479 title: string; 26480 imageUrl?: string; 26481} 26482 26483/** 26484 * Reconciliation overlay, stored in the \`inventory-snapshots\` table under the 26485 * sentinel key PK \`snapshotDate = __recon__#\${tenant}\`, SK \`itemKey = \${date}\`. 26486 */ 26487export interface InventoryReconciliationOverlay { 26488 /** Table PK attr â holds \`\${RECON_PK_PREFIX}\${tenant}\`. */ 26489 snapshotDate: string; 26490 /** Table SK attr â the snapshot day (\`YYYY-MM-DD\`). */ 26491 itemKey: string; 26492 supplierId: string; 26493 rulesVersion: string; 26494 /** The snapshot day this overlay reconciles (== \`itemKey\`). */ 26495 reconciledDate: string; 26496 summary: InventoryReconciliationSummary; 26497 /** Keyed by RAW Winnings \`customerSku\` â Shopify match (matched rows only). */ 26498 matchBySku: Record<string, InventoryReconciliationMatch>; 26499 /** Shopify SKUs sellable on the store with no Winnings stock. */ 26500 shopifyOnly: InventoryReconciliationShopifyOnly[]; 26501 computedAtUtc: string; 26502 ttl: number; 26503} 26504`,Pn=`/** 26505 * Template Generator Types 26506 * 26507 * Type definitions for AI-powered template generation feature. 26508 * Used by the template wizard UI and dataApi generate endpoint. 26509 */ 26510 26511// ============================================================================ 26512// Enums and Basic Types 26513// ============================================================================ 26514 26515export type TemplateTone = 'professional' | 'friendly' | 'urgent' | 'playful' | 'minimal'; 26516 26517export type TemplateLength = 'short' | 'medium' | 'detailed'; 26518 26519export type TemplateChannelType = 'email' | 'sms' | 'site'; 26520 26521export const VALID_TEMPLATE_TONES: TemplateTone[] = [ 26522 'professional', 26523 'friendly', 26524 'urgent', 26525 'playful', 26526 'minimal', 26527]; 26528 26529export const VALID_TEMPLATE_LENGTHS: TemplateLength[] = ['short', 'medium', 'detailed']; 26530 26531export const VALID_TEMPLATE_CHANNELS: TemplateChannelType[] = ['email', 'sms', 'site']; 26532 26533// ============================================================================ 26534// Request/Response Types 26535// ============================================================================ 26536 26537/** 26538 * Request body for POST /data/templates/{tenantId}/{channelType}/generate 26539 */ 26540export interface GenerateTemplateRequest { 26541 /** Tenant ID (from URL path, validated against user access) */ 26542 tenantId: string; 26543 26544 /** Channel type: email, sms, or site modal */ 26545 channelType: TemplateChannelType; 26546 26547 /** Campaign type determines available variables (abandon_cart, welcome, etc.) */ 26548 campaignType: string; 26549 26550 /** Tone of the generated template */ 26551 tone: TemplateTone; 26552 26553 /** Length/detail level of the template */ 26554 length: TemplateLength; 26555 26556 /** Whether discount is enabled (unlocks discountPercent, discountCode, etc.) */ 26557 hasDiscount: boolean; 26558 26559 /** Whether draft order is enabled (unlocks savings, freeItems, etc.) */ 26560 hasDraftOrder: boolean; 26561 26562 /** Asset names from tenant library to include in prompt */ 26563 selectedAssets: string[]; 26564 26565 /** Optional brand notes/instructions for the AI */ 26566 brandNotes?: string; 26567 26568 /** For email: whether to generate subject line */ 26569 generateSubject?: boolean; 26570 26571 /** 26572 * Optional campaign ID to load full campaign context. 26573 * When provided, the campaign is fetched from DynamoDB and serialized 26574 * into the prompt, providing the LLM with detailed campaign information 26575 * (discount codes, values, channels, eligibility rules, etc.) 26576 */ 26577 campaignId?: string; 26578} 26579 26580/** 26581 * Generated template content from AI 26582 */ 26583export interface GeneratedTemplateContent { 26584 /** HTML content for email or site modal */ 26585 html?: string; 26586 26587 /** Plain text content (required for all channels) */ 26588 text: string; 26589 26590 /** Email subject line (only for email channel with generateSubject=true) */ 26591 subject?: string; 26592} 26593 26594/** 26595 * Token usage information for billing 26596 */ 26597export interface TemplateGenerationTokenUsage { 26598 /** Number of tokens in the prompt */ 26599 promptTokens: number; 26600 26601 /** Number of tokens in the completion */ 26602 completionTokens: number; 26603 26604 /** Total cost in dollars */ 26605 cost: number; 26606} 26607 26608/** 26609 * Response from POST /data/templates/{tenantId}/{channelType}/generate 26610 */ 26611export interface GenerateTemplateResponse { 26612 /** Generated template content */ 26613 content: GeneratedTemplateContent; 26614 26615 /** List of variables used in the generated template */ 26616 usedVariables: string[]; 26617 26618 /** List of asset names included in the template */ 26619 usedAssets: string[]; 26620 26621 /** Token usage for billing purposes */ 26622 tokenUsage: TemplateGenerationTokenUsage; 26623 26624 /** Knowledge sources used during generation (for frontend viewer) */ 26625 knowledgeSources?: import('./knowledge.js').KnowledgeSource[]; 26626} 26627 26628// ============================================================================ 26629// Template Modification Types 26630// ============================================================================ 26631 26632/** 26633 * Request body for POST /data/templates/{tenantId}/{channelType}/modify 26634 * Used to modify an existing template with AI assistance. 26635 */ 26636export interface ModifyTemplateRequest { 26637 /** Tenant ID (from URL path, validated against user access) */ 26638 tenantId: string; 26639 26640 /** Channel type: email, sms, or site modal */ 26641 channelType: TemplateChannelType; 26642 26643 /** Template ID being modified */ 26644 templateId: string; 26645 26646 /** Current template content to modify */ 26647 currentContent: string; 26648 26649 /** User's modification instruction (e.g., "Make it more urgent") */ 26650 modificationPrompt: string; 26651 26652 /** Current subject line for email templates */ 26653 currentSubject?: string; 26654} 26655 26656/** 26657 * Response from POST /data/templates/{tenantId}/{channelType}/modify 26658 */ 26659export interface ModifyTemplateResponse { 26660 /** Modified template content */ 26661 content: GeneratedTemplateContent; 26662 26663 /** Token usage for billing purposes */ 26664 tokenUsage: TemplateGenerationTokenUsage; 26665} 26666 26667/** 26668 * Type guard to check if a request is a ModifyTemplateRequest 26669 */ 26670export function isModifyTemplateRequest(request: unknown): request is ModifyTemplateRequest { 26671 return ( 26672 typeof request === 'object' && 26673 request !== null && 26674 'modificationPrompt' in request && 26675 'currentContent' in request && 26676 'templateId' in request 26677 ); 26678} 26679 26680// ============================================================================ 26681// Template Variable Types 26682// ============================================================================ 26683 26684/** 26685 * All available template variables with their metadata 26686 */ 26687export interface TemplateVariableInfo { 26688 /** Variable name (without braces) */ 26689 name: string; 26690 26691 /** Human-readable description */ 26692 description: string; 26693 26694 /** Data source path */ 26695 source: string; 26696 26697 /** Default value if data is missing */ 26698 defaultValue: string; 26699 26700 /** Example value for AI context */ 26701 example: string; 26702} 26703 26704/** 26705 * Campaign types that have access to checkout event data 26706 */ 26707export const CHECKOUT_CAMPAIGN_TYPES = ['abandon_cart', 'abandon_checkout'] as const; 26708 26709/** 26710 * Get available variables for a campaign type and discount configuration 26711 */ 26712export function getAvailableVariables( 26713 campaignType: string, 26714 hasDiscount: boolean, 26715 hasDraftOrder: boolean 26716): string[] { 26717 // Always available 26718 const variables = ['name', 'link']; 26719 26720 // Checkout event variables 26721 if (CHECKOUT_CAMPAIGN_TYPES.includes(campaignType as typeof CHECKOUT_CAMPAIGN_TYPES[number])) { 26722 variables.push('productName', 'cartTotal'); 26723 } 26724 26725 // Discount variables (when discount is enabled) 26726 if (hasDiscount) { 26727 variables.push('discountPercent', 'discountCode', 'freeDiscountCode'); 26728 } 26729 26730 // Draft order variables (only for abandon_checkout with draft order) 26731 if (hasDraftOrder && campaignType === 'abandon_checkout') { 26732 variables.push('savings', 'freeItems', 'discountDraftInvoiceTotal'); 26733 } 26734 26735 return variables; 26736} 26737 26738// ============================================================================ 26739// Validation Helpers 26740// ============================================================================ 26741 26742export function isValidTemplateTone(tone: string): tone is TemplateTone { 26743 return VALID_TEMPLATE_TONES.includes(tone as TemplateTone); 26744} 26745 26746export function isValidTemplateLength(length: string): length is TemplateLength { 26747 return VALID_TEMPLATE_LENGTHS.includes(length as TemplateLength); 26748} 26749 26750export function isValidTemplateChannel(channel: string): channel is TemplateChannelType { 26751 return VALID_TEMPLATE_CHANNELS.includes(channel as TemplateChannelType); 26752} 26753`,Rn=`/** 26754 * Tenant Inbound Policy Types 26755 * 26756 * Type definitions for inbound phone call routing policies and phone registry. 26757 * Source of truth: terraform/SHARED/create-dynamo-tenant-inbound-policy/ 26758 */ 26759 26760// ============================================================================ 26761// Inbound Policy Configuration 26762// ============================================================================ 26763 26764export type OverflowStrategy = 'queue' | 'reject' | 'voicemail'; 26765export type AfterHoursPolicy = 'voicemail' | 'reject' | 'forward'; 26766 26767export const VALID_OVERFLOW_STRATEGIES: OverflowStrategy[] = ['queue', 'reject', 'voicemail']; 26768export const VALID_AFTER_HOURS_POLICIES: AfterHoursPolicy[] = ['voicemail', 'reject', 'forward']; 26769 26770export interface TenantInboundPolicy { 26771 tenantName: string; 26772 maxConcurrentCalls: number; 26773 overflowStrategy: OverflowStrategy; 26774 afterHoursPolicy: AfterHoursPolicy; 26775 updatedAt?: string; 26776} 26777 26778// ============================================================================ 26779// Inbound Phone Registry 26780// ============================================================================ 26781 26782export type PhoneStatus = 'active' | 'inactive' | 'resolver' | 'test'; 26783export type PhoneCountry = 'AU' | 'NZ' | 'US' | 'UK'; 26784export type PhoneChannel = 'google' | 'facebook' | 'tv' | 'direct'; 26785 26786export const VALID_PHONE_STATUSES: PhoneStatus[] = ['active', 'inactive', 'resolver', 'test']; 26787export const VALID_PHONE_COUNTRIES: PhoneCountry[] = ['AU', 'NZ', 'US', 'UK']; 26788export const VALID_PHONE_CHANNELS: PhoneChannel[] = ['google', 'facebook', 'tv', 'direct']; 26789 26790/** 26791 * Inbound phone number entry in the registry 26792 */ 26793export interface InboundPhoneEntry { 26794 // Core identifier (DNIS - Dialed Number Identification Service) 26795 dnis: string; 26796 26797 // Status of the phone number 26798 status: PhoneStatus; 26799 26800 // IVR configuration 26801 ivrName: string; 26802 ivrDescription: string; 26803 26804 // Optional fields for active numbers 26805 country?: PhoneCountry; 26806 brand?: string; 26807 tenant?: string; 26808 26809 // MAX contact center configuration 26810 maxOwner?: string; 26811 list?: string; 26812 26813 // Marketing attribution 26814 channel?: PhoneChannel; 26815 campaign?: string; 26816 agentProfile?: string; 26817} 26818 26819/** 26820 * Inbound phone registry containing all phone number mappings 26821 */ 26822export interface InboundPhoneRegistry { 26823 version: number; 26824 description: string; 26825 numbers: InboundPhoneEntry[]; 26826} 26827 26828// ============================================================================ 26829// DynamoDB Record Types 26830// ============================================================================ 26831 26832/** 26833 * Tenant inbound policy as stored in DynamoDB 26834 */ 26835export interface TenantInboundPolicyRecord extends TenantInboundPolicy { 26836 // DynamoDB uses tenantName as partition key 26837} 26838 26839// ============================================================================ 26840// Validation Helpers 26841// ============================================================================ 26842 26843/** 26844 * Check if a phone entry is an active entry with full configuration 26845 */ 26846export function isActivePhoneEntry( 26847 entry: InboundPhoneEntry 26848): entry is InboundPhoneEntry & { tenant: string; country: PhoneCountry } { 26849 return entry.status === 'active' && !!entry.tenant && !!entry.country; 26850} 26851 26852/** 26853 * Check if a phone entry is a resolver or test entry 26854 */ 26855export function isSpecialPhoneEntry(entry: InboundPhoneEntry): boolean { 26856 return entry.status === 'resolver' || entry.status === 'test'; 26857} 26858`,Mn=`/** 26859 * Tenant Outbound Policy Types 26860 * 26861 * Type definitions for outbound phone call routing policies and phone registry. 26862 * Source of truth: terraform/SHARED/create-dynamo-tenant-outbound-policy/ 26863 */ 26864 26865// ============================================================================ 26866// Shared Types (from inbound policy) 26867// ============================================================================ 26868 26869import type { PhoneStatus, PhoneCountry, PhoneChannel } from './tenant-inbound-policy.js'; 26870 26871// Re-export for convenience
26872export type { PhoneStatus, PhoneCountry, PhoneChannel }; 26873 26874// ============================================================================ 26875// Outbound Phone Registry 26876// ============================================================================ 26877 26878// Extended channel type for outbound (includes 'test') 26879export type OutboundPhoneChannel = PhoneChannel | 'test'; 26880export const VALID_OUTBOUND_PHONE_CHANNELS: OutboundPhoneChannel[] = [ 26881 'google', 26882 'facebook', 26883 'tv', 26884 'direct', 26885 'test', 26886]; 26887 26888/** 26889 * Outbound phone number entry in the registry (destination-based) 26890 */ 26891export interface OutboundPhoneEntry { 26892 // Core identifier (destination phone number) 26893 destinationNumber: string; 26894 26895 // Status 26896 status: PhoneStatus; 26897 26898 // Geographic and brand info (required for active entries) 26899 country?: PhoneCountry; 26900 brand?: string; 26901 tenant?: string; 26902 26903 // MAX contact center configuration (required for active entries) 26904 maxOwner?: string; 26905 list?: string; 26906 26907 // Marketing attribution (optional) 26908 channel?: OutboundPhoneChannel; 26909 26910 // Optional extended fields 26911 campaign?: string; 26912 agentProfile?: string; 26913} 26914 26915/** 26916 * Outbound phone registry containing all phone number mappings 26917 */ 26918export interface OutboundPhoneRegistry { 26919 version: number; 26920 description: string; 26921 numbers: OutboundPhoneEntry[]; 26922} 26923 26924// ============================================================================ 26925// ALL_PHONES (MAX System Export) 26926// ============================================================================ 26927 26928/** 26929 * Phone entry from MAX system export 26930 * This is a raw export format with more metadata 26931 */ 26932export interface MaxPhoneEntry { 26933 // Core identifier (DNIS) 26934 dnis: string; 26935 26936 // Description and metadata 26937 description: string; 26938 reservedUntil: string; 26939 26940 // MAX configuration 26941 maxOwner: string; 26942 tags: string[]; 26943 campaign: string; 26944 list: string; 26945 26946 // IVR configuration 26947 ivrName: string; 26948 ivrDescription: string; 26949 iceRoute: string; 26950} 26951 26952/** 26953 * MAX phone registry (ALL_PHONES.json format) 26954 */ 26955export interface MaxPhoneRegistry { 26956 version: number; 26957 description: string; 26958 numbers: MaxPhoneEntry[]; 26959} 26960 26961// ============================================================================ 26962// Validation Constants 26963// ============================================================================ 26964 26965// Re-use constants from inbound policy 26966export { 26967 VALID_PHONE_STATUSES, 26968 VALID_PHONE_COUNTRIES, 26969 VALID_PHONE_CHANNELS, 26970} from './tenant-inbound-policy.js'; 26971 26972// ============================================================================ 26973// Validation Helpers 26974// ============================================================================ 26975 26976/** 26977 * Check if an outbound phone entry has marketing attribution 26978 */ 26979export function hasMarketingAttribution(entry: OutboundPhoneEntry): boolean { 26980 return !!entry.channel; 26981} 26982 26983/** 26984 * Get all unique tenants from an outbound phone registry 26985 */ 26986export function getUniqueTenants(registry: OutboundPhoneRegistry): string[] { 26987 const tenants = new Set<string>(); 26988 registry.numbers.forEach((entry) => { 26989 if (entry.tenant) { 26990 tenants.add(entry.tenant); 26991 } 26992 }); 26993 return Array.from(tenants); 26994} 26995`,On=`/** 26996 * Tenant Configuration Types 26997 * 26998 * Type definitions for tenant configuration stored in DynamoDB tenant table. 26999 * Source of truth: terraform/SHARED/create-dynamo-tenant/tenants.json 27000 */ 27001 27002import type { FreightIntegrationsConfig } from './freight.js'; 27003 27004// ============================================================================ 27005// Custom Domain 27006// ============================================================================ 27007 27008export interface CustomDomain { 27009 domain: string; 27010 tenantId: string; 27011} 27012 27013// ============================================================================ 27014// Tool Definitions 27015// ============================================================================ 27016 27017export interface ToolParameter { 27018 type: 'string' | 'number' | 'boolean'; 27019 format?: 'phone' | 'email'; 27020 description: string; 27021 required: boolean; 27022} 27023 27024export interface ToolDefinition { 27025 name: string; 27026 description: string; 27027 type: 'function'; 27028 parameters: Record<string, ToolParameter>; 27029} 27030 27031export interface TenantTools { 27032 definitions: ToolDefinition[]; 27033} 27034 27035// ============================================================================ 27036// Lead Categorisation Rules 27037// ============================================================================ 27038 27039// Import from dedicated lead-rules module (re-export for consumers) 27040import type { 27041 EventMatchMode, 27042 PriorEventOperator, 27043 PriorEventEntry, 27044 EventCategoryRule, 27045 LeadCategorisationRules, 27046 TenantLeadRules, 27047} from './lead-rules.js'; 27048 27049import type { SiteChatConfig } from './chat.js'; 27050import type { DistributionConfig } from './agent-distribution.js'; 27051 27052export type { 27053 EventMatchMode, 27054 PriorEventOperator, 27055 PriorEventEntry, 27056 EventCategoryRule, 27057 LeadCategorisationRules, 27058 TenantLeadRules, 27059}; 27060 27061// ============================================================================ 27062// Contact Info Configuration 27063// ============================================================================ 27064 27065export interface SmsContactConfig { 27066 twilioPhoneNumber: string; 27067 messagingServiceSid?: string; 27068} 27069 27070/** 27071 * One of a business's platform mailboxes (3 Oct 2026, plans/platform/business-mailboxes-on-the-platform.md). 27072 * customer = today's pipeline (Lead, workflows, Messages); business = filed, never a Lead. 27073 */ 27074export interface MailboxConfig { 27075 localPart: string; 27076 /** 'reply' = on the reply subdomain (team@mail.<domain>); 'root' = on the domain itself */ 27077 host: 'root' | 'reply'; 27078 kind: 'customer' | 'business'; 27079 displayName?: string; 27080} 27081 27082export interface EmailContactConfig { 27083 from: string;
27084 fromName: string; 27085 configurationSetName?: string; 27086 replySubdomain?: string; // e.g. "reply" -> Reply-To uses {local}@reply.{domain} 27087 /** 27088 * New businesses only (Chris, 3 Oct 2026): sales/service (customer) + accounts/ops/admin (business). 27089 * Its presence switches on mailbox classification for the tenant; absent = today's behaviour. 27090 */ 27091 mailboxes?: Record<string, MailboxConfig>; 27092 /** sender addresses or domains that are never customers (suppliers, 3PL, carriers, accountant) */ 27093 neverCustomer?: string[]; 27094 /** 27095 * The signature the sender appends to staff and business-mailbox emails (Chris, 4 Oct 2026: 27096 * supplier enquiries from ops@ need a professional signature with the logo). Every field is 27097 * optional: the sender derives what is missing from the row â businessName from \`nickname\`, 27098 * website from \`baseUrl\`, logoUrl from \`logoUrl\`, phone from the primary Twilio number. 27099 */ 27100 signature?: EmailSignatureConfig; 27101} 27102 27103export interface EmailSignatureConfig { 27104 businessName?: string; 27105 tagline?: string; 27106 /** shown as typed, e.g. "0485 001 991" */ 27107 phone?: string; 27108 /** shown without the scheme, linked with it */ 27109 website?: string; 27110 address?: string; 27111 abn?: string; 27112 logoUrl?: string; 27113 /** rendered width of the logo in the signature, px (default 140) */ 27114 logoWidthPx?: number; 27115} 27116 27117export interface TestContactConfig { 27118 phoneNumber?: string; 27119 emailAddress?: string; 27120 /** Engine \`testMode\` destination for the RESELLER leg of a reseller 27121 * appointment SMS (the customer leg uses \`phoneNumber\`). Point it at a 27122 * dev-subaccount Twilio number so the leg is inspectable in the Twilio 27123 * console without texting any real reseller. E.164 without \`+\`. */ 27124 resellerPhoneNumber?: string; 27125} 27126 27127export interface ContactInfo { 27128 sms?: SmsContactConfig; 27129 email?: EmailContactConfig; 27130 test?: TestContactConfig; 27131} 27132 27133// ============================================================================ 27134// NEW: Integrations Configuration (replaces scattered SSM paths) 27135// ============================================================================ 27136 27137/** 27138 * Shopify onboarding status - stamped on the tenant record 27139 * as each setup step is completed (via wizard or API verification). 27140 */ 27141export interface ShopifyOnboarding { 27142 credentialsStored?: boolean; 27143 webhooksRegistered?: boolean; 27144 webhooksCount?: number; 27145 pixelInstalled?: boolean; 27146 formFanoutDeployed?: boolean; 27147 formFanoutEnabled?: boolean; 27148 lastVerifiedAt?: string; 27149} 27150 27151/** 27152 * Shopify e-commerce integration 27153 */ 27154export interface ShopifyIntegration { 27155 enabled: boolean; 27156 productSummaryMode?: 'recent' | 'all'; 27157 /** 27158 * Shopify Partners app client_id (for managed-install/Token Exchange apps). 27159 * Stamped by the Token Exchange lambda on first install; used by store-health 27160 * and onboarding UI to resolve the per-app SSM access token. 27161 */ 27162 clientId?: string; 27163 secrets: { 27164 apiSecretSsm: string; 27165 accessTokenSsm: string; 27166 apiKeySsm?: string; 27167 }; 27168 onboarding?: ShopifyOnboarding; 27169} 27170 27171/** 27172 * Stripe payments integration â for tenants that take money through Stripe 27173 * instead of a Shopify storefront (consumer-app tenants like Delta X Coach, 27174 * where checkout lives in the tenant's own app). 27175 * 27176 * â ï¸ This is NOT the platform's own prepaid billing (\`Tenant.billing\`, which 27177 * bills the tenant for Shopzen usage). This node describes where the tenant's 27178 * CUSTOMERS pay. A tenant may have both. 27179 * 27180 * Purchases themselves are NOT read from Stripe: the tenant's app posts them 27181 * into \`events-customer\` as \`order.confirmed\` / \`order.cancelled\` events and 27182 * \`create-lead-and-identity\` stamps \`Lead.stripeCustomerId\`. This node only 27183 * says "this tenant's money arrives via Stripe" and carries the ids needed to 27184 * deep-link an operator into the Stripe dashboard. 27185 */ 27186export interface StripePaymentsIntegration { 27187 enabled: boolean; 27188 /** Stripe account the customers/charges live on (\`acct_â¦\`). Used to build 27189 * dashboard deep links, and (future) the \`Stripe-Account\` header when the 27190 * account is connected via Connect DIRECT charges â a platform-level lookup 27191 * returns nothing for a customer that lives on the connected account. */ 27192 accountId?: string; 27193 /** Dashboard origin override. Defaults to \`https://dashboard.stripe.com\`. 27194 * Set to the test-mode origin for a tenant still running on test keys. */ 27195 dashboardBaseUrl?: string; 27196 /** SSM path to the tenant's Stripe secret key. RESERVED â nothing reads this 27197 * yet. It exists for a future live-read toolcall (card on file, subscription 27198 * state, delinquency), which the event history cannot answer. */ 27199 secretKeySsm?: string; 27200} 27201 27202/** 27203 * Zoho CRM integration 27204 */ 27205export interface ZohoIntegration { 27206 enabled: boolean; 27207 clientIdSsm: string; 27208 clientSecretSsm: string; 27209 refreshTokenSsm: string; 27210 apiDomainSsm: string; 27211 accountsDomainSsm: string; 27212 redirectUriSsm: string; 27213} 27214 27215/** 27216 * Wicked Reports CRM integration 27217 */ 27218export interface WickedIntegration { 27219 enabled: boolean; 27220 apiKeySsm: string; 27221} 27222 27223/** 27224 * Max (telephony) integration 27225 */ 27226export interface MaxIntegration { 27227 enabled: boolean; 27228 userSsm: string; 27229 passwordSsm: string; 27230} 27231 27232/** 27233 * Aircall (telephony) integration 27234 */ 27235export interface AircallIntegration { 27236 enabled: boolean; 27237 apiKeySsm: string; // SSM path for Aircall API key (access_key) 27238 apiIdSsm: string; // SSM path for Aircall API client ID (client_key) 27239 webhookTokenSsm?: string; // SSM path for webhook validation token 27240} 27241 27242/** 27243 * Twilio phone number with capabilities 27244 */ 27245export interface TwilioPhoneNumber { 27246 /** Phone number in E.164 format (e.g., +61400000000) */ 27247 phoneNumber: string; 27248 /** Twilio Phone Number SID (PNxxxxxxx) */ 27249 sid: string; 27250 /** Friendly name for the number */ 27251 friendlyName?: string; 27252 /** Type of phone number (mobile, local, tollFree, national) */ 27253 numberType?: 'mobile' | 'local' | 'tollFree' | 'national'; 27254 /** Phone number capabilities */ 27255 capabilities?: { 27256 sms: boolean; 27257 voice: boolean; 27258 mms: boolean; 27259 }; 27260 /** Whether this is the primary number for outbound SMS */ 27261 isPrimary?: boolean; 27262 /** ISO timestamp when the number was provisioned */ 27263 provisionedAt: string; 27264 /** ISO timestamp when the number was last billed (for recurring monthly billing) */ 27265 lastBilledAt?: string; 27266 /** Phone number to forward incoming voice calls to (E.164 format). Undef
27266ined = no forwarding. */ 27267 forwardingNumber?: string; 27268 /** 27269 * How an inbound call to this number is answered (Chris, 4 Oct 2026: the DEFAULT is voicemail â 27270 * a neutral female greeting naming the business, the message recorded, transcribed after the call 27271 * and raised as a sales / service / business action; numbers are not forwarded to a person). 27272 * - \`voicemail\` (default when unset and there is no forwardingNumber; an explicit \`voicemail\` 27273 * wins over a forwardingNumber): greeting + <Record> â POST /twilio/voicemail. 27274 * - \`forward\`: today's <Dial><Number forwardingNumber>. 27275 * - \`softphone\`: owner-first <Dial><Client> (needs ownerFirstInboundEnabled + callSoftphoneEnabled). 27276 */ 27277 voiceMode?: 'voicemail' | 'forward' | 'softphone'; 27278 /** Voicemail settings; every field optional, defaults derive from the tenant nickname. */ 27279 voicemail?: { 27280 /** The spoken greeting. Default: "We're sorry, we can't answer the phone right now. This is <nickname>. Please leave a message after the tone and we'll get back to you." */ 27281 greeting?: string; 27282 /** Twilio <Say> voice id. Default Polly.Olivia-Neural (Australian English, neutral female). */ 27283 voice?: string; 27284 /** Max message length in seconds. Default 120. */ 27285 maxSeconds?: number; 27286 }; 27287 /** 27288 * Opt this number in to owner-first inbound routing: ring the lead owner's 27289 * browser softphone before falling back to \`forwardingNumber\`. 27290 * 27291 * â OFF unless explicitly set, and it must stay off on production numbers. 27292 * Operator rule (Chris, 23 Aug 2026): the Twilio mobile number makes OUTBOUND 27293 * calls only; inbound to it forwards to the tenant's 1300 and does nothing 27294 * else. Only dev-masseuse sets this. 27295 * 27296 * BOTH this AND \`tenant.callSoftphoneEnabled\` must be true for owner-first to 27297 * fire. The tenant flag alone is NOT a safe gate â it also enables outbound 27298 * softphone dialling (twiml-softphone-outbound), so it is true in production 27299 * and cannot be turned off to suppress inbound routing. See voice-forward.ts. 27300 */ 27301 ownerFirstInboundEnabled?: boolean; 27302} 27303 27304/** 27305 * Twilio subaccount integration for tenant-level isolation 27306 */ 27307export interface TwilioIntegration { 27308 /** Whether Twilio integration is enabled for this tenant */ 27309 enabled: boolean; 27310 /** Twilio Subaccount SID (ACxxxxxxx) */ 27311 subaccountSid?: string; 27312 /** SSM path for subaccount auth token: /keys/tenant/{tenantId}/twilio-auth-token */ 27313 authTokenSsm?: string; 27314 /** Friendly name for the subaccount */ 27315 friendlyName?: string; 27316 /** Subaccount status */ 27317 status?: 'active' | 'suspended' | 'closed'; 27318 /** Provisioned phone numbers for this tenant */ 27319 phoneNumbers?: TwilioPhoneNumber[]; 27320 /** ISO timestamp when the subaccount was created */ 27321 createdAt?: string; 27322 /** Cloned regulatory bundles for this subaccount (keyed by country code, e.g., "AU") */ 27323 regulatoryBundles?: Record<string, { 27324 /** The cloned bundle SID in this subaccount */ 27325 bundleSid: string; 27326 /** The address SID associated with the bundle (if any) */ 27327 addressSid?: string; 27328 /** ISO timestamp when the bundle was cloned */ 27329 clonedAt: string; 27330 }>; 27331} 27332 27333/** 27334 * Missive (Happiness Team shared inbox) integration â 15 Sep 2026. 27335 * 27336 * ONE Missive organisation serves BOTH Masseuse brands, and its single Twilio SMS number 27337 * (+61 480 093 791, in a Twilio account we do not control) is shared by both, so a Missive 27338 * message cannot be attributed to a tenant from the number alone. The webhook adapter 27339 * (toolcall-missive-webhook) reads every tenant row carrying an ENABLED missive config 27340 * and resolves the tenant in this order: Missive team id â mailbox email domain â the 27341 * customer's phone found on a Lead of exactly one candidate tenant â the tenant flagged 27342 * \`isDefaultForSharedSms\`. Routing metadata, not an access control. 27343 */ 27344export interface MissiveIntegration { 27345 enabled: boolean; 27346 /** Missive organisation id (Settings > API > Resource IDs) */ 27347 organizationId?: string; 27348 /** Missive account ids (mailboxes / SMS numbers) that belong to this tenant */ 27349 accountIds?: string[]; 27350 /** Missive team ids whose conversations belong to this tenant (conversation.team.id) */ 27351 teamIds?: string[]; 27352 /** Email domains of this tenant's Missive mailboxes, e.g. masseusemassage.com.au */ 27353 emailDomains?: string[]; 27354 /** SMS numbers connected in Missive for this tenant, E.164 (may be shared with another tenant) */ 27355 phoneNumbers?: string[]; 27356 /** When an SMS on a shared number cannot be attributed, it lands on this tenant */ 27357 isDefaultForSharedSms?: boolean; 27358 /** 27359 * Lower-case phrases that name THIS brand in message text ("masseuse massage chairs", 27360 * "masseusehealth.co"). A conversation mentioning exactly one brand is attributed to it 27361 * before the Lead lookup â the way the shared SMS number gets split between MMC and MHC. 27362 */ 27363 brandKeywords?: string[]; 27364 /** SSM path of the rule webhook signing secret (shared across the org's tenants) */ 27365 webhookSecretSsm?: string; 27366 /** SSM path of the Missive personal API token used to fetch full message bodies */ 27367 apiTokenSsm?: string; 27368} 27369 27370/** 27371 * Meta/Facebook integration 27372 */ 27373export interface MetaIntegration { 27374 enabled: boolean; 27375 appIdSsm: string; 27376 appSecretSsm: string; 27377 pageTokenSsm?: string; 27378 pageId?: string; 27379 /** Facebook Ad Account ID (without 'act_' prefix) for ad preview listing */ 27380 adAccountId?: string; 27381 /** SSM path for Facebook System User token with ads_read permission (e.g., /keys/{tenant}/facebook-system-user-token) */ 27382 systemUserTokenSsm?: string; 27383 /** 27384 * Messenger channel claim. Routing registry for inbound Messenger webhooks: 27385 * a pageId listed here routes that page's conversations to THIS tenant. 27386 * Exactly one tenant may claim a given pageId (bare \`pageId\` above is ambiguous â 27387 * several tenants share a page for ads attribution; only the claim here routes messages). 27388 * Written by BigM/aws-scripts/connect-messenger/connect-messenger.mjs. 27389 */ 27390 messenger?: { 27391 pageIds: string[]; 27392 }; 27393 /** 27394 * Social publishing claim (14 Sep 2026). Present + enabled â the improvement engine's 27395 * \`social-presence\` check may propose posts for this tenant and the dataApi may publish 27396 * an approved one. Its OWN token pointers: the parent \`pageTokenSsm\` may point at a 27397 * Messenger-only token minted by another app; posting must never depend on that. 27398 */ 27399 publishing?: SocialPublishingConfig; 27400} 27401
27402/** 27403 * Per-tenant social publishing configuration. Fail closed: absent or \`enabled:false\` 27404 * means no proposal, no publish. Tokens are SSM pointers resolved at call time. 27405 */ 27406export interface SocialPublishingConfig { 27407 enabled: boolean; 27408 /** Instagram business account id. Absent â Page-only publishing. */ 27409 igBusinessId?: string; 27410 /** Facebook Page id the posts go to (and the IG account is linked to). */ 27411 pageId: string; 27412 /** SSM path of a PAGE token carrying pages_manage_posts + read_insights. */ 27413 pageTokenSsm: string; 27414 /** SSM path of the SYSTEM-USER token carrying the instagram_* publishing scopes. */ 27415 systemUserTokenSsm: string; 27416 /** Who must have said yes before Approve is enabled on the board. */ 27417 approver: 'owner' | 'operator'; 27418 /** The name the card asks for ("Jani said yes?"). */ 27419 ownerLabel?: string; 27420} 27421 27422/** 27423 * OpenAI integration 27424 */ 27425export interface OpenAIIntegration { 27426 enabled: boolean; 27427 projectId?: string; 27428 apiKeySsm: string; 27429} 27430 27431/** 27432 * Tavily (web search) integration 27433 */ 27434export interface TavilyIntegration { 27435 enabled: boolean; 27436 secretSsm: string; 27437} 27438 27439/** 27440 * Moonshot (Kimi) LLM integration 27441 */ 27442export interface MoonshotIntegration { 27443 enabled: boolean; 27444 apiKeySsm: string; 27445 apiBaseUrl?: string; 27446} 27447 27448/** 27449 * Anthropic (Claude) LLM integration 27450 */ 27451export interface AnthropicIntegration { 27452 enabled: boolean; 27453 apiKeySsm: string; 27454} 27455 27456/** 27457 * Google AI (Vertex AI / Gemini / Nano Banana Pro / Veo) integration. 27458 * 27459 * Per-tenant GCP project for billing + quota isolation, mirroring the 27460 * project-per-tenant pattern used by OpenAI. Auth is via a service-account 27461 * JSON stored as an SSM SecureString (NOT a long-lived API key). 27462 */ 27463export interface GoogleAIIntegration { 27464 enabled: boolean; 27465 /** GCP project ID, e.g. "bigm-everydayassist-ai". Used for \`aiplatform\` API calls. */ 27466 projectId: string; 27467 /** 27468 * SSM SecureString path holding the service-account JSON. Convention: 27469 * \`/keys/{tenantId}/google-service-account-json\`. 27470 */ 27471 serviceAccountJsonSsm: string; 27472 /** Vertex AI region. Recommend \`asia-southeast1\` for AU/NZ tenants. */ 27473 region: string; 27474} 27475 27476/** 27477 * Humm (BNPL) integration 27478 */ 27479export interface HummIntegration { 27480 enabled: boolean; 27481 merchantIdSsm: string; 27482 apiKeySsm: string; 27483 /** 27484 * "Retailer Name" values from the Humm merchant portal that belong to this 27485 * tenant (matched case-insensitively on the scraped Retailer Name column). 27486 * A single tenant may own multiple retailer entries in Humm. 27487 */ 27488 retailerNames?: string[]; 27489} 27490 27491/** 27492 * Klaviyo (marketing/forms) integration 27493 */ 27494export interface KlaviyoIntegration { 27495 enabled: boolean; 27496 apiKeySsm: string; 27497} 27498 27499/** 27500 * Xero (accounting) integration - Custom Connection (machine-to-machine) 27501 * Uses OAuth2 client_credentials grant (no refresh token needed) 27502 */ 27503export interface XeroIntegration { 27504 enabled: boolean; 27505 clientIdSsm: string; // SSM path for Xero client_id 27506 clientSecretSsm: string; // SSM path for Xero client_secret 27507 xeroTenantId: string; // Xero organization UUID (required in xero-tenant-id header) 27508 accessTokenSsm: string; // SSM path where refreshed access_token is stored 27509 /** Sales-accounting automation profile (Phase 1). Absent â automation off for this tenant. */ 27510 accountingProfile?: XeroAccountingProfile; 27511} 27512 27513/** dry_run = build+log only (never POST); draft = create DRAFT invoices; live = create AUTHORISED. */ 27514export type XeroPostMode = 'dry_run' | 'draft' | 'live'; 27515 27516/** collapse = MMC/MHC chair-net + install (zero the rest); per_line = one invoice line per order line. */ 27517export type XeroInvoiceMode = 'collapse' | 'per_line'; 27518 27519/** 27520 * Per-tenant description of how a Shopify order maps to a Xero sales invoice. 27521 * The extensibility core: MMC/MHC use collapse + the typed channel map; a simple 27522 * tenant uses per_line + a single default account. 27523 * 27524 * NOTE: the LeadSourceChannelâaccount map itself is a TYPED CODE constant in 27525 * \`@bigm/shared\` (MMC/MHC only) â not stored here â per the design decision. 27526 */ 27527export interface XeroAccountingProfile { 27528 invoiceMode: XeroInvoiceMode; 27529 /** Defaults to 'dry_run' when omitted. Never posts unless explicitly 'draft'/'live'. */ 27530 postMode?: XeroPostMode; 27531 /** Only orders on/after this ISO date (order created_at) are auto-posted â cutover guard. */ 27532 cutoverDate?: string; 27533 /** Whether to apply the typed MMC/MHC LeadSourceChannelâaccount override. Simple tenants: false. */ 27534 useChannelAccountMap?: boolean; 27535 /** 27536 * Base revenue account codes that are "channel-overridable" (e.g. the 41000 D2C 27537 * chair family). When a product's base account is one of these AND useChannelAccountMap 27538 * is true, the channel map replaces it; other product lines (e.g. sauna 43001, 27539 * icebath 42001) keep their own account regardless of channel. 27540 */ 27541 channelOverridableAccounts?: string[]; 27542 /** collapse mode: SKU glob(s) identifying the install/delivery line kept separate (e.g. "WGI-*"). */ 27543 installSkuPatterns?: string[]; 27544 /** Fallback revenue account when neither product nor channel resolves one. */ 27545 defaultAccountCode?: string; 27546 /** Optional Shopify SKU â Xero ItemCode rekey (MMC re-keys; simple tenants omit). */ 27547 skuRekeyMap?: Record<string, string>; 27548 /** Region tag for account/currency selection (e.g. 'AU' | 'NZ'). */ 27549 region?: string; 27550} 27551 27552/** 27553 * SAP ERP integration â OData APIs via SAP Developer Hub 27554 * Auth: x-api-key header + User/Password custom headers 27555 * 27556 * Available OData services: 27557 * - ZSD_DELIVERY_CALENDAR_SRV - Delivery calendar / blocked dates 27558 * - ZSD_INVENTORY_STOCK_SRV - Real-time inventory stock levels 27559 * - ZSD_SALES_ORDER_SRV - Sales order management 27560 * - ZSD_SALES_ORDER_GET_SRV - Sales order retrieval (read-only) 27561 * - ZSD_PURCHASE_ORDER_SRV - Purchase order management 27562 * - ZSD_PURCHASE_ORDER_STO_SRV - Stock transfer orders 27563 */ 27564export interface SapIntegration { 27565 enabled: boolean; 27566 /** SAP API Management base URL (env-specific, no trailing slash) */ 27567 baseUrl: string; 27568 /** SSM path for SAP Application Key (x-api-key header) */ 27569 applicationKeySsm: string; 27570 /** SSM path for SAP username (User header) */ 27571 usernameSsm: string; 27572 /** SSM path for SAP password (Password header) */ 27573 passwordSsm: string; 27574} 27575 27576/** 27577 * Google Ads integration 27578 */ 27579export interface GoogleAdsIntegration { 27580 enabled: boolean; 27581 /** Google Ads Customer ID (no dashes, e.g. "1234567890") */ 27582 customerId: string; 27583 /** MCC/Manager Customer ID (if using MCC account). Optional. */ 27584 loginCustomerId?: string; 27585 /** SSM path for developer token */ 27586 developerTokenSsm: string; 27587 /** SSM path for OAuth2 client ID */ 27588 clientIdSsm: string; 27589 /** SSM path for OAuth2 client secret */ 27590 clientSecretSsm: string; 27591 /** SSM path for OAuth2 refresh token */ 27592 refreshTokenSsm: string; 27593} 27594 27595/** 27596 * Integrations configuration - grouped by category 27597 */ 27598export interface IntegrationsConfig { 27599 ecommerce?: { 27600 shopify?: ShopifyIntegration; 27601 }; 27602 /** Where the tenant's CUSTOMERS pay, when that isn't a Shopify storefront.
27603 * \`ecommerce.shopify\` WINS when both are enabled â Shopify carries orders, 27604 * drafts, customers and products, so it stays the primary surface. */ 27605 payments?: { 27606 stripe?: StripePaymentsIntegration; 27607 }; 27608 crm?: { 27609 zoho?: ZohoIntegration; 27610 wicked?: WickedIntegration; 27611 }; 27612 telephony?: { 27613 twilio?: TwilioIntegration; 27614 max?: MaxIntegration; 27615 aircall?: AircallIntegration; 27616 }; 27617 social?: { 27618 meta?: MetaIntegration; 27619 }; 27620 advertising?: { 27621 googleAds?: GoogleAdsIntegration; 27622 }; 27623 ai?: { 27624 openai?: OpenAIIntegration; 27625 tavily?: TavilyIntegration; 27626 moonshot?: MoonshotIntegration; 27627 anthropic?: AnthropicIntegration; 27628 google?: GoogleAIIntegration; 27629 }; 27630 finance?: { 27631 humm?: HummIntegration; 27632 }; 27633 accounting?: { 27634 xero?: XeroIntegration; 27635 }; 27636 marketing?: { 27637 klaviyo?: KlaviyoIntegration; 27638 }; 27639 /** Shared-inbox tools the tenant's staff work customer messages in (Missive, 15 Sep 2026) */ 27640 messaging?: { 27641 missive?: MissiveIntegration; 27642 }; 27643 erp?: { 27644 sap?: SapIntegration; 27645 }; 27646 /** Freight carriers + per-tenant routing rules (see @bigm/types freight.ts). */ 27647 freight?: FreightIntegrationsConfig; 27648} 27649 27650// ============================================================================ 27651// NEW: Storage Configuration 27652// ============================================================================ 27653 27654export interface StorageConfig { 27655 recordingsPath?: string; // S3 path for call recordings 27656} 27657 27658// ============================================================================ 27659// NEW: Prepaid Billing Configuration 27660// ============================================================================ 27661 27662export type BillingStatus = 'active' | 'low_balance' | 'suspended' | 'free'; 27663 27664export interface AutoTopUpConfig { 27665 enabled: boolean; 27666 threshold: number; // Trigger top-up when balance <= threshold (cents) 27667 amount: number; // Amount to top up (cents) 27668 paymentMethodId?: string; // Saved payment method for auto top-up 27669 hasPaymentMethod?: boolean; // Convenience flag indicating if payment method is saved 27670 27671 // Event-driven auto top-up tracking (for duplicate prevention) 27672 lastAttemptTimestamp?: number; // Unix timestamp (ms) of last auto top-up attempt 27673 lastAttemptStatus?: 'success' | 'failed'; // Result of last attempt 27674 lastAttemptError?: string; // Error message if last attempt failed 27675} 27676 27677export interface LastTopUpInfo { 27678 amount: number; 27679 timestamp: string; // ISO timestamp 27680 checkoutSessionId: string; 27681} 27682 27683export interface BillingConfig { 27684 stripeCustomerId?: string; // Created on first top-up 27685 balance: number; // Current balance in cents (e.g., 5000 = $50.00) 27686 currency: string; // ISO currency code (e.g., "aud") 27687 status: BillingStatus; 27688 autoTopUp?: AutoTopUpConfig; 27689 lastTopUp?: LastTopUpInfo; 27690} 27691 27692// ============================================================================ 27693// NEW: Cost Allocation Pricing Configuration 27694// ============================================================================ 27695 27696/** 27697 * Model-specific token pricing (cents per 1K tokens) 27698 */ 27699export interface TokenPricing { 27700 input: number; // e.g., 0.015 = $0.00015 per 1K tokens 27701 output: number; // e.g., 0.06 = $0.0006 per 1K tokens 27702} 27703 27704/** 27705 * Tenant-specific pricing configuration for cost allocation. 27706 * If not set, falls back to global defaults from SSM: /config/default-pricing 27707 */ 27708export interface TenantPricing { 27709 // Twilio SMS pricing (per segment, in cents) 27710 twilioSms?: { 27711 outbound: number; // e.g., 5 = $0.05 per segment 27712 inbound: number; // e.g., 1 = $0.01 per segment 27713 }; 27714 27715 // Twilio Voice pricing (per minute, in cents) 27716 twilioVoice?: { 27717 outbound: number; // e.g., 12 = $0.12 per minute 27718 inbound: number; // e.g., 3 = $0.03 per minute 27719 }; 27720 27721 // OpenAI pricing (per 1K tokens, in cents) 27722 openai?: { 27723 [model: string]: TokenPricing; 27724 }; 27725 27726 // MoonShot/Kimi pricing (per 1K tokens, in cents) 27727 moonshot?: { 27728 [model: string]: TokenPricing; 27729 }; 27730 27731 // Anthropic/Claude pricing (per 1K tokens, in cents) 27732 anthropic?: { 27733 [model: string]: TokenPricing; 27734 }; 27735 27736 // Tavily search pricing (per search, in cents) 27737 tavily?: { 27738 perSearch: number; // e.g., 1 = $0.01 per search 27739 }; 27740 27741 // AWS SES email pricing (per email, in cents) 27742 email?: { 27743 perEmail: number; // e.g., 1 = $0.01 per email 27744 }; 27745 27746 // Compute pricing for Fargate/EC2 workloads (per minute of audio, in cents) 27747 compute?: { 27748 'fargate-recording-fetch'?: number; // e.g., 0.5 = $0.005 per minute 27749 'ec2-diarize-transcribe'?: number; // e.g., 2.0 = $0.02 per minute 27750 'fargate-voice-agent'?: number; // e.g., 2.0 = $0.02 per minute 27751 }; 27752} 27753 27754// ============================================================================ 27755// Internal Cost Configuration (for ROI calculations, not billing) 27756// ============================================================================ 27757
27758/** Estimated internal costs for ROI calculations (in dollars) */ 27759export interface InternalCostConfig { 27760 /** Estimated staff cost per call */ 27761 callCost?: { 27762 perCall?: number; // e.g., 2.00 = $2.00 per call (dial + connect + wrap-up) 27763 perMinute?: number; // e.g., 1.00 = $1.00 per minute of talk time 27764 }; 27765} 27766 27767// ============================================================================ 27768// App Override Configuration (for Template + Override pattern) 27769// ============================================================================ 27770 27771import type { 27772 AppType, 27773 LlmConfig, 27774 PromptsConfig, 27775 KnowledgeConfig, 27776 AppToolsConfig, 27777 MediaConfig, 27778 AnalysisExtension, 27779 AgentExtension, 27780 LeadAnalysisExtension, 27781 ExpertAnalysisExtension, 27782} from './app-config.js'; 27783 27784/** 27785 * Per-tenant override for app configuration (v2.0). 27786 * These values are merged with shared app templates at runtime. 27787 * 27788 * The structure mirrors UnifiedAppConfig, with all fields optional. 27789 * Deep merge is used: nested objects are merged, arrays are replaced. 27790 * 27791 * @example 27792 * \`\`\`typescript 27793 * const override: AppOverride = { 27794 * enabled: true, 27795 * llm: { 27796 * apiKeySsmPath: '/keys/tenant/moonshot-api-key', 27797 * apiBaseUrl: 'https://api.moonshot.ai/v1' 27798 * }, 27799 * knowledge: { 27800 * sources: ['tenants/masseuse-massage/faq.md'] 27801 * } 27802 * }; 27803 * \`\`\` 27804 */ 27805export interface AppOverride { 27806 /** Whether this app is enabled for the tenant */ 27807 enabled?: boolean; 27808 27809 /** Override LLM configuration */ 27810 llm?: Partial<LlmConfig>; 27811 27812 /** Override prompts configuration */ 27813 prompts?: Partial<PromptsConfig>; 27814 27815 /** Override knowledge sources (replaces array, not merged) */ 27816 knowledge?: Partial<KnowledgeConfig>; 27817 27818 /** Override tools configuration */ 27819 tools?: Partial<AppToolsConfig>; 27820 27821 /** Override media configuration */ 27822 media?: Partial<MediaConfig>; 27823 27824 /** Override extensions */ 27825 extensions?: { 27826 analysis?: Partial<AnalysisExtension>; 27827 agent?: Partial<AgentExtension>; 27828 leadAnalysis?: Partial<LeadAnalysisExtension>; 27829 expertAnalysis?: Partial<ExpertAnalysisExtension>; 27830 }; 27831} 27832 27833/** 27834 * Map of app type to override configuration. 27835 * Used in TenantConfig.appOverrides (LEGACY â see AgentOverridesMap below). 27836 */ 27837export type AppOverridesMap = { 27838 [K in AppType]?: AppOverride; 27839}; 27840 27841/** 27842 * Per-agent override (Phase B of the AI Workloads tab rebuild). 27843 * 27844 * Successor to AppOverride. Same shape (intentional â keeps the override 27845 * editing form reusable), keyed by \`agentId\` from the AI_AGENTS registry 27846 * instead of by AppType. Lambdas resolve overrides via getResolvedAppConfig 27847 * which falls back to AppOverride when no AgentOverride is present. 27848 */ 27849export type AgentOverride = AppOverride; 27850 27851/** 27852 * Map of agent ID to override configuration. 27853 * Used in TenantConfig.agentOverrides â operator-facing surface for 27854 * per-tenant per-agent customisation (model, prompt, knowledge sources). 27855 * 27856 * Keys are \`agentId\` strings from the AI_AGENTS registry 27857 * (\`@bigm/types/ai-agents.ts\`). Treat as untyped string here â the 27858 * registry can grow without bumping this type. 27859 */ 27860export type AgentOverridesMap = { 27861 [agentId: string]: AgentOverride | undefined; 27862}; 27863 27864// ============================================================================ 27865// Video Categories (tenant-configurable video types) 27866// ============================================================================ 27867 27868export interface VideoCategory { 27869 id: string; 27870 name: string; 27871} 27872 27873// ============================================================================ 27874// Asset Links (tenant-curated shareable content for agent messaging) 27875// ============================================================================ 27876 27877export type AssetLinkCategory = 27878 | 'product' 27879 | 'page' 27880 | 'video' 27881 | 'blog' 27882 | 'showroom' 27883 | 'payment' 27884 | 'ndis' 27885 | 'resource'; 27886 27887export interface AssetLink { 27888 id: string; 27889 label: string; 27890 url: string; 27891 category: AssetLinkCategory; 27892 description?: string; 27893 sortOrder?: number; 27894} 27895 27896// ============================================================================ 27897// Feature Flags 27898// ============================================================================ 27899 27900/** 27901 * The \`features\` block on a tenant row. 27902 * 27903 * Written by the terraform seeds, read by \`get-site-modal\`, edited by ShopDash's 27904 * Settings â Features tab, and JSON-parsed by \`getTenantConfig\`'s \`parseJsonFields\` 27905 * â and until now typed nowhere. ShopDash carried a local 27906 * \`{ [key: string]: boolean | undefined }\` which cannot express \`siteModal: 27907 * { enabled }\`, the exact shape that is on prod rows today. 27908 * 27909 * Two shapes live in this block, both real: 27910 * - **object flags** \`{ enabled: boolean }\` â \`siteModal\` (7 live rows), 27911 * \`improvementEngine\`. These are what \`listTenantsWithFeature\` filters on. 27912 * - **bare booleans** â \`supportsWarranty\`, \`supportsDiscounts\`, 27913 * \`supportsFormFanout\`, and \`leads\` / \`events\` on \`dev-nutri-2\`. 27914 * 27915 * Measured 2 Sep 2026 with a full scan of \`tenants\` (16 rows). 27916 */ 27917export interface TenantFeatures { 27918 /** Site modal (the on-site offer popup). Object-shaped; on 7 rows today. */ 27919 siteModal?: { enabled: boolean }; 27920 27921 /** 27922 * Bot form-fill screen at lead creation (17 Sep 2026). When enabled, 27923 * \`create-lead-and-identity\` scores every \`form.submitted\` lead with 27924 * \`scoreBotFormFill\` (@bigm/utils) and stamps \`falsePositive: true\`, 27925 * \`falsePositiveMarkedBy: 'auto'\`, \`falsePositiveReason: 'bot_form_fill'\` 27926 * at or above the threshold, so the lead never reaches the agent queue or the 27927 * workflow engine. Nothing is deleted; the Messages "False lead / undo" button 27928 * reverses it. Absent means OFF. 27929 */ 27930 spamScreen?: { enabled: boolean }; 27931 27932 /** 27933 * Countries whose numbering plans count as a valid phone for this tenant 27934 * (ISO 3166-1 alpha-2). First entry = default country for national-format 27935 * input; \`'*'\` = valid in any country is enough. Read by the spam screen and 27936 * by \`normalizePhoneForRegions\`. Absent â AU/NZ, i.e. today's behaviour. 27937 */ 27938 phoneRegions?: string[]; 27939 27940 /** 27941 * Continuous-improvement engine (\`improvement-engine\`). Object-shaped, and the 27942 * discovery predicate for the whole fleet: \`listTenantsWithFeature('improvementEngine')\` 27943 * is what decides which tenants the engine runs for. Absent means OFF, which is 27944 * deliberate â there is no \`status\`/\`archived\` field on a tenant row, so opt-in 27945 * is the only guard that keeps stale and dev rows out with no list to maintain. 27946 */ 27947 improvementEngine?: { 27948 enabled: boolean; 27949 /** 27950 * Optional allow-list of \`checkId\`s. Absent â every registered check runs. 27951 * The engine keys every run as \`\${tenantId}#\${checkId}\`, so additional 27952 * workloads need no schema change and no new flag. 27953 */ 27954 checks?: string[]; 27955 }; 27956 27957 /** 27958 * Media library sync (\`media-asset-sync\`): catalogue the tenant's Shopify product 27959 * photos and its \`{tenant}/assets/\` uploads into the \`media-assets\` registry every 27960 * 6 hours. Object-shaped so \`listTenantsWithFeature('mediaLibrary')\` can discover 27961 * it; replaces the old hard-coded MMC+MHC allow-list (14 Sep 2026). 27962 */ 27963 mediaLibrary?: { enabled: boolean }; 27964 27965 /** 27966 * Social presence check + publishing behaviour (14 Sep 2026). Object-shaped so it is 27967 * discoverable; the check itself also needs \`integrations.social.meta.publishing\`. 27968 */ 27969 social?: { 27970 enabled: boolean; 27971 /** Posts per 30 days: below \`minPer30d\` â \`below_cadence\`; \`targetPer30d\` is shown as the target. Defaults 8 / 12. */ 27972 cadence?: { minPer30d?: number; targetPer30d?: number }; 27973 /** Deterministic caption rules: strings that must never appear; strings that must appear on the live site when configured. */ 27974 claims?: { absent?: string[]; present?: string[] }; 27975 /** Cap on \`publish_post\` proposals the engine parks per night for this tenant (default 2 â one per account). */ 27976 maxProposalsPerNight?: number; 27977 /** The destination a tracked link points at (bio link target / Page post link). Absent â no link, \`no_link_path\` can never clear. */ 27978 bioLink?: string; 27979 /** Phrase the IG caption may use to point at the bio link, verbatim (default "link in bio"). */ 27980 linkPhrase?: string; 27981 /** Image generation (Phase 4). */ 27982 generation?: { enabled?: boolean; maxImagesPerNight?: number; model?: string; personGeneration?: 'allow_all' | 'allow_adult' | 'dont_allow' }; 27983 }; 27984 27985 /** Warranty registration and claims. Bare boolean. */ 27986 supportsWarranty?: boolean; 27987 27988 /** Whether AI agents may offer a discount. Bare boolean. */ 27989 supportsDiscounts?: boolean; 27990 27991 /** form-fanout theme extension capture. Bare boolean. */ 27992 supportsFormFanout?: boolean; 27993 27994 /** Legacy bare booleans seen on \`dev-nutri-2\` only. Kept so a save cannot drop them. */ 27995 leads?: boolean; 27996 events?: boolean; 27997} 27998 27999/** 28000 * The \`features\` keys that are object-shaped with an \`enabled\` boolean â the only 28001 * ones \`listTenantsWithFeature\` can filter a Scan on. A bare boolean flag like 28002 * \`supportsWarranty\` has no \`.enabled\` and would silently match nothing, so the 28003 * type makes that a compile error rather than an empty result set. 28004 */
28005export type TenantFeatureKey = { 28006 [K in keyof TenantFeatures]-?: NonNullable<TenantFeatures[K]> extends { enabled: boolean } ? K : never; 28007}[keyof TenantFeatures]; 28008 28009// ============================================================================ 28010// Tenant Configuration (Main Type) 28011// ============================================================================ 28012 28013export interface TenantConfig { 28014 // Core identifiers 28015 tenantId: string; 28016 baseUrl: string; 28017 logoUrl: string | null; 28018 28019 /** Human-readable name for the tenant, shown wherever an operator reads a tenant. 28020 * On real rows today (\`Masseuse Massage Chairs\`, \`Masseuse Health Co\`, \`Delta X Coach\`). 28021 * ShopDash's own \`Tenant\` interface has always declared it; \`TenantConfig\` never did. */ 28022 nickname?: string; 28023 28024 /** URL-safe slug for branded campaign links (e.g. 'masseusemassage' â masseusemassage.go.shopzen.ai) */ 28025 brandSlug?: string; 28026 28027 /** "Save our number" contact-card push (vCard). When set, the AI appointment 28028 * workflow appends a per-lead tracked short link to the .vcf on the first 28029 * booking confirmation it sends a lead (durable once-ever stamp 28030 * \`Lead.contactCardSentAt\`). The .vcf is a hosted snapshot of the tenant's 28031 * callable numbers (1300 + Twilio + healthy MAX CLIs) served as text/x-vcard; 28032 * regenerate via BigM/aws-scripts/contact-card-vcard when the CLI set changes. */ 28033 contactCard?: { 28034 /** HTTPS URL of the hosted .vcf (media CDN). */ 28035 vcfUrl: string; 28036 /** Per-lead holdout share [0..1] for answer-rate measurement (FNV-1a on 28037 * \`contact-card-holdout:<leadId>\`, same pattern as the SMS-line experiment). 28038 * Default 0.5; set 0 to send to everyone. */ 28039 holdoutShare?: number; 28040 }; 28041 28042 // Custom domains 28043 customDomains: CustomDomain[]; 28044 28045 // NEW: Grouped integrations (replaces scattered SSM paths) 28046 integrations: IntegrationsConfig; 28047 28048 // NEW: Storage paths 28049 storage?: StorageConfig; 28050 28051 // NEW: Prepaid billing 28052 billing?: BillingConfig; 28053 28054 // NEW: Cost allocation pricing (overrides global defaults) 28055 pricing?: TenantPricing; 28056 28057 // Internal cost estimates for ROI calculations (not billing) 28058 internalCostConfig?: InternalCostConfig; 28059 28060 // Contact info (canonical structure for SMS and email) 28061 contactInfo?: ContactInfo; 28062 28063 // Tool definitions for AI agents 28064 tools?: TenantTools; 28065 28066 // Lead categorisation rules 28067 leadCategorisationRules?: LeadCategorisationRules; 28068 28069 /** Per-appType overrides merged with templates at runtime (LEGACY). 28070 * Read by \`getResolvedAppConfig\`. Phase D will remove this once all 28071 * callers have migrated to \`agentOverrides\`. */ 28072 appOverrides?: AppOverridesMap; 28073 28074 /** Per-agent overrides merged with templates at runtime. 28075 * Keyed by \`agentId\` from the AI_AGENTS registry. Read by 28076 * \`getResolvedAppConfig\` BEFORE \`appOverrides\` (Phase B fallback shim). 28077 * Operator-facing surface: Settings â AI Workloads. */ 28078 agentOverrides?: AgentOverridesMap; 28079 28080 /** Dynamic video categories configurable by tenant admins/operations */ 28081 videoCategories?: VideoCategory[]; 28082 28083 /** Curated shareable asset links for agent messaging */ 28084 assetLinks?: AssetLink[]; 28085 28086 /** Site chat (digital clienteling) configuration */ 28087 chatConfig?: SiteChatConfig; 28088 28089 /** Feature gates. See \`TenantFeatures\`. Absent means every flag is off. */ 28090 features?: TenantFeatures; 28091 28092 /** Agent lead distribution configuration */ 28093 distribution?: DistributionConfig; 28094 28095 /** Lead prioritisation configuration for sequence-aware intent scoring */ 28096 leadPrioritisation?: import('./lead-prioritisation.js').LeadPrioritisationConfig; 28097 28098 /** Sales cycle configuration: tenant-configurable what/when/why dropdown options */ 28099 salesCycleConfig?: import('./lead.js').SalesCycleConfig; 28100 28101 /** 28102 * Master gate for in-browser Twilio softphone (outbound + inbound pickup). 28103 * Undefined/false â all softphone code paths no-op (legacy MaxContact + voice-forward TwiML unchanged). 28104 * Only set true on tenants explicitly opted in. 28105 */ 28106 callSoftphoneEnabled?: boolean; 28107 28108 /** 28109 * Master gate for the CEO Dashboard Recommendation Lifecycle UI 28110 * (Trigger â Analysis â Recommend â Action â Monitor â Close). 28111 * 28112 * Undefined/false â /dashboard/ceo-dashboard renders the LEGACY recommendation 28113 * table only. The "Accept" button keeps its original behaviour (flips 28114 * userStatus on the recommendation row; does NOT promote to a Change). 28115 * 28116 * True â renders the lifecycle banner + open-changes feed + recently-closed 28117 * drawer. The green button reads "Propose Change" and creates a Change row 28118 * via POST /data/changes. 28119 * 28120 * Backend \`/data/changes/*\` routes are always live; this flag gates the UI 28121 * only. Per-tenant rollout (canary on bb7a96-71 first, then masseuse-massage). 28122 */ 28123 ceoLifecycleEnabled?: boolean; 28124 28125 /** 28126 * Per-tenant call channel selection for the in-browser softphone. 28127 * \`default\` is the channel selected when the agent dials from /messages. 28128 * \`enabled\` lists which channels the UI surfaces. 28129 * Unset â softphone UI hidden regardless of \`callSoftphoneEnabled\`. 28130 */ 28131 callChannel?: { 28132 default: 'twilio' | 'maxcontact'; 28133 enabled: Array<'twilio' | 'maxcontact'>; 28134 }; 28135 28136 /**
28137 * Per-tenant goal thresholds for the dashboard's KPI tiles + agent reasoning. 28138 * All values are fractions (0..1). The CEO Dashboard reads these to colour 28139 * the trust pills (â above target / â¼ below target). The agent reasons 28140 * against these via factRegistry entries \`f-acct-target-*\`. 28141 * 28142 * Sensible defaults if unset: browseRate 3%, deliveryRate 95%, clickRate 5%. 28143 * Storefront / messaging metrics are SMS-flavoured today; future metric 28144 * domains (Meta ads, calls, refunds) extend this block. 28145 */ 28146 metricTargets?: { 28147 /** Customers visiting the store within 24h of an SMS â primary KPI. */ 28148 browseRate?: number; 28149 /** Twilio-confirmed delivery â trust signal on the SMS reaching customers. */ 28150 deliveryRate?: number; 28151 /** Diagnostic CTR; high values can indicate Apple link-preview noise. */ 28152 clickRate?: number; 28153 /** Operator-set monthly revenue forecast (whole dollars). Renders as a 28154 * reference line on the Trends â Revenue by Tenant chart in 28155 * currentMonth/priorMonth/priorMonth2 windows. */ 28156 monthlyRevenueForecast?: number; 28157 }; 28158 28159 /** 28160 * Per-tenant outbound message caps for back-book recurring campaigns. 28161 * Enforced by campaign-batch-executor before each emit. Hitting the daily 28162 * cap stops the run with \`executionProgress.cappedAtTenantDailyLimit = true\` 28163 * and emits a \`BackbookCapHit\` CloudWatch metric (CapKind=tenant_daily). 28164 * Unset â no daily ceiling (only per-campaign maxEmitsPerRun applies). 28165 */ 28166 outboundLimits?: { 28167 /** Max back-book SMS the tenant can send per UTC day across ALL recurring back-books. */ 28168 maxBackbookSmsPerDay?: number; 28169 }; 28170 28171 /** 28172 * Booking/showroom availability hours for the appointment calendar AND the AI 28173 * appointment maker. Weekday (MonâFri) + weekend (Sat/Sun) open/close in 28174 * minutes-from-midnight, in \`timezone\` (defaults to Melbourne â the booking 28175 * grid's frame). SINGLE SOURCE OF TRUTH for both the ShopDash calendar grid 28176 * and the campaign-execution-engine slot engine (\`businessHoursFromConfig\` 28177 * expands it to the per-day \`BusinessHours\` those consume). Unset â platform 28178 * \`DEFAULT_BUSINESS_HOURS\` (Weekday 09:30â20:00, Weekend 10:00â17:00). A \`null\` 28179 * weekday/weekend closes that group entirely. 28180 */ 28181 appointmentHours?: import('./appointment.js').AppointmentHoursConfig; 28182 28183 /** 28184 * Per-slot booking capacity for the appointment slot engine â SINGLE SOURCE 28185 * OF TRUTH for how many active appointments a 30-min slot may hold, shared 28186 * by the AI offerer/booking handler AND the ShopDash create/reschedule API. 28187 * Time-banded (\`bands\` in tenant-local minutes, first match wins) with a 28188 * \`default\` for unbanded times. Unset â each caller falls back to its 28189 * workflow step's \`maxPerSlot\` (legacy behaviour). 28190 */ 28191 appointmentCapacity?: import('./appointment.js').AppointmentCapacityConfig; 28192 28193 /** 28194 * Availability hours for SERVICE appointments â bookings whose captured 28195 * \`topic\` is \`product_service\` / \`product_support\` (an existing purchaser 28196 * needing the service team, not a sales consultation). Same shape as 28197 * \`appointmentHours\`; the AI booking handler validates/snaps service-topic 28198 * bookings against THIS window instead. Unset â service bookings follow the 28199 * general \`appointmentHours\` (legacy behaviour). A \`null\` weekday/weekend 28200 * closes that group entirely (e.g. weekend: null = no weekend service). 28201 */ 28202 serviceAppointmentHours?: import('./appointment.js').AppointmentHoursConfig; 28203 28204 /** 28205 * The tenant's physical showroom. PRESENCE OF THIS CONFIG IS THE FEATURE GATE 28206 * for showroom (in-person) appointments: with it, the ShopDash create modal 28207 * offers a Phone/Showroom toggle and \`POST /data/appointments\` accepts 28208 * \`mode:'showroom'\`; without it, that mode is rejected. The address is 28209 * SNAPSHOTTED onto each appointment row at create time, so editing this config 28210 * later never rewrites what a customer was already told. 28211 */ 28212 showroom?: import('./appointment.js').ShowroomConfig; 28213 28214 /** 28215 * Reseller (third-party showroom) appointment bookings. PRESENCE OF THIS 28216 * CONFIG IS THE FEATURE GATE, exactly like \`showroom\`: with it, the ShopDash 28217 * calendar offers the Reseller mode and \`POST /data/appointments\` accepts 28218 * \`mode:'reseller'\`; without it, that mode is rejected. MMC (+ its dev twin) 28219 * only at launch â the reseller registry is an MMC concern. Per-reseller 28220 * eligibility (appointmentsEnabled, status, contactPolicy, a person with a 28221 * mobile) is enforced on top of this gate. 28222 */ 28223 resellerAppointments?: { 28224 enabled: true; 28225 /** 28226 * The RESELLER MANAGER (Chris, 17 Sep 2026: "Steve manages the resellers"). 28227 * Every reseller visit â booked by staff in the calendar or by the AI in an 28228 * SMS thread â is assigned to this agent (resolved via the agents table 28229 * \`GSI_ByEmail\`) so it lands in their calendar, and a \`reseller_booking_review\` 28230 * agent action is raised straight to them. No accept step: the reseller is 28231 * the one who attends, so the row is written already-accepted. Prod MMC: 28232 * [email protected]; dev twin: [email protected]. 28233 * Unset â bookings stay unassigned and no review action is raised. 28234 */ 28235 managerAgentEmail?: string; 28236 /** 28237 * How the reseller is told to reach us (Chris, 17 Sep 2026: "I don't want 28238 * the reseller to text the Twilio number back at all, the message says 28239 * call Steve for any questions"). Rendered verbatim into the reseller-leg 28240 * SMS as {{managerName}} / {{managerPhone}}; the copy also says the text is 28241 * not monitored. Unset â "the team" / the tenant's showroom phone. 28242 */ 28243 managerName?: string; 28244 managerPhone?: string; 28245 }; 28246 28247 /** 28248 * Conversational AI agent config â which retrieval Context Providers and 28249 * Capabilities the SMS agent runs for this tenant. Consumed by 28250 * \`@bigm/shared/ai-agent\`. Unset â appointments-only (legacy behaviour).
28251 */ 28252 aiAgent?: import('./ai-agent.js').AiAgentConfig; 28253 28254 /** 28255 * First name the AI SMS handlers text AS â must match the name the opener 28256 * campaigns sign off with, so the conversation keeps one consistent persona 28257 * (the handler must never deny being this person). Unset â 'Steve'. 28258 */ 28259 aiPersonaName?: string; 28260} 28261 28262// ============================================================================ 28263// DynamoDB Record Type (includes DynamoDB metadata) 28264// ============================================================================ 28265 28266export interface TenantRecord extends TenantConfig { 28267 createdAt?: string; 28268 updatedAt?: string; 28269} 28270 28271// ============================================================================ 28272// Validation Helpers 28273// ============================================================================ 28274 28275export const VALID_PRODUCT_SUMMARY_MODE = ['recent', 'all'] as const; 28276export type ProductSummaryMode = (typeof VALID_PRODUCT_SUMMARY_MODE)[number]; 28277 28278export const VALID_BILLING_STATUS = ['active', 'low_balance', 'suspended', 'free'] as const; 28279 28280// ============================================================================ 28281// Helper Types for Integration Access 28282// ============================================================================ 28283 28284/** 28285 * Helper to check if a specific integration is enabled 28286 */ 28287export function isIntegrationEnabled( 28288 integrations: IntegrationsConfig | undefined, 28289 category: keyof IntegrationsConfig, 28290 provider: string 28291): boolean { 28292 if (!integrations) return false; 28293 const categoryConfig = integrations[category]; 28294 if (!categoryConfig) return false; 28295 const providerConfig = (categoryConfig as Record<string, { enabled?: boolean }>)[provider]; 28296 return providerConfig?.enabled === true; 28297} 28298 28299/** 28300 * Get Shopify configuration if enabled 28301 */ 28302export function getShopifyConfig( 28303 integrations: IntegrationsConfig | undefined 28304): ShopifyIntegration | undefined { 28305 if (!integrations?.ecommerce?.shopify?.enabled) return undefined; 28306 return integrations.ecommerce.shopify; 28307} 28308 28309/** 28310 * Get Zoho configuration if enabled 28311 */ 28312export function getZohoConfig( 28313 integrations: IntegrationsConfig | undefined 28314): ZohoIntegration | undefined { 28315 if (!integrations?.crm?.zoho?.enabled) return undefined; 28316 return integrations.crm.zoho; 28317} 28318 28319/** 28320 * Get OpenAI configuration if enabled 28321 */ 28322export function getOpenAIConfig( 28323 integrations: IntegrationsConfig | undefined 28324): OpenAIIntegration | undefined { 28325 if (!integrations?.ai?.openai?.enabled) return undefined; 28326 return integrations.ai.openai; 28327} 28328 28329/** 28330 * Get Tavily configuration if enabled 28331 */ 28332export function getTavilyConfig( 28333 integrations: IntegrationsConfig | undefined 28334): TavilyIntegration | undefined { 28335 if (!integrations?.ai?.tavily?.enabled) return undefined; 28336 return integrations.ai.tavily; 28337} 28338 28339/** 28340 * Social publishing config, only when BOTH the Meta integration and the publishing 28341 * claim are enabled (fail closed). 28342 */ 28343export function getSocialPublishingConfig( 28344 integrations: IntegrationsConfig | undefined 28345): SocialPublishingConfig | undefined { 28346 const meta = getMetaConfig(integrations); 28347 if (!meta?.publishing?.enabled) return undefined; 28348 if (!meta.publishing.pageId || !meta.publishing.pageTokenSsm || !meta.publishing.systemUserTokenSsm) return undefined; 28349 return meta.publishing; 28350} 28351 28352/** 28353 * Get Meta configuration if enabled 28354 */ 28355export function getMetaConfig( 28356 integrations: IntegrationsConfig | undefined 28357): MetaIntegration | undefined { 28358 if (!integrations?.social?.meta?.enabled) return undefined; 28359 return integrations.social.meta; 28360} 28361 28362/** 28363 * Get Max (telephony) configuration if enabled 28364 */ 28365export function getMaxConfig( 28366 integrations: IntegrationsConfig | undefined 28367): MaxIntegration | undefined { 28368 if (!integrations?.telephony?.max?.enabled) return undefined; 28369 return integrations.telephony.max; 28370} 28371 28372/** 28373 * Get Wicked configuration if enabled 28374 */ 28375export function getWickedConfig( 28376 integrations: IntegrationsConfig | undefined 28377): WickedIntegration | undefined { 28378 if (!integrations?.crm?.wicked?.enabled) return undefined; 28379 return integrations.crm.wicked; 28380} 28381 28382/** 28383 * Get Humm configuration if enabled 28384 */ 28385export function getHummConfig( 28386 integrations: IntegrationsConfig | undefined 28387): HummIntegration | undefined { 28388 if (!integrations?.finance?.humm?.enabled) return undefined; 28389 return integrations.finance.humm; 28390} 28391 28392/** 28393 * Get Twilio configuration if enabled 28394 */ 28395export function getTwilioConfig( 28396 integrations: IntegrationsConfig | undefined 28397): TwilioIntegration | undefined { 28398 if (!integrations?.telephony?.twilio?.enabled) return undefined; 28399 return integrations.telephony.twilio; 28400} 28401 28402/** 28403 * Get Missive configuration if enabled 28404 */ 28405export function getMissiveConfig( 28406 integrations: IntegrationsConfig | undefined 28407): MissiveIntegration | undefined { 28408 if (!integrations?.messaging?.missive?.enabled) return undefined; 28409 return integrations.messaging.missive; 28410} 28411
28412/** 28413 * Get the primary Twilio phone number for a tenant 28414 */ 28415export function getPrimaryTwilioNumber( 28416 twilio: TwilioIntegration | undefined 28417): TwilioPhoneNumber | undefined { 28418 if (!twilio?.phoneNumbers?.length) return undefined; 28419 return twilio.phoneNumbers.find(n => n.isPrimary) || twilio.phoneNumbers[0]; 28420} 28421 28422/** 28423 * Get Moonshot configuration if enabled 28424 */ 28425export function getMoonshotConfig( 28426 integrations: IntegrationsConfig | undefined 28427): MoonshotIntegration | undefined { 28428 if (!integrations?.ai?.moonshot?.enabled) return undefined; 28429 return integrations.ai.moonshot; 28430} 28431 28432/** 28433 * Get Aircall configuration if enabled 28434 */ 28435export function getAircallConfig( 28436 integrations: IntegrationsConfig | undefined 28437): AircallIntegration | undefined { 28438 if (!integrations?.telephony?.aircall?.enabled) return undefined; 28439 return integrations.telephony.aircall; 28440} 28441 28442/** 28443 * Get Google Ads configuration if enabled 28444 */ 28445export function getGoogleAdsConfig( 28446 integrations: IntegrationsConfig | undefined 28447): GoogleAdsIntegration | undefined { 28448 if (!integrations?.advertising?.googleAds?.enabled) return undefined; 28449 return integrations.advertising.googleAds; 28450} 28451 28452/** 28453 * Get Klaviyo configuration if enabled 28454 */ 28455export function getKlaviyoConfig( 28456 integrations: IntegrationsConfig | undefined 28457): KlaviyoIntegration | undefined { 28458 if (!integrations?.marketing?.klaviyo?.enabled) return undefined; 28459 return integrations.marketing.klaviyo; 28460} 28461 28462/** 28463 * Get Xero configuration if enabled 28464 */ 28465export function getXeroConfig( 28466 integrations: IntegrationsConfig | undefined 28467): XeroIntegration | undefined { 28468 if (!integrations?.accounting?.xero?.enabled) return undefined; 28469 return integrations.accounting.xero; 28470} 28471 28472/** 28473 * Get Anthropic configuration if enabled 28474 */ 28475export function getAnthropicConfig( 28476 integrations: IntegrationsConfig | undefined 28477): AnthropicIntegration | undefined { 28478 if (!integrations?.ai?.anthropic?.enabled) return undefined; 28479 return integrations.ai.anthropic; 28480} 28481 28482/** 28483 * Get SAP configuration if enabled 28484 */ 28485export function getSapConfig( 28486 integrations: IntegrationsConfig | undefined 28487): SapIntegration | undefined { 28488 if (!integrations?.erp?.sap?.enabled) return undefined; 28489 return integrations.erp.sap; 28490} 28491 28492/** 28493 * Get freight configuration (providers + routing) if any provider is enabled 28494 */ 28495export function getFreightConfig( 28496 integrations: IntegrationsConfig | undefined 28497): FreightIntegrationsConfig | undefined { 28498 const freight = integrations?.freight; 28499 if (!freight) return undefined; 28500 if (!freight.northline?.enabled && !freight.winnings?.enabled) return undefined; 28501 return freight; 28502} 28503`,Nn=`/** 28504 * Training Example Types 28505 * 28506 * Shapes for the SMS AI training corpus builder (Phase 1) and downstream 28507 * eval/shadow pipelines (Phases 3-4). See \`plans/i-want-you-to-eager-naur.md\`. 28508 * 28509 * Table: training-examples 28510 * PK: {tenantName}#{channel} e.g. "masseuse-massage.myshopify.com#sms" 28511 * SK: {score}#{createdAt}#{exampleId} sortable by score descending when queried 28512 * GSI byOutcome: outcome (HASH), createdAt (RANGE) 28513 */ 28514 28515/** Training channel â SMS only in Phase 1 of this plan */ 28516export type TrainingChannel = 'sms' | 'chat' | 'email' | 'call'; 28517 28518/** 0-5 goldness score assigned by the corpus builder */ 28519export type GoldnessScore = 0 | 1 | 2 | 3 | 4 | 5; 28520 28521/** Manual review status (future admin UI will flip these) */ 28522export type ReviewStatus = 'unreviewed' | 'approved' | 'rejected' | 'flagged'; 28523 28524/** Outcome label joined from \`sales-orders\` / \`Lead.lockType\` */ 28525export type TrainingOutcome = 28526 | 'converted' 28527 | 'engaged_no_convert' 28528 | 'lost' 28529 | 'escalated' 28530 | 'unknown'; 28531 28532/** Actor that produced the terminal (most-recent) outbound message */ 28533export type TrainingActor = 'agent_physical' | 'agent_ai' | 'workflow_automated'; 28534 28535/** One turn in the reconstructed SMS conversation */ 28536export interface SmsConversationTurn { 28537 /** Chronological ordering index (0-based, oldest first) */ 28538 index: number; 28539 28540 /** Who spoke on this turn */ 28541 role: 'user' | 'assistant'; 28542 28543 /** Source actor when role='assistant' (null on user turns) */ 28544 actor: TrainingActor | null; 28545 28546 /** Message body (PII-redacted) */ 28547 body: string; 28548
28549 /** ISO-8601 timestamp from the source event */ 28550 ts: string; 28551 28552 /** Source event ID from events-customer */ 28553 eventId: string; 28554} 28555 28556/** Provenance â so any example is reproducible from source events */ 28557export interface TrainingProvenance { 28558 /** Ordered list of source event IDs from events-customer */ 28559 eventIds: string[]; 28560 28561 /** Lead ID this example was derived from */ 28562 leadId: string; 28563 28564 /** sales-orders record ID if an outcome was joined */ 28565 salesOrderId?: string; 28566 28567 /** Builder run ID that produced this example */ 28568 builderRunId: string; 28569 28570 /** ISO-8601 of the builder run */ 28571 builderRunAt: string; 28572 28573 /** 28574 * True when this example was added via the admin "promote from trace" flow 28575 * (not the nightly builder). Handpicked rows are never overwritten by 28576 * subsequent builder runs. 28577 */ 28578 handpicked?: boolean; 28579 28580 /** agent-trace traceId this example was promoted from, if any */ 28581 sourceTraceId?: string; 28582} 28583 28584/** Main training example row stored in \`training-examples\` */ 28585export interface TrainingExample { 28586 /** Partition key: {tenantName}#{channel} */ 28587 pk: string; 28588 28589 /** Sort key: {score}#{createdAt}#{exampleId} */ 28590 sk: string; 28591 28592 /** Tenant name (shop domain) */ 28593 tenantName: string; 28594 28595 /** Channel (sms for Phase 1) */ 28596 channel: TrainingChannel; 28597 28598 /** Unique id for the example (uuid v4) */ 28599 exampleId: string; 28600 28601 /** Goldness score 0-5 */ 28602 score: GoldnessScore; 28603 28604 /** Outcome label used by GSI byOutcome */ 28605 outcome: TrainingOutcome; 28606 28607 /** Actor that produced the terminal outbound (gold vs bronze) */ 28608 actor: TrainingActor; 28609 28610 /** Full conversation in chronological order, PII-redacted */ 28611 turns: SmsConversationTurn[]; 28612 28613 /** Convenience copy of the full transcript in OpenAI chat-format JSON */ 28614 messagesJson: string; 28615 28616 /** The visitor's last inbound message (anchor for retrieval queries) */ 28617 visitorMessage: string; 28618 28619 /** The gold/bronze response to that visitor message */ 28620 goldResponse: string; 28621 28622 /** ISO timestamp the terminal outbound was sent */ 28623 createdAt: string; 28624 28625 /** Manual review status (defaults to unreviewed) */ 28626 reviewStatus: ReviewStatus; 28627 28628 /** Optional reviewer-supplied notes (why flagged, why approved, etc.) */ 28629 reviewNotes?: string; 28630 28631 /** Cognito sub / email of the reviewer who last set reviewStatus */ 28632 reviewedBy?: string; 28633 28634 /** ISO-8601 timestamp of the most recent review action */ 28635 reviewedAt?: string; 28636 28637 /** Provenance for reproducibility */ 28638 provenance: TrainingProvenance; 28639} 28640 28641/** Input for writing a new TrainingExample (pk/sk computed) */ 28642export type CreateTrainingExampleInput = Omit<TrainingExample, 'pk' | 'sk'>; 28643 28644/** 28645 * Prompt eval row stored in \`prompt-evals\` 28646 * PK: evalRunId, SK: exampleId 28647 */ 28648export interface PromptEvalJudgeScores { 28649 factualCorrectness?: number; // 0-5 28650 toneMatch?: number; // 0-5 28651 escalationCorrect?: boolean; // pass/fail 28652 conversionIntent?: number; // 0-5 28653 hallucination?: boolean; // false = pass, true = fail (hard fail) 28654 /** Free-text judge rationale for the run (optional) */ 28655 rationale?: string; 28656} 28657 28658export type PromptEvalVerdict = 'win' | 'loss' | 'tie' | 'unknown'; 28659 28660/** 28661 * Human reviewer's verdict overlaid on a judge-scored row. Lets a person 28662 * override the LLM-as-judge when they disagree with its grade, feeding 28663 * signal into future judge-prompt tuning. 28664 */ 28665export type PromptEvalHumanVerdict = 28666 | 'agree_with_judge' 28667 | 'disagree_with_judge' 28668 | 'unsure'; 28669 28670export interface PromptEvalRecord { 28671 /** Partition key */ 28672 evalRunId: string; 28673 28674 /** Sort key */ 28675 exampleId: string; 28676 28677 /** Tenant this example came from */ 28678 tenantName: string; 28679 28680 /** Which prompt version was run (e.g. 'v1', 'v2') */ 28681 promptVersion: string; 28682 28683 /** Prompt ID (e.g. 'sms-reply/default.system') */ 28684 promptId: string; 28685 28686 /** Visitor message the prompt replied to */ 28687 visitorMessage: string; 28688 28689 /** Reply the AI produced */ 28690 replyGenerated: string; 28691 28692 /** Reference human reply from the gold example */ 28693 humanReply: string; 28694 28695 /** Judge scores */ 28696 judgeScores: PromptEvalJudgeScores; 28697 28698 /** Rolled-up verdict vs baseline version */ 28699 verdict?: PromptEvalVerdict; 28700 28701 /** Which baseline version this row is compared against (e.g. 'v1') */ 28702 baselineVersion?: string; 28703 28704 /** Model used to generate replyGenerated */ 28705 model: string; 28706 28707 /** Tokens consumed by the reply generation call */ 28708 tokensIn: number; 28709 tokensOut: number; 28710 28711 /** ISO timestamp when this row was written */ 28712 createdAt: string; 28713 28714 /** Admin reviewer's verdict overlaid on the judge score (optional) */ 28715 humanVerdict?: PromptEvalHumanVerdict; 28716
28717 /** ISO-8601 timestamp when humanVerdict was set */ 28718 humanVerdictAt?: string; 28719 28720 /** Cognito sub / email of the reviewer who set humanVerdict */ 28721 humanVerdictBy?: string; 28722} 28723 28724export type CreatePromptEvalInput = Omit<PromptEvalRecord, 'createdAt'> & { 28725 createdAt?: string; 28726}; 28727 28728/** 28729 * Shadow comparison row stored in \`shadow-comparisons\` 28730 * PK: leadId, SK: {createdAt}#{version} 28731 */ 28732export interface ShadowComparisonRecord { 28733 /** Partition key: leadId */ 28734 leadId: string; 28735 28736 /** Sort key: \`\${createdAt}#\${version}\` */ 28737 sk: string; 28738 28739 /** Tenant */ 28740 tenantName: string; 28741 28742 /** The agent-trace id the production (v1) reply was recorded in */ 28743 productionTraceId: string; 28744 28745 /** Visitor inbound message (input to both prompts) */ 28746 visitorMessage: string; 28747 28748 /** Reply the live (v1) path produced and actually sent */ 28749 productionReply: string; 28750 28751 /** Reply the shadow (v2) path generated but DID NOT send */ 28752 shadowReply: string; 28753 28754 /** Shadow prompt version (e.g. 'v2') */ 28755 shadowVersion: string; 28756 28757 /** Model used by the shadow path */ 28758 shadowModel: string; 28759 28760 /** Shadow-path token cost */ 28761 shadowTokensIn: number; 28762 shadowTokensOut: number; 28763 28764 /** Manual thumbs-up/down from admin review UI, if any */ 28765 humanVerdict?: 'shadow_wins' | 'production_wins' | 'tie' | 'unrated'; 28766 28767 /** ISO timestamp when this row was written */ 28768 createdAt: string; 28769} 28770 28771export type CreateShadowComparisonInput = Omit<ShadowComparisonRecord, 'sk'>; 28772 28773/** Build the training-examples PK */ 28774export function buildTrainingExamplePk( 28775 tenantName: string, 28776 channel: TrainingChannel 28777): string { 28778 return \`\${tenantName}#\${channel}\`; 28779} 28780 28781/** Build the training-examples SK â padded score so sort descending yields gold first */ 28782export function buildTrainingExampleSk( 28783 score: GoldnessScore, 28784 createdAt: string, 28785 exampleId: string 28786): string { 28787 return \`\${score}#\${createdAt}#\${exampleId}\`; 28788} 28789 28790/** Build the shadow-comparisons SK */ 28791export function buildShadowComparisonSk(createdAt: string, version: string): string { 28792 return \`\${createdAt}#\${version}\`; 28793} 28794`,Ln=`/** 28795 * Twilio Type Definitions 28796 * 28797 * TypeScript interfaces for Twilio SMS webhook payloads and API responses 28798 * Based on Twilio's webhook documentation and API responses 28799 */ 28800 28801import type { EventActor, SmsGeneration } from './event-customer.js'; 28802 28803/** 28804 * Twilio inbound SMS webhook payload structure 28805 * Sent by Twilio when an SMS is received 28806 * 28807 * Reference: https://www.twilio.com/docs/usage/webhooks/messaging-webhooks 28808 */ 28809export interface TwilioInboundSmsWebhook { 28810 /** Unique identifier for the message (SID format: SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) */ 28811 MessageSid: string; 28812 28813 /** Twilio Account SID */ 28814 AccountSid: string; 28815 28816 /** Messaging Service SID (if message was sent via Messaging Service) */ 28817 MessagingServiceSid?: string; 28818 28819 /** Phone number or alphanumeric sender ID that sent the message (E.164 format) */ 28820 From: string; 28821 28822 /** Phone number that received the message (E.164 format) */ 28823 To: string; 28824 28825 /** The text content of the message */ 28826 Body: string; 28827 28828 /** Number of media items attached to the message (0 if none) */ 28829 NumMedia?: string; 28830 28831 /** Media content type (if media attached) - format: MediaContentType0, MediaContentType1, etc. */ 28832 MediaContentType0?: string; 28833 MediaContentType1?: string; 28834 MediaContentType2?: string; 28835 MediaContentType3?: string; 28836 MediaContentType4?: string; 28837 MediaContentType5?: string; 28838 MediaContentType6?: string; 28839 MediaContentType7?: string; 28840 MediaContentType8?: string; 28841 MediaContentType9?: string; 28842 28843 /** Media URL (if media attached) - format: MediaUrl0, MediaUrl1, etc. */ 28844 MediaUrl0?: string; 28845 MediaUrl1?: string; 28846 MediaUrl2?: string; 28847 MediaUrl3?: string; 28848 MediaUrl4?: string; 28849 MediaUrl5?: string; 28850 MediaUrl6?: string; 28851 MediaUrl7?: string; 28852 MediaUrl8?: string; 28853 MediaUrl9?: string; 28854 28855 /** The API version used by Twilio to process the message */ 28856 ApiVersion?: string; 28857 28858 /** The status of the message (e.g., "received") */ 28859 SmsStatus?: string; 28860 28861 /** The SID of the message that this message is a reply to (if applicable) */ 28862 ReferralNumMedia?: string; 28863 28864 /** The city of the sender (if available) */ 28865 FromCity?: string; 28866 28867 /** The state or province of the sender (if available) */ 28868 FromState?: string; 28869 28870 /** The postal code of the sender (if available) */ 28871 FromZip?: string; 28872 28873 /** The country of the sender (if available) */ 28874 FromCountry?: string; 28875 28876 /** The city of the recipient (if available) */ 28877 ToCity?: string; 28878 28879 /** The state or province of the recipient (if available) */ 28880 ToState?: string; 28881 28882 /** The postal code of the recipient (if available) */ 28883 ToZip?: string; 28884 28885 /** The country of the recipient (if available) */ 28886 ToCountry?: string; 28887 28888 /** Additional custom parameters that may be included */ 28889 [key: string]: string | undefined; 28890} 28891 28892/** 28893 * Twilio outbound SMS API response structure 28894 * Returned by Twilio API when sending an SMS via messages.create() 28895 * 28896 * Reference: https://www.twilio.com/docs/sms/api/message-resource 28897 */ 28898export interface TwilioOutboundSmsResponse { 28899 /** Unique identifier for the message (SID format: SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) */ 28900 sid: string; 28901 28902 /** The date and time the message was created (ISO 8601 format) */ 28903 dateCreated: Date; 28904 28905 /** The date and time the message was last updated (ISO 8601 format) */ 28906 dateUpdated: Date; 28907 28908 /** The date and time the message was sent (ISO 8601 format, may be null) */ 28909 dateSent: Date | null; 28910 28911 /** The account SID that sent the message */ 28912 accountSid: string; 28913 28914 /** The phone number or alphanumeric sender ID that sent the message */ 28915 from: string; 28916 28917 /** The phone number that received the message */ 28918 to: string; 28919 28920 /** The text content of the message */ 28921 body: string; 28922 28923 /** The status of the message (queued, sending, sent, failed, delivered, undelivered, etc.) */ 28924 status: string; 28925 28926 /** Number of segments the message was split into */ 28927 numSegments?: string; 28928 28929 /** Number of media items attached */ 28930 numMedia?: string; 28931 28932 /** The direction of the message (outbound-api, inbound, etc.) */ 28933 direction: string; 28934 28935 /** The price of the message (if available) */ 28936 price?: string; 28937 28938 /** The currency of the price (if available) */ 28939 priceUnit?: string; 28940 28941 /** The API version used */ 28942 apiVersion?: string; 28943 28944 /** The URI of the message resource */ 28945 uri: string; 28946 28947 /** The subresource URIs */ 28948 subresourceUris: { 28949 media?: string; 28950 feedback?: string; 28951 }; 28952
28953 /** Error code if message failed (if applicable) */ 28954 errorCode?: number | null; 28955 28956 /** Error message if message failed (if applicable) */ 28957 errorMessage?: string | null; 28958 28959 /** Messaging Service SID (if sent via Messaging Service) */ 28960 messagingServiceSid?: string | null; 28961 28962 /** Additional properties that may be present */ 28963 [key: string]: unknown; 28964} 28965 28966/** 28967 * Request body structure for sending SMS via the send-sms Lambda 28968 */ 28969export interface SendSmsRequest { 28970 /** Phone number to send SMS to (E.164 format) */ 28971 to: string; 28972 28973 /** The text content of the message */ 28974 body: string; 28975 28976 /** Twilio phone number to send from (E.164 format) */ 28977 twilioPhoneNumber: string; 28978 28979 /** Optional tenant name for event tracking */ 28980 tenantName?: string; 28981 28982 /** Optional app ID to lookup tenant from app-configs DynamoDB table */ 28983 appId?: string; 28984 28985 /** Optional campaign ID - if provided, campaign context will be loaded and attached to event */ 28986 campaignId?: string; 28987 28988 /** Optional email address of the recipient - used for lead matching */ 28989 email?: string; 28990 28991 /** Optional lead ID - if provided, will be stored in EventCustomer record and event will be excluded from create-lead-and-identity queue */ 28992 leadId?: string; 28993 28994 /** Media URLs for inline MMS attachment (video, image). Twilio fetches from these public URLs. Max 5MB total. */ 28995 mediaUrl?: string[]; 28996 28997 /** Name of the agent who sent this message (populated by frontend for direct sends) */ 28998 sentByName?: string; 28999 29000 /** Actor who initiated this send. If omitted, the handler resolves it from sentByName/campaignId, defaulting to agent_ai. */ 29001 actor?: EventActor; 29002 29003 /** 29004 * Which team texted by hand (15 Sep 2026). ShopDash sets 'service' when the caller's 29005 * role is tenant_service; stamped onto the outbound_sms event as \`sentByLane\` so the 29006 * engine's \`check_last_outbound_sms_lane\` can route the customer's reply to the 29007 * service queue. Routing metadata, never an access control. 29008 */ 29009 sentByLane?: 'service'; 29010 29011 /** 29012 * How the body text was produced (18 Sep 2026): model prose vs code-built vs 29013 * rendered template. Copied verbatim onto \`eventData.generation\` of the 29014 * outbound_sms row. The engine sets it; other callers may leave it unset. 29015 */ 29016 generation?: SmsGeneration; 29017 29018 /** 29019 * Opt-in: stamp a TOP-LEVEL \`phone\` attribute onto the outbound 29020 * events-customer row so it lands in the sparse \`eventsByPhone\` GSI. Used 29021 * for RESELLER-directed sends (a third party, not the lead) so /messages 29022 * can show a phone-keyed reseller thread. Leave unset for normal customer 29023 * sends â the GSI must stay tiny. 29024 */ 29025 stampPhone?: boolean; 29026 29027 /** 29028 * With \`stampPhone\`: the number to stamp when it differs from \`to\` (e.g. an 29029 * engine testMode run delivers to a test number but the thread belongs to 29030 * the reseller's real number). Defaults to \`to\`. 29031 */ 29032 counterpartyPhone?: string; 29033} 29034 29035// ============================================================================= 29036// Twilio Management API Types 29037// ============================================================================= 29038 29039/** 29040 * Supported countries for phone number provisioning 29041 */ 29042export type TwilioCountryCode = 'AU' | 'US'; 29043 29044/** 29045 * Phone number types supported by Twilio 29046 * - mobile: Mobile numbers (AU +614xx, US varies) 29047 * - local: Local/landline numbers (AU +612/3/7/8, US area codes) 29048 * - tollFree: Toll-free numbers (AU 1800, US 800/888/877/866/855/844/833) 29049 * - national: National rate numbers (AU 1300 only - not available in US) 29050 */ 29051export type TwilioNumberType = 'mobile' | 'local' | 'tollFree' | 'national'; 29052 29053/** 29054 * Combined country and number type key for pricing lookup 29055 */ 29056export type TwilioNumberPricingKey = 29057 | 'AU_MOBILE' | 'AU_LOCAL' | 'AU_TOLL_FREE' | 'AU_NATIONAL' 29058 | 'US_MOBILE' | 'US_LOCAL' | 'US_TOLL_FREE'; 29059 29060/** 29061 * Phone number capabilities filter 29062 */ 29063export interface TwilioCapabilitiesFilter { 29064 sms?: boolean; 29065 voice?: boolean; 29066 mms?: boolean; 29067} 29068 29069/** 29070 * Request to create a Twilio subaccount for a tenant 29071 */ 29072export interface CreateSubaccountRequest { 29073 tenantId: string; 29074 friendlyName?: string; 29075} 29076 29077/** 29078 * Response from creating a Twilio subaccount 29079 */ 29080export interface CreateSubaccountResponse { 29081 subaccountSid: string; 29082 authTokenSsm: string; 29083 friendlyName: string; 29084 status: 'active' | 'suspended' | 'closed'; 29085 createdAt: string; 29086} 29087 29088/** 29089 * Request to update subaccount status 29090 */ 29091export interface UpdateSubaccountStatusRequest { 29092 tenantId: string; 29093 status: 'active' | 'suspended'; 29094} 29095 29096/** 29097 * Query parameters for searching available phone numbers 29098 */ 29099export interface SearchAvailableNumbersRequest { 29100 /** Country code (AU or US) */ 29101 country: TwilioCountryCode; 29102 /** Number type to search for (mobile, local, tollFree, national) */ 29103 numberType?: TwilioNumberType; 29104 /** Area code filter (optional) */ 29105 areaCode?: string; 29106 /** Search by number pattern (optional) */ 29107 contains?: string; 29108 /** Capabilities filter */ 29109 capabilities?: TwilioCapabilitiesFilter; 29110 /** Maximum results to return (default 20) */ 29111 limit?: number; 29112 /** Tenant ID for authorization */ 29113 tenantId: string; 29114} 29115 29116/** 29117 * Available phone number from Twilio API 29118 */ 29119export interface AvailablePhoneNumber { 29120 phoneNumber: string; 29121 friendlyName: string; 29122 locality?: string; 29123 region?: string; 29124 postalCode?: string; 29125 isoCountry: string; 29126 /** Type of phone number (mobile, local, tollFree, national) */ 29127 numberType: TwilioNumberType; 29128 capabilities: { 29129 sms: boolean; 29130 voice: boolean; 29131 mms: boolean; 29132 }; 29133 /** Monthly cost in cents */ 29134 monthlyPriceCents: number; 29135 /** Setup cost in cents */ 29136 setupPriceCents?: number; 29137} 29138 29139/** 29140 * Response from searching available phone numbers 29141 */ 29142export interface SearchAvailableNumbersResponse { 29143 numbers: AvailablePhoneNumber[]; 29144 country: TwilioCountryCode; 29145 total: number; 29146} 29147 29148/** 29149 * Request to provision a phone number 29150 */ 29151export interface ProvisionPhoneNumberRequest { 29152 tenantId: string; 29153 phoneNumber: string; 29154 friendlyName?: string; 29155 isPrimary?: boolean; 29156 /** Regulatory bundle SID - required for Australian mobile numbers to enable SMS */ 29157 bundleSid?: string; 29158 /** Address SID - required for Australian mobile numbers (associated with regulatory bundle) */ 29159 addressSid?: string; 29160} 29161 29162/** 29163 * Response from provisioning a phone number 29164 */ 29165export interface ProvisionPhoneNumberResponse { 29166 sid: string; 29167 phoneNumber: string; 29168 friendlyName: string; 29169 /** Type of phone number (mobile, local, tollFree, national) */ 29170 numberType: TwilioNumberType; 29171 capabilities: { 29172 sms: boolean; 29173 voice: boolean; 29174 mms: boolean; 29175 }; 29176 isPrimary: boolean; 29177 provisionedAt: string; 29178 /** Monthly cost in cents */ 29179 monthlyPriceCents: number; 29180} 29181 29182/** 29183 * Request to release a phone number 29184 */ 29185export interface ReleasePhoneNumberRequest { 29186 tenantId: string; 29187 phoneNumberSid: string; 29188} 29189 29190/** 29191 * Request to set a phone number as primary 29192 */ 29193export interface SetPrimaryPhoneNumberRequest { 29194 tenantId: string; 29195 phoneNumberSid: string; 29196} 29197 29198/** 29199 * Get Twilio configuration for a tenant 29200 */ 29201export interface GetTwilioConfigRequest { 29202 tenantId: string; 29203} 29204 29205/** 29206 * Response with tenant's Twilio configuration 29207 */ 29208export interface GetTwilioConfigResponse { 29209 enabled: boolean; 29210 subaccountSid?: string; 29211 friendlyName?: string; 29212 status?: 'active' | 'suspended' | 'closed'; 29213 phoneNumbers: Array<{ 29214 phoneNumber: string; 29215 sid: string; 29216 friendlyName?: string; 29217 capabilities?: { 29218 sms: boolean; 29219 voice: boolean; 29220 mms: boolean; 29221 }; 29222 isPrimary?: boolean; 29223 provisionedAt: string; 29224 forwardingNumber?: string; 29225 /** 29226 * Phase 6 â when true and tenant.callSoftphoneEnabled is also true, 29227 * inbound calls to this number try to ring the lead-owner's browser 29228 * before falling back to forwardingNumber. See voice-forward.ts. 29229 */ 29230 ownerFirstInboundEnabled?: boolean; 29231 }>; 29232 createdAt?: string; 29233} 29234 29235/** 29236 * Request to set or clear call forwarding for a phone number 29237 */ 29238export interface SetForwardingNumberRequest { 29239 tenantId: string; 29240 phoneNumberSid: string; 29241 /** Phone number to forward calls to (E.164 format). Null or empty to clear forwarding. */ 29242 forwardingNumber: string | null; 29243} 29244 29245/** 29246 * Response from setting call forwarding 29247 */ 29248export interface SetForwardingNumberResponse { 29249 success: boolean; 29250 phoneNumberSid: string; 29251 forwardingNumber: string | null; 29252} 29253 29254`,Un=`/** 29255 * USA Project Tracker Type Definitions 29256 * 29257 * Based on Terraform schema: terraform/SHARED/create-dynamo-usa-project-tracker 29258 * 29259 * Table: usa-project-tracker 29260 * PK: projectId (fixed "usa-launch" for global scope) 29261 * SK: targetDate#id (for timeline ordering) 29262 * 29263 * GSIs: 29264 * - tasksByStatus: status â targetDate 29265 * - tasksByOwner: owner â targetDate 29266 */ 29267 29268/** 29269 * Task Status - progress states for project tasks 29270 */ 29271export type ProjectTaskStatus = 'pending' | 'in-progress' | 'completed' | 'blocked'; 29272 29273/** 29274 * Task Priority - urgency levels 29275 */ 29276export type ProjectTaskPriority = 'low' | 'medium' | 'high' | 'critical'; 29277 29278/** 29279 * Work Type - category of work 29280 */ 29281export type ProjectWorkType = 'technology' | 'legal' | 'accounting' | 'logistics' | 'marketing' | 'operations' | 'compliance' | 'other'; 29282 29283/** 29284 * Source Artifact Type - reference to related resources 29285 */ 29286export type SourceArtifactType = 'file' | 'url' | 'document' | 'section'; 29287 29288/** 29289 * Source Artifact - reference to related files, URLs, or resources 29290 */ 29291export interface SourceArtifact { 29292 /** Type of artifact: 'file', 'url', 'document', 'section' */ 29293 type: SourceArtifactType; 29294 /** Reference path or URL */ 29295 reference: string; 29296 /** Optional description */ 29297 description?: string; 29298} 29299 29300/** 29301 * USA Project Tracker Task record 29302 */ 29303export interface UsaProjectTask { 29304 /** Partition key - fixed "usa-launch" for global scope */ 29305 projectId: string; 29306 29307 /** Sort key - composite: targetDate#id */ 29308 sk: string; 29309 29310 /** Unique task identifier (UUID) */ 29311 id: string; 29312 29313 /** Task title */ 29314 title: string; 29315 29316 /** Detailed task description */ 29317 description: string; 29318 29319 /** Assigned owner (user email or name) */ 29320 owner: string; 29321 29322 /** Task status */ 29323 status: ProjectTaskStatus; 29324 29325 /** Target completion date (YYYY-MM-DD) */ 29326 targetDate: string; 29327 29328 /** Category/phase (month-1, month-2, etc.) */ 29329 category: string; 29330 29331 /** Related source artifacts/references */ 29332 sourceArtifacts: SourceArtifact[]; 29333 29334 /** Task priority */ 29335 priority?: ProjectTaskPriority; 29336 29337 /** Work type category (technology, legal, accounting, etc.) */ 29338 workType?: ProjectWorkType; 29339 29340 /** Additional notes */ 29341 notes?: string; 29342 29343 /** ISO 8601 timestamp when created */ 29344 createdAt: string; 29345 29346 /** ISO 8601 timestamp when last updated */ 29347 updatedAt: string; 29348 29349 /** ISO 8601 timestamp when completed (optional) */ 29350 completedAt?: string; 29351} 29352 29353/** 29354 * Create task input (omits auto-generated fields) 29355 */ 29356export interface CreateUsaProjectTaskInput { 29357 title: string; 29358 description: string; 29359 owner: string; 29360 status?: ProjectTaskStatus; 29361 targetDate: string; 29362 category: string; 29363 sourceArtifacts?: SourceArtifact[]; 29364 priority?: ProjectTaskPriority; 29365 workType?: ProjectWorkType; 29366 notes?: string; 29367} 29368 29369/** 29370 * Update task input (only updatable fields) 29371 */ 29372export interface UpdateUsaProjectTaskInput { 29373 title?: string; 29374 description?: string; 29375 owner?: string; 29376 status?: ProjectTaskStatus; 29377 targetDate?: string; 29378 category?: string; 29379 sourceArtifacts?: SourceArtifact[]; 29380 priority?: ProjectTaskPriority; 29381 workType?: ProjectWorkType; 29382 notes?: string; 29383} 29384 29385// ============================================ 29386// Strategy Types (Read-only from markdown) 29387// ============================================ 29388 29389/** 29390 * Key Decision - confirmed strategic decision 29391 */ 29392export interface KeyDecision { 29393 /** Decision topic */ 29394 topic: string; 29395 /** What was decided */ 29396 decision: string; 29397 /** How to implement */ 29398 implementation: string; 29399} 29400 29401/** 29402 * Risk Likelihood/Impact levels 29403 */ 29404export type RiskLevel = 'Low' | 'Medium' | 'High'; 29405 29406/** 29407 * Risk Item - project risk with mitigation 29408 */ 29409export interface RiskItem { 29410 /** Risk description */ 29411 risk: string; 29412 /** Likelihood of occurrence */ 29413 likelihood: RiskLevel; 29414 /** Impact if occurs */ 29415 impact: RiskLevel; 29416 /** Mitigation strategy */ 29417 mitigation: string; 29418} 29419 29420/** 29421 * Success Metric timeframe 29422 */ 29423export type MetricTimeframe = '6-month' | '12-month'; 29424 29425/** 29426 * Success Metric - measurable goal 29427 */ 29428export interface SuccessMetric { 29429 /** Metric name */ 29430 metric: string; 29431 /** Target value */ 29432 target: string; 29433 /** Measurement timeframe */ 29434 timeframe: MetricTimeframe; 29435} 29436
29437/** 29438 * Project Strategy - read-only strategy data 29439 */ 29440export interface UsaProjectStrategy { 29441 /** Key strategic decisions */ 29442 keyDecisions: KeyDecision[]; 29443 /** Risk assessment */ 29444 risks: RiskItem[]; 29445 /** Success metrics */ 29446 successMetrics: SuccessMetric[]; 29447 /** Last updated timestamp */ 29448 lastUpdated: string; 29449} 29450 29451// ============================================ 29452// Constants and Helpers 29453// ============================================ 29454 29455/** Fixed project ID for global USA launch project */ 29456export const USA_PROJECT_ID = 'usa-launch'; 29457 29458/** Valid task status values */ 29459export const VALID_PROJECT_TASK_STATUS: ProjectTaskStatus[] = [ 29460 'pending', 29461 'in-progress', 29462 'completed', 29463 'blocked', 29464]; 29465 29466/** Valid task priority values */ 29467export const VALID_PROJECT_TASK_PRIORITY: ProjectTaskPriority[] = [ 29468 'low', 29469 'medium', 29470 'high', 29471 'critical', 29472]; 29473 29474/** Valid work type values */ 29475export const VALID_PROJECT_WORK_TYPES: ProjectWorkType[] = [ 29476 'technology', 29477 'legal', 29478 'accounting', 29479 'logistics', 29480 'marketing', 29481 'operations', 29482 'compliance', 29483 'other', 29484]; 29485 29486/** Valid source artifact types */ 29487export const VALID_SOURCE_ARTIFACT_TYPES: SourceArtifactType[] = [ 29488 'file', 29489 'url', 29490 'document', 29491 'section', 29492]; 29493 29494/** 29495 * Build sort key from targetDate and id 29496 */ 29497export function buildProjectTaskSk(targetDate: string, id: string): string { 29498 return \`\${targetDate}#\${id}\`; 29499} 29500 29501/** 29502 * Parse sort key to extract targetDate and id 29503 */ 29504export function parseProjectTaskSk(sk: string): { targetDate: string; id: string } { 29505 const [targetDate, id] = sk.split('#'); 29506 return { targetDate, id }; 29507} 29508 29509/** 29510 * Type guard for ProjectTaskStatus 29511 */ 29512export function isValidProjectTaskStatus(status: string): status is ProjectTaskStatus { 29513 return VALID_PROJECT_TASK_STATUS.includes(status as ProjectTaskStatus); 29514} 29515 29516/** 29517 * Type guard for ProjectTaskPriority 29518 */ 29519export function isValidProjectTaskPriority(priority: string): priority is ProjectTaskPriority { 29520 return VALID_PROJECT_TASK_PRIORITY.includes(priority as ProjectTaskPriority); 29521} 29522`,Bn=`import type { OwnershipReason, AgentActionType } from './lead.js'; 29523import type { DistributionStrategy } from './agent-distribution.js'; 29524import type { EventActor } from './event-customer.js'; 29525 29526/** 29527 * Campaign Workflow Type Definitions 29528 * 29529 * Defines types for deterministic campaign execution workflows. 29530 * Workflows declare explicit execution steps that are executed in order. 29531 */ 29532 29533/** 29534 * Step input reference - structured format for referencing data sources 29535 */ 29536export interface StepInput { 29537 /** Source of the data */ 29538 source: 'checkout' | 'campaign' | 'context'; 29539 /** Field path within the source (e.g., "total", "discount.value") */ 29540 field: string; 29541} 29542 29543/** 29544 * Condition for conditional steps 29545 */ 29546export interface StepCondition { 29547 /** Field to evaluate (using StepInput format) */ 29548 field: StepInput; 29549 /** Comparison operator */ 29550 operator: '==' | '!=' | '>' | '>=' | '<' | '<='; 29551 /** Value to compare against */ 29552 value: string | number | boolean; 29553} 29554 29555/** 29556 * Base step type 29557 */ 29558export interface BaseStep { 29559 type: string; 29560} 29561 29562/** 29563 * Load checkout data from events-customer table 29564 */ 29565export interface LoadCheckoutStep extends BaseStep { 29566 type: 'load_checkout'; 29567} 29568 29569/** 29570 * Load any event from events-customer table 29571 * Works with all event types (form.submitted, customer.created, etc.) 29572 * Use this for campaigns that don't require checkout-specific data. 29573 */ 29574export interface LoadEventStep extends BaseStep { 29575 type: 'load_event'; 29576} 29577 29578/** 29579 * Evaluate discount rules and calculate savings 29580 * Matches DashboardView calculateSavings logic 29581 */ 29582export interface EvaluateDiscountStep extends BaseStep { 29583 type: 'evaluate_discount'; 29584 /** Input references for discount calculation */ 29585 inputs: StepInput[]; 29586} 29587 29588/** 29589 * Create Shopify draft order 29590 * Calls /toolcall/create-shopify-checkout API endpoint 29591 */ 29592export interface CreateDraftOrderStep extends BaseStep { 29593 type: 'create_draft_order'; 29594} 29595 29596/** 29597 * Send SMS message 29598 * Uses campaign.messages.sms.template and calls /toolcall/send-sms 29599 */ 29600export interface SendSmsStep extends BaseStep { 29601 type: 'send_sms'; 29602 channel: 'sms'; 29603} 29604
29605/** 29606 * Render the draft-order MMS card PNG via \`mms-card-renderer\` Lambda. 29607 * 29608 * Used by "system" workflows that fire on \`draft_order.created\` / 29609 * \`draft_order.updated\` events (replacing the standalone 29610 * \`lambda-deployed-draft-order-image-renderer\` Lambda). The step reads 29611 * \`context.eventId\` to fetch the trigger event, extracts \`draft_order_id\`, 29612 * and calls the shared \`renderDraftOrderImageForDraft\` helper which writes 29613 * to the canonical S3 key \`\${tenantId}/draft-orders/\${draftOrderId}.png\`. 29614 * 29615 * Read-only relative to customer outreach â no SMS/email/call dispatch. 29616 */ 29617export interface RenderDraftOrderImageStep extends BaseStep { 29618 type: 'render_draft_order_image'; 29619 /** If set, only render when the triggering draft event's \`eventData.actor\` 29620 * is in this list. Undefined/empty = render for any actor (legacy default). 29621 * Note: drafts created via Shopify webhook/poll carry NO actor, so they are 29622 * excluded whenever this filter is non-empty. */ 29623 allowedActors?: EventActor[]; 29624} 29625 29626/** 29627 * Send email message 29628 * Uses campaign.messages.email and calls /toolcall/send-email 29629 */ 29630export interface SendEmailStep extends BaseStep { 29631 type: 'send_email'; 29632 channel: 'email'; 29633} 29634 29635/** 29636 * Run an AI workload (agent Lambda). 29637 * 29638 * Async-invokes the Lambda registered for \`agentId\` in the AI_AGENTS registry 29639 * (\`@bigm/types/src/ai-agents.ts\`). Default invocation type is 'Event' (fire-and- 29640 * forget); the agent persists its own results (e.g. expert-analysis writes rows 29641 * to expert-analysis-results, surfaced on the CEO dashboard). 29642 * 29643 * Reusable from both schedule-triggered and event-triggered workflows. 29644 */ 29645export interface RunAiWorkloadStep extends BaseStep { 29646 type: 'run_ai_workload'; 29647 channel: 'ai_workload'; 29648 /** ID into the AI_AGENTS registry, e.g. 'meta-expert-review'. */ 29649 agentId: string; 29650 /** Optional payload override forwarded to the agent Lambda (merged onto the 29651 * agent's defaultPayload). */ 29652 payload?: Record<string, unknown>; 29653 /** Defaults to 'Event' (async). Use 'RequestResponse' only when a downstream 29654 * step needs the agent's return value in the same workflow execution. */ 29655 invocationType?: 'Event' | 'RequestResponse'; 29656} 29657 29658/** 29659 * Run one conversation turn of the standard AI sales workload on the SMS 29660 * channel (plan \`unified-sales-agent-runtime.md\` §7; piece C1 step 8, stub). 29661 * Sibling of \`manage_appointment\`: on an inbound_sms trigger it IS the SMS 29662 * adapter (runtime v2); on an \`nba.action_due\` trigger (\`trigger.kind = 29663 * 'due_action'\`) it composes the opener for the catalogue entry. In C1 the due- 29664 * action branch logs and acks with no send; piece D makes it real. 29665 */ 29666export interface RunConversationTurnStep extends BaseStep { 29667 type: 'run_conversation_turn'; 29668 /** app-configs row id (logging / parity with manage_appointment). */ 29669 appId?: string; 29670 maxHistoryMessages?: number; 29671 maxPerSlot?: number; 29672 confirmationTemplates?: { book?: string; reschedule?: string; cancel?: string; anotherTime?: string }; 29673} 29674 29675/** 29676 * Wait/delay step 29677 */ 29678export interface WaitStep extends BaseStep { 29679 type: 'wait'; 29680 /** Delay in minutes */ 29681 delayMinutes: number; 29682} 29683 29684/** 29685 * Conditional step - execute different paths based on condition 29686 */ 29687export interface IfStep extends BaseStep { 29688 type: 'if'; 29689 condition: StepCondition; 29690 /** Steps to execute if condition is true */ 29691 then: CampaignStep[]; 29692 /** Steps to execute if condition is false (optional) */ 29693 else?: CampaignStep[]; 29694} 29695 29696/** 29697 * Generate messages step - pre-render SMS and email messages 29698 * Populates context.messages with fully rendered message content 29699 */ 29700export interface GenerateMessagesStep extends BaseStep { 29701 type: 'generate_messages'; 29702} 29703 29704/** 29705 * Generate AI reply step â AI-powered sibling of generate_messages. 29706 * Loads the inbound SMS conversation for the lead, calls OpenAI with the tenant's 29707 * smsAi config (tools + system prompt from @bigm/prompts), and sets 29708 * \`context.messages.sms.body\` to the AI-generated reply. Never sends â pair with 29709 * \`check_approval\` + \`send_sms\` downstream to control delivery. 29710 * 29711 * Plan: plans/how-in-the-ui-wild-thompson.md 29712 */ 29713/** 29714 * @deprecated RETIRED 20 Sep 2026 â the OpenAI reply generator is gone (Anthropic 29715 * only on the conversational path, plan \`unified-sales-agent-runtime.md\` §1d). 29716 * The engine logs and stages nothing for this step; kept so historical rows 29717 * still type-check. Use \`manage_appointment\` (runtime v2) or \`run_conversation_turn\`. 29718 */ 29719export interface GenerateAiReplyStep extends BaseStep { 29720 type: 'generate_ai_reply'; 29721 /** app-configs row whose smsAi block (model/temperature/tools) drives the call */
29722 appId: string; 29723 /** Optional @bigm/prompts version override (default: CURRENT_VERSIONS[SMS_REPLY_DEFAULT_SYSTEM]) */ 29724 promptVersion?: string; 29725 /** Inject customer-profile markdown into system prompt (default true) */ 29726 includeCustomerProfile?: boolean; 29727 /** Max recent SMS turns to include as conversation history (default 10) */ 29728 maxHistoryMessages?: number; 29729 /** Hard timeout in ms for the OpenAI call, default 15_000 */ 29730 timeoutMs?: number; 29731} 29732 29733/** 29734 * Check approval step - check if approval is required before sending 29735 */ 29736export interface CheckApprovalStep extends BaseStep { 29737 type: 'check_approval'; 29738} 29739 29740/** 29741 * Exit step - stop workflow execution 29742 */ 29743export interface ExitStep extends BaseStep { 29744 type: 'exit'; 29745} 29746 29747/** 29748 * Add free items step - adds warranty, shipping, concierge to order 29749 */ 29750export interface AddFreeItemsStep extends BaseStep { 29751 type: 'add_free_items'; 29752} 29753 29754/** 29755 * Generate cart permalink step - builds cart URL with discount code 29756 * Alternative to create_draft_order for discount code method 29757 */ 29758export interface GenerateCartPermalinkStep extends BaseStep { 29759 type: 'generate_cart_permalink'; 29760} 29761 29762/** 29763 * Create call-now recommendation 29764 * Updates Lead.callNowState to 'active' and creates call_now_recommended event 29765 */ 29766export interface CreateCallNowStep extends BaseStep { 29767 type: 'create_call_now'; 29768 channel: 'call_now'; 29769} 29770 29771/** 29772 * Create agent action â stamps a pending action on the Lead for the assigned agent. 29773 * Runs after distribute_to_pool so it knows which agent was assigned. 29774 */ 29775export interface CreateAgentActionStep extends BaseStep { 29776 type: 'create_agent_action'; 29777 actionType: AgentActionType; 29778 priority?: 'normal' | 'high'; 29779 expiryMinutes?: number; 29780 cooldownMinutes?: number; 29781} 29782 29783/** 29784 * Create AI outbound call 29785 * Triggers an automated AI voice call to the lead via Twilio + OpenAI Realtime 29786 */ 29787export interface CreateAiCallStep extends BaseStep { 29788 type: 'create_ai_call'; 29789 channel: 'ai_call'; 29790} 29791 29792/** 29793 * Route chat session to AI agent. 29794 * Sets session.assignedAgent = '__ai__' and invokes chat-reply-generator Lambda. 29795 * Used when no human agent is available (workflow-configurable fallback). 29796 */ 29797export interface RouteToAiAgentStep extends BaseStep { 29798 type: 'route_to_ai_agent'; 29799} 29800 29801/** 29802 * Resolve agent video step - looks up calling agent's video from S3
29803 * Maps MaxContact userId â agent record â video asset 29804 */ 29805export interface ResolveAgentVideoStep extends BaseStep { 29806 type: 'resolve_agent_video'; 29807 scope?: 'general' | 'product' | 'collection' | 'discount' | 'category'; 29808 /** Video delivery method - determines which variant to resolve */ 29809 delivery?: 'link' | 'mms'; 29810} 29811 29812/** 29813 * Check video sent step - checks if a video of this scope was already sent to the lead 29814 */ 29815export interface CheckVideoSentStep extends BaseStep { 29816 type: 'check_video_sent'; 29817} 29818 29819/** 29820 * Evaluate eligibility step - runs configured eligibility checks at runtime 29821 * Reads checks from campaign.eligibility.channelChecks[channel].checks 29822 * Stops workflow if any check fails. 29823 */ 29824export interface EvaluateEligibilityStep extends BaseStep { 29825 type: 'evaluate_eligibility'; 29826 /** Which channel's checks to evaluate (default: 'sms') */ 29827 channel?: 'sms' | 'email' | 'site' | 'call_now' | 'ai_call'; 29828} 29829 29830/** 29831 * Assign agent ownership step - sets ownedByAgent on the Lead record 29832 * Uses the calling agent's MaxContact userId from eventData. 29833 * This is NOT a BUYHOT/BUYCLO disposition â it's a workflow-driven ownership assignment. 29834 */ 29835export interface AssignAgentOwnershipStep extends BaseStep { 29836 type: 'assign_agent_ownership'; 29837 /** Reason for ownership (stored as ownedByReason on Lead). Default: 'workflow_assigned' */ 29838 reason?: OwnershipReason; 29839 /** Hours until ownership expires (optional, omit for indefinite) */ 29840 expiryHours?: number; 29841} 29842 29843/** 29844 * Distribute to agent pool step - assigns lead to an agent from a configured pool. 29845 * Uses tenant's DistributionConfig to resolve pool, check agent availability, 29846 * and apply round-robin (or other strategy) assignment. 29847 * If no agents are available, the lead is left unassigned. 29848 */ 29849export interface DistributeToPoolStep extends BaseStep { 29850 type: 'distribute_to_pool'; 29851 /** Specific pool ID to use (omit for auto-match by trigger type/channel) */ 29852 poolId?: string; 29853 /** Override the pool's default strategy */ 29854 strategy?: DistributionStrategy; 29855 /** Hours until ownership expires (omit for indefinite) */ 29856 expiryHours?: number; 29857 /** Reason stamped on Lead.ownedByReason (default: 'pool_distributed') */ 29858 reason?: OwnershipReason; 29859 /** If true, prefer the agent with BUYHOT/BUYCLO disposition on the lead before round-robin */ 29860 preferDispositionAgent?: boolean; 29861} 29862 29863/** 29864 * Dismiss and close lead step â auto-closes a lead based on disposition code. 29865 * Reads resultCode from context.checkout.eventData.resultCode (populated by load_event), 29866 * maps it via dispositionToSalesCycle(), then: 29867 * 1. Dismisses any pending agent action 29868 * 2. Confirms sales cycle stage (forward-only) 29869 * 3. Sets buyer readiness and/or exit code 29870 * 4. Stamps a close_lead audit record with workflow ID 29871 */ 29872export interface DismissAndCloseLeadStep extends BaseStep { 29873 type: 'dismiss_and_close_lead'; 29874 /** 29875 * If set, only close the lead when the triggering event's \`eventData.actor\` 29876 * is in this list. Undefined or empty = fire on any actor (legacy behaviour). 29877 */ 29878 allowedActors?: EventActor[]; 29879 /** 29880 * If true, only run when a send_sms step earlier in THIS workflow run 29881 * actually delivered (context.smsSentInRun). Protects cron/backbook openers 29882 * that close the agent action after texting the lead: send failures and 29883 * per-step suppressions (backfill guard, falsePositive, channel opt-out) are 29884 * non-fatal and the workflow continues â without this flag the action would 29885 * be expired off the agent with no SMS ever sent. 29886 */ 29887 requireSmsSentInRun?: boolean; 29888} 29889 29890/** 29891 * Mark agent action in-progress â flips a pending agentActionState to 'in_progress'. 29892 * Triggered by *_call_started events when an agent goes live with a lead. 29893 * Reverted to 'dismissed' by dismiss_and_close_lead on call completion. 29894 */ 29895export interface MarkActionInProgressStep extends BaseStep { 29896 type: 'mark_action_in_progress'; 29897} 29898 29899/** 29900 * Build a multi-rail "How to pay" HTML fragment and stash it on the workflow 29901 * context for the email template to render via \`{{paymentOptionsBlock}}\`. 29902 * Reads toggles from campaign.paymentOptions and pulls bank-transfer text 29903 * live from Shopify manual payment methods via @bigm/shared. 29904 */ 29905export interface AddPaymentOptionsBlockStep extends BaseStep { 29906 type: 'add_payment_options_block'; 29907} 29908 29909/** 29910 * UserRole â mirrors the values stored in Cognito \`custom:role\`. 29911 * Used by SendPushNotificationStep when target.kind === 'role'. 29912 */ 29913export type UserRole = 29914 | 'app_admin' 29915 | 'tenant_admin' 29916 | 'tenant_sales' 29917 | 'tenant_marketing' 29918 | 'tenant_operations'; 29919 29920/** 29921 * Push notification target â three resolution modes. 29922 * 29923 * - \`assigned-agent\`: pulls Lead.agentActionAssignedTo (or eventData.agentMaxId 29924 * for \`agent.assigned\` triggers), resolves maxId â cognitoSub via the agents 29925 * GSI. Use for "notify whichever agent now owns this". 29926 * - \`role\`: queries Cognito users in the ShopDash pool whose \`custom:role\` 29927 * matches AND whose \`custom:tenants\` array contains the lead's tenantName. 29928 * Use for "notify all tenant_admins of THIS tenant". 29929 * - \`cognito-subs\`: pre-resolved Cognito subs. Use for tests, distress 29930 * escalation lists, or any caller that has already resolved targets. 29931 */ 29932export type PushNotificationTarget = 29933 | { kind: 'assigned-agent' } 29934 | { kind: 'role'; roles: UserRole[] } 29935 | { kind: 'cognito-subs'; subs: string[] }; 29936 29937/** 29938 * Send a OneSignal push notification. 29939 * 29940 * Replaces the inline push call previously in \`create-agent-action.ts\`. 29941 * One configurable primitive used for: agent-action-assigned, manual reassign, 29942 * AI-workload-completed (CEO dashboard), distress alerts, and any future 29943 * notification trigger. Operator picks target + title + body in the workflow 29944 * builder. 29945 * 29946 * \`title\` and \`body\` support \`{{var}}\` interpolation against: 29947 * - context.lead.* (e.g. \`{{masterProfile.firstName}}\`, \`{{tenantName}}\`) 29948 * - context.checkout.eventData.* (the triggering event's payload) 29949 * - context.lead-derived fields like \`{{leadId}}\` 29950 * 29951 * \`body\` is sliced to 240 chars (Safari Web Push limit). 29952 * 29953 * \`collapseId\` defaults to \`\${type}-\${leadId}\` so re-fires for the same 29954 * "thing" replace the prior toast on the lock screen instead of stacking. 29955 * 29956 * Identity: external_user_id = Cognito sub. Same scheme as Twilio Device 29957 * (callStore.ts) and EDA. Phantom-sub safe (uses \`include_external_user_ids\` 29958 * + \`channel_for_external_user_ids\` per BigM/packages/shared/src/onesignal). 29959 */ 29960export interface SendPushNotificationStep extends BaseStep { 29961 type: 'send_push_notification'; 29962 target: PushNotificationTarget; 29963 title: string; 29964 body: string; 29965 collapseId?: string; 29966} 29967 29968/** 29969 * Parses the Shopify order's timeline CommentEvent for a staff-written 29970 * "Agent: X / Humm/Payright/Outright: Y" template, resolves the agent name
29971 * to a MaxContact ID via the agents table, and stamps the 4 \`custom.*\` 29972 * sales-attribution metafields on the order. 29973 * 29974 * Dispatch matrix (inside the tool): 29975 * - eventType=order.confirmed, empty metafields â parse + stamp 29976 * - eventType=order.confirmed, only workflow:/ai: markers â parse + append human 29977 * - eventType=order.confirmed, source_name=web + no agent parseable â stamp system:ecommerce 29978 * - eventType=order.confirmed, already has human MaxId â no-op (manual save won) 29979 * - eventType=order.cancelled â dismiss pending confirm_sales_attribution agent_action; leave metafields 29980 * 29981 * On any successful stamp, writes salesAttribution.needsConfirmation=false back 29982 * onto the events-customer row so the delayed confirm-sales-attribution 29983 * workflow's eligibility check correctly skips (no duplicate blue pulse). 29984 */ 29985export interface AutoStampSalesAttributionStep extends BaseStep { 29986 type: 'auto_stamp_sales_attribution'; 29987} 29988 29989/** 29990 * Manage appointment step â Claude-only, self-contained sibling of generate_ai_reply 29991 * for SMS appointment booking. Gates on the most-recent outbound SMS campaignId 29992 * (â bookingCampaignIds), makes ONE Claude call (booking prompt â reply + structured 29993 * action), writes/updates/cancels the lead's single active \`appointments\` row (validating 29994 * the :00/:30 grid + business hours), emits an \`appointment.*\` event, and sets 29995 * \`context.messages.sms.body\` for the downstream \`send_sms\` step. No-ops (sets nothing, 29996 * so send_sms auto-skips) when the gate fails or the action is \`none\`. 29997 */ 29998export interface ManageAppointmentStep extends BaseStep { 29999 type: 'manage_appointment'; 30000 /** app-configs row id (logging / parity with generate_ai_reply) */ 30001 appId: string; 30002 /** 30003 * @deprecated Vestigial â the gate moved to the handler row's 30004 * \`evaluate_eligibility\` \`check_last_outbound_sms_campaign\` include list 30005 * (2026-06-23), which is also the source of truth for the per-tenant 30006 * appointment-opener registry (\`getAppointmentOpenerCampaignIds\` in 30007 * @bigm/shared). Do not read or extend this list. 30008 */ 30009 bookingCampaignIds: string[]; 30010 /** Optional @bigm/prompts version override for APPOINTMENT_BOOKING_SYSTEM */ 30011 promptVersion?: string; 30012 /** Max recent SMS turns to include as conversation history (default 10) */ 30013 maxHistoryMessages?: number; 30014 /** Hard timeout in ms for the Claude call, default 15_000 */ 30015 timeoutMs?: number; 30016 /** Soft per-slot capacity (best-effort, GSI-race). When set, a book/reschedule 30017 * into a slot already holding >= maxPerSlot active appointments is rejected 30018 * and the next under-capacity slot is offered. Undefined = unlimited (legacy). */ 30019 maxPerSlot?: number; 30020 /** How many A/B/C slots the opener offered (default 3) â used when re-offering. */ 30021 offeredSlotsCount?: number; 30022 /** Editable SMS template ids (folders in {tenantId}/sms/) used for the 30023 * operator-controlled confirmation copy on a SUCCESSFUL action. The matching 30024 * template is rendered ({{firstName}}, {{appointmentTime}}) and replaces the 30025 * built-in/AI wording. Any omitted key â keep the built-in copy for that action. 30026 * \`anotherTime\` is the deterministic reply to the offer's "D) another time" 30027 * option (renders {{firstName}} only â no appointment exists yet). */ 30028 confirmationTemplates?: { book?: string; reschedule?: string; cancel?: string; anotherTime?: string }; 30029} 30030 30031/** 30032 * Offer 3 (configurable) concrete appointment slots labelled A/B/C in the opener 30033 * SMS. Computes capacity-aware, spread slots over the next \`horizonHours\`, stamps 30034 * them on the lead's \`appointmentOffer\`, builds the body (A/B/C + chair link Ã3 + 30035 * free-text invite) into \`context.messages.sms.body\`. MMS image is the campaign's 30036 * \`messages.sms.mediaUrl\`. Sits before \`check_approval\` + \`send_sms\` in the opener. 30037 */ 30038export interface OfferAppointmentSlotsStep extends BaseStep { 30039 type: 'offer_appointment_slots'; 30040 /** How many slots to offer (default 3 â A/B/C). */ 30041 offeredSlotsCount?: number; 30042 /** Soft per-slot capacity for spreading + skipping full slots (default 5). */ 30043 maxPerSlot?: number; 30044 /** How far ahead to look for slots, in hours (default 24). */ 30045 horizonHours?: number; 30046 /** Product page to link as the "chair of the year" offer. When unset, the 30047 * chair lines are omitted (never emit a broken URL). Minted via mintShortLink. */ 30048 chairOfferUrl?: string; 30049 /** Override the chair offer line text (default "up to 50% offâ¦"). */ 30050 chairOfferText?: string; 30051 /** Name the message is signed with, and the \`{{agent}}\` template token. 30052 * Resolution order: the agent whose video is attached this run 30053 * (\`context.agentVideo.agentFirstName\`, set by a prior \`resolve_agent_video\` 30054 * step) â this field â "Steve". Set this on rows that offer slots without a 30055 * per-agent video so the signature is not the hardcoded default. */ 30056 agentName?: string; 30057 /** Per-slot suffix on each A/B/C line (default "(phone consultation)"). 30058 * Set "" to drop it (e.g. the missed-call opener wants bare times). */ 30059 slotSuffix?: string; 30060 /** The "another time" line after A/B/C (default "D) Text me another time that works"). */ 30061 otherOptionText?: string; 30062 /** Dynamic opener (tenant \`aiAgent.dynamic_opener\`): operator-editable S3 SMS 30063 * template ids per segment. \`customer\` overrides the built-in service copy for 30064 * buyers; \`prospect\` mirrors the existing single-template path. Any omitted 30065 * key â built-in copy for that segment. Tokens: {{firstName}}, 30066 * {{appointmentSlots}}, {{chairLink}}, {{productTitle}}, {{deliveryLine}}. */ 30067 openerTemplates?: { prospect?: string; customer?: string }; 30068} 30069 30070/** 30071 * Post a Xero accounting entry for a Shopify order event (Phase 1: sales + amendments). 30072 * 30073 * Reads the order from \`context.checkout\` (a prior \`load_event\` step) plus the lead's 30074 * \`leadSource.channel\`, builds an ACCREC invoice per the tenant's Xero accounting profile 30075 * (\`@bigm/shared\` buildSalesInvoice), and â depending on the resolved postMode â either logs 30076 * the payload (\`dry_run\`), creates a DRAFT, or creates a live AUTHORISED invoice. 30077 * 30078 * Best-effort / non-blocking: a Xero failure never fails the pipeline. NOT an outreach step 30079 * (bookkeeping only). Ships DORMANT â the seeded campaign is \`active:false\` with NO registered 30080 * trigger rule until an explicit, guarded go-live. See the plan's "Enable sequence". 30081 */ 30082export interface PostToXeroStep extends BaseStep { 30083 type: 'post_to_xero'; 30084 /** 30085 * Override the tenant accounting profile's postMode for this step. 30086 * Defaults to the profile's postMode (itself defaulting to 'dry_run'). 30087 */ 30088 postMode?: 'dry_run' | 'draft' | 'live'; 30089} 30090 30091/** 30092 * Move the lead's Zoho Deal Stage so MaxContact stops (or resumes) dialling it 30093 * (plan \`ai-and-appointment-dial-stop.md\`, Chris 1 Oct 2026). Rules live in \`@bigm/shared\` 30094 * \`applyDialStop\`. Bookkeeping, never outreach; a Zoho failure never fails the workflow. 30095 * Writes to Zoho only when the tenant's \`aiAgent.dialStop.enabled\` is true. 30096 */ 30097/** 30098 * AI Service agent turn for an EXISTING CUSTOMER (plan \`ai-service-workload.md\`, Chris 1 Oct 2026). Separate from 30099 * \`manage_appointment\`: own prompt, own tools, never books; it answers order and delivery questions from the lead's 30100 * events and raises a service action for a person (with the time the customer said suits them). \`missed_call\` mode 30101 * sends a deterministic "sorry we missed you" text with no model call. Both modes exit silently for a lead that is 30102 * not a customer, so the row's checks can stay thin. Writes to \`context.messages.sms\` for a downstream \`send_sms\`. 30103 */ 30104export interface RunServiceTurnStep extends BaseStep { 30105 type: 'run_service_turn'; 30106 mode?: 'reply' | 'missed_call'; 30107} 30108 30109export interface SetZohoStageStep extends BaseStep { 30110 type: 'set_zoho_stage'; 30111 /** \`ai_card\` / \`appointment\` hold the lead (Buyer Closing); \`paid\` marks an AI-card lead Closed Won. */ 30112 reason: 'ai_card' | 'appointment' | 'paid'; 30113} 30114
30115/** 30116 * Union type for all campaign step types 30117 */ 30118/** 30119 * Stage an SMS with the lead's nearest reseller showroom details (address, 30120 * phone, who to ask for, hours, optional website). Pass-through stager like 30121 * offer_appointment_slots: builds context.messages.sms.body for a downstream 30122 * send_sms step (which carries all outreach guards) and messages nothing 30123 * itself. Resolution: lead geocode (address verification) when fresh, else 30124 * postcode (salesContext.deliveryPostcode â shipping zip â billing zip) via 30125 * the @bigm/shared reseller-locator over RESELLER_REGISTRY. Restricted, 30126 * closed, former and online-only resellers are never suggested. 30127 */ 30128export interface SendResellerDetailsStep extends BaseStep { 30129 type: 'send_reseller_details'; 30130 /** Showrooms to include in the message (default 1). */ 30131 maxResults?: number; 30132 /** Haversine cap in km â beyond it the step stages nothing (default none). */ 30133 maxDistanceKm?: number; 30134 /** Include the reseller's website URL in the SMS (default false). */ 30135 includeWebsite?: boolean; 30136 /** Only suggest stockists of this chair family (e.g. "TheraMax"). */ 30137 chairFamily?: string; 30138 /** Optional /templates SMS override; tokens {{firstName}}, {{resellerDetails}}. */ 30139 templateId?: string; 30140} 30141 30142/** 30143 * Send the RESELLER leg of a reseller appointment SMS â the one step on the 30144 * platform whose destination is NOT the lead. Reads \`eventData.resellerPhone\` 30145 * (+ reseller/customer identity and \`customerCardUrl\`) from the triggering 30146 * \`reseller_appointment.*\` event and sends directly via the toolcall send-sms 30147 * endpoint, attributed to the customer's leadId with \`actor: 30148 * 'workflow_automated'\` and a top-level \`phone\` stamp so the send lands in the 30149 * reseller's /messages thread. The customer leg stays a normal send_sms. 30150 * 30151 * â The rendered body must NEVER contain pricing (22 Jul 2026 leak): the 30152 * template is reseller-facing and the no-pricing test pins it. 30153 * In \`testMode\` the destination is \`tenants.contactInfo.test.resellerPhoneNumber\`. 30154 */ 30155export interface SendResellerSmsStep extends BaseStep { 30156 type: 'send_reseller_sms'; 30157 /** /templates SMS template id for the reseller-leg body. Tokens: 30158 * {{resellerContactName}} {{customerName}} {{appointmentDate}} 30159 * {{appointmentTime}} {{locationName}} {{customerCardUrl}}. */ 30160 templateId?: string; 30161} 30162 30163/** 30164 * Resolve welcome-email content for a \`delivery.booked\` event (welcome-email 30165 * plan Phase 5). Reads the SAP parent SKUs off the loaded event, resolves them 30166 * through the \`welcome-template-map\` table to ONE content pack (+ companions), 30167 * derives the delivery type, applies the HOLD rules, renders the pack and 30168 * delivery blocks from the tenant's S3 template prefix, and stamps the 30169 * placeholders on \`context.welcome\` for \`send_email\`. Any hold / unmapped SKU 30170 * / missing content raises an agent action and stops the run (IneligibleError) 30171 * â silence beats a wrong email. 30172 */ 30173export interface ResolveWelcomeContentStep extends BaseStep { 30174 type: 'resolve_welcome_content'; 30175 /** S3 template id under \`{tenant}/email/\` holding template.html, block.*.html, pack.*. Default \`welcome-delivery-email-v1\`. */ 30176 templateId?: string; 30177 /** Hold when the requested delivery date is more than this many days out (measured MMC p97 = 13). Default 14. */ 30178 maxLeadDays?: number; 30179 /** Delivery type when neither the order metafield nor the line items say. Default \`white_glove\`. */ 30180 defaultDeliveryType?: 'white_glove' | 'kerbside' | 'pickup'; 30181 /** Read \`custom.welcome_hold\` / \`custom.sale_details\` / \`custom.delivery_details\` from Shopify. Default true. Fail-open. */ 30182 checkOrderMetafields?: boolean; 30183 /** Agent action raised on every hold. Default \`review_lead\`. */ 30184 holdActionType?: AgentActionType; 30185} 30186 30187export type CampaignStep = 30188 | LoadCheckoutStep 30189 | LoadEventStep 30190 | EvaluateDiscountStep 30191 | AddFreeItemsStep 30192 | CreateDraftOrderStep 30193 | GenerateCartPermalinkStep 30194 | GenerateMessagesStep 30195 | GenerateAiReplyStep 30196 | CheckApprovalStep 30197 | SendSmsStep 30198 | SendEmailStep 30199 | RenderDraftOrderImageStep 30200 | CreateCallNowStep 30201 | CreateAgentActionStep 30202 | CreateAiCallStep 30203 | RouteToAiAgentStep 30204 | ResolveAgentVideoStep 30205 | CheckVideoSentStep 30206 | EvaluateEligibilityStep 30207 | AssignAgentOwnershipStep 30208 | DistributeToPoolStep 30209 | DismissAndCloseLeadStep 30210 | MarkActionInProgressStep 30211 | AddPaymentOptionsBlockStep 30212 | AutoStampSalesAttributionStep 30213 | SendPushNotificationStep 30214 | RunAiWorkloadStep 30215 | RunConversationTurnStep 30216 | ManageAppointmentStep 30217 | OfferAppointmentSlotsStep
30218 | SendResellerDetailsStep 30219 | SendResellerSmsStep 30220 | PostToXeroStep 30221 | SetZohoStageStep 30222 | RunServiceTurnStep 30223 | ResolveWelcomeContentStep 30224 | WaitStep 30225 | IfStep 30226 | ExitStep; 30227 30228/** 30229 * Workflow definition 30230 */ 30231export interface Workflow { 30232 /** Workflow version for evolution tracking */ 30233 version: string; 30234 /** Ordered list of steps to execute */ 30235 steps: CampaignStep[]; 30236} 30237 30238/** 30239 * Pre-generated SMS message 30240 */ 30241export interface PreGeneratedSms { 30242 to: string; 30243 body: string; 30244 /** MMS media staged by the conversation runtime's \`send_media\` skill (20 Sep 2026). 30245 * \`send_sms\` honours it only for a pre-generated body; template flows never set it. */ 30246 mediaUrl?: string[]; 30247} 30248 30249/** 30250 * Pre-generated email message 30251 * 30252 * When templateId is provided, htmlBody may be omitted to avoid DynamoDB size limits. 30253 * The template should be fetched from S3 and rendered on demand. 30254 */ 30255export interface PreGeneratedEmail { 30256 to: string; 30257 subject: string; 30258 textBody: string; 30259 /** Rendered HTML body - may be omitted if templateId is set */ 30260 htmlBody?: string; 30261 /** S3 template reference - fetch and render on demand instead of storing htmlBody */ 30262 templateId?: string; 30263 /** Tenant ID for S3 template path (required when templateId is set) */ 30264 templateTenantId?: string; 30265} 30266 30267/** 30268 * Pre-generated messages (populated by generate_messages step) 30269 */ 30270export interface PreGeneratedMessages { 30271 sms?: PreGeneratedSms; 30272 email?: PreGeneratedEmail; 30273 trackingLink: string; 30274 /** Optional second SMS sent by send_sms AFTER the primary body (1.5s apart, 30275 * same Part-1/Part-2 pattern as splitLink). Used for the contact-card push: 30276 * a message whose trailing link stands alone gets the rich iOS contact-card 30277 * preview, which an appended link inside a longer body does not. */ 30278 smsFollowUp?: { body: string }; 30279} 30280 30281/** 30282 * Execution context - maintains state between workflow steps 30283 */ 30284export interface ExecutionContext { 30285 /** Event ID that triggered the workflow */ 30286 eventId: string; 30287 /** Lead ID associated with the event */ 30288 leadId?: string; 30289 /** Campaign configuration */ 30290 campaign: Record<string, unknown>; 30291 /** App configuration */ 30292 app: Record<string, unknown>; 30293 /** Checkout data (populated by load_checkout step) */ 30294 checkout?: Record<string, unknown>; 30295 /** Discount evaluation result (populated by evaluate_discount or create_promotional_offer step) */ 30296 discount?: { 30297 totalSavings: number | null; 30298 productName: string; 30299 freeItemsAdded: string[]; 30300 discountPercent?: number; 30301 discountCode?: string; 30302 }; 30303 /** Draft order result (populated by create_draft_order step) */ 30304 draftOrder?: { 30305 draftOrderId: number; 30306 invoiceUrl: string; 30307 /** Actual line items from Shopify draft order response */ 30308 lineItems?: Array<{ 30309 title: string; 30310 variantTitle?: string; 30311 variantId: number; 30312 quantity: number; 30313 price: string; 30314 sku?: string; 30315 }>; 30316 /** Actual total price from Shopify draft order */ 30317 totalPrice?: string; 30318 /** Actual subtotal price from Shopify draft order */ 30319 subtotalPrice?: string; 30320 /** Currency code */ 30321 currency?: string; 30322 }; 30323 /** Cart permalink result (populated by generate_cart_permalink step) */ 30324 cartPermalink?: { 30325 /** Cart URL with ?discount=CODE parameter */ 30326 permalink: string; 30327 /** Direct checkout URL variant */ 30328 checkoutUrl: string; 30329 }; 30330 /** Free items to add (populated by add_free_items step) */ 30331 freeItems?: { 30332 freeItemsAdded: string[]; 30333 freeItemVariants: Array<{ 30334 variantId: string; 30335 title: string; 30336 action: 'added' | 'overridden' | 'skipped'; 30337 reason: string; 30338 }>; 30339 }; 30340 /** Pre-generated messages (populated by generate_messages step) */ 30341 messages?: PreGeneratedMessages; 30342 /** 30343 * Promotional offer result (populated by create_promotional_offer step) 30344 * This unified result replaces the separate draftOrder/cartPermalink/discount/freeItems 30345 * when using the centralized promotional offer service. 30346 */ 30347 promotionalOffer?: { 30348 /** Type of offer created */ 30349 offerType: 'draft_order' | 'cart_permalink'; 30350 /** Primary link (invoiceUrl for draft order, permalink for cart) */ 30351 link: string; 30352 /** Draft order details */ 30353 draftOrder?: { 30354 draftOrderId: number; 30355 invoiceUrl: string; 30356 adminUrl?: string; 30357 }; 30358 /** Cart permalink details */ 30359 cartPermalink?: { 30360 permalink: string; 30361 checkoutUrl: string; 30362 }; 30363 /** Free items that were added */
30364 freeItemsAdded: string[]; 30365 /** Discount summary */ 30366 discountSummary?: { 30367 totalSavings: number | null; 30368 primaryProduct: string; 30369 effectiveDiscountPercent: number; 30370 eligibleItemCount: number; 30371 }; 30372 }; 30373 /** Lead data */ 30374 lead?: { 30375 id: string; 30376 phoneNumber?: string; 30377 emailAddress?: string; 30378 tenantName?: string; 30379 name?: string; 30380 /** Lead's master profile (aggregated from all events) */ 30381 masterProfile?: Record<string, unknown>; 30382 /** The customer-confirmed AU state, when the lead has one (S40: the opener shows its 30383 * slots in the customer's own zone instead of Melbourne's). */ 30384 confirmedState?: string; 30385 /** 4-digit AU postcode, when known â the zone fallback behind \`confirmedState\`. */ 30386 deliveryPostcode?: string; 30387 /** Legacy lead data */ 30388 leadData?: Record<string, unknown>; 30389 }; 30390 /** Execution metadata */ 30391 metadata?: { 30392 executionId: string; 30393 startedAt: string; 30394 completedAt?: string; 30395 /** True when invoked by the campaign-delay-scanner (delay already applied) */ 30396 scheduled?: boolean; 30397 }; 30398 /** Agent video resolved at runtime (populated by resolve_agent_video step) */ 30399 agentVideo?: { agentId: string; cdnUrl: string; s3Key: string; agentFirstName?: string }; 30400 /** Whether the call was answered (populated by load_event for call events) */ 30401 callAnswered?: boolean; 30402 /** Whether a video of this scope was already sent to this lead */ 30403 videoAlreadySent?: boolean; 30404 /** Set by workflow executor when eligibility check fails â signals handler to release claim */ 30405 ineligible?: boolean; 30406 /** Set by workflow executor when CDR not yet received â signals handler to re-schedule with short delay */ 30407 dispositionRetry?: boolean; 30408 /** 30409 * Rendered HTML fragment for the "How to pay" block, populated by the 30410 * \`add_payment_options_block\` step. Surfaced in the email template via 30411 * the \`{{paymentOptionsBlock}}\` placeholder. Empty string = omit. 30412 */ 30413 paymentOptionsBlock?: string; 30414} 30415 30416/** 30417 * Execution options for workflow execution 30418 */ 30419export interface ExecutionOptions { 30420 /** If true, skip send_sms and send_email steps (dry-run mode) */ 30421 dryRun?: boolean; 30422 /** If true, use test contact details instead of lead contact details */ 30423 testMode?: boolean; 30424 /** Test phone number (used when testMode is true) */ 30425 testPhoneNumber?: string; 30426 /** Test email address (used when testMode is true) */ 30427 testEmailAddress?: string; 30428 /** Test destination for the RESELLER leg of send_reseller_sms (used when 30429 * testMode is true; from tenants.contactInfo.test.resellerPhoneNumber). 30430 * Absent in testMode â the reseller leg is SKIPPED, never sent to the real 30431 * reseller number. */ 30432 testResellerPhoneNumber?: string; 30433} 30434 30435`,Fn={class:"min-h-screen bg-surface-soft"},Hn={class:"page-container py-4 sm:py-6"},Vn={key:0,class:"empty-state py-16"},Gn={key:1,class:"space-y-4 sm:space-y-6"},Wn={class:"flex overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0 gap-2 pb-2",style:{"-webkit-overflow-scrolling":"touch"}},qn=["onClick","title"],Kn={key:0,class:"data-indicator"},Yn={class:"type-panel"},jn={class:"panel-header"},$n={class:"panel-title"},zn={class:"file-path"},Qn={class:"view-tabs"},Xn=["onClick"],Zn={class:"tab-panel"},Jn={class:"code-container"},et={class:"code-content"},nt={class:"tab-panel"},tt={class:"form-section"},at={class:"json-tree-container"},rt={class:"tab-panel"},it={class:"form-section"},ot={class:"section-description"},st={key:0,class:"empty-store-message"},lt={key:1,class:"empty-store-message"},dt={class:"json-tree-container"},ct=ie({__name:"TypesView",setup(pt){const F=Object.assign({"/packages/types/src/agent-alarm.ts":ye,"/packages/types/src/agent-distribution.ts":be,"/packages/types/src/agent-readiness.ts":ve,"/packages/types/src/ai-agent.ts":we,"/packages/types/src/ai-agents.ts":Se,"/packages/types/src/ai-business-rules.ts":Ce,"/packages/types/src/ai-turn-feedback.ts":Ae,"/packages/types/src/ai-workload-run.ts":ke,"/packages/types/src/alarm-topic.ts":Te,"/packages/types/src/app-config.ts":Ie,"/packages/types/src/appointment.ts":_e,"/packages/types/src/backfill-checkpoint.ts":Ee,"/packages/types/src/brand-profile.ts":xe,"/packages/types/src/business-event.ts":De,"/packages/types/src/campaign-approval.ts":Pe,"/packages/types/src/campaign-audience.ts":Re,"/packages/types/src/campaign-context.ts":Me,"/packages/types/src/campaign-send.ts":Oe,"/packages/types/src/cart-attributes.ts":Ne,"/packages/types/src/cart-permalink.ts":Le,"/packages/types/src/change-log.ts":Ue,"/packages/types/src/chat.ts":Be,"/packages/types/src/cohorts.ts":Fe,"/packages/types/src/delivery-booking.ts":He,"/packages/types/src/delivery-confirmation.ts":Ve,"/packages/types/src/discount-rules.ts":Ge,"/packages/types/src/draft-order-builder.ts":We,"/packages/types/src/draft-order-preview.ts":qe,"/packages/types/src/eda.ts":Ke,"/packages/types/src/event-customer.ts":Ye,"/packages/types/src/event-email.ts":je,"/packages/types/src/expertAnalysis.ts":$e,"/packages/types/src/external-orders.ts":ze,"/packages/types/src/freight.ts":Qe,"/packages/types/src/improvement.ts":Xe,"/packages/types/src/index.ts":Ze,"/packages/types/src/inventory-snapshot.ts":Je,"/packages/types/src/knowledge.ts":en,"/packages/types/src/lead-prioritisation.ts":nn,"/packages/types/src/lead-rules.ts":tn,"/packages/types/src/lead.ts":an,"/packages/types/src/legal-entity.ts":rn,"/packages/types/src/max-agent-status.ts":on,"/packages/types/src/max-result-codes.test.ts":sn,"/packages/types/src/max-result-codes.ts":ln,"/packages/types/src/media-asset.ts":dn,"/packages/types/src/message-placeholders.ts":cn,"/packages/types/src/meta-ad-config.ts":pn,"/packages/types/src/meta-asset-metadata.ts":un,"/packages/types/src/mms-card-templates.ts":mn,"/packages/types/src/partners.ts":gn,"/packages/types/src/pii-feedback.ts":hn,"/packages/types/src/pii-prompt-ids.ts":fn,"/packages/types/src/placeholder-utils.ts":yn,"/packages/types/src/pricing.ts":bn,"/packages/types/src/product-catalog.ts":vn,"/packages/types/src/promotional-offer.ts":wn,"/packages/types/src/report-aggregates.ts":Sn,"/packages/types/src/reporting.ts":Cn,"/packages/types/src/resellers.ts":An,"/packages/types/src/revenue-ideas.ts":kn,"/packages/types/src/sales-attribution.ts":Tn,"/packages/types/src/sales-order.ts":In,"/packages/types/src/sales-training.ts":_n,"/packages/types/src/sap.ts":En,"/packages/types/src/service-health.ts":xn,"/packages/types/src/supplier-reconciliation.ts":Dn,"/packages/types/src/template-generator.ts":Pn,"/packages/types/src/tenant-inbound-policy.ts":Rn,"/packages/types/src/tenant-outbound-policy.ts":Mn,"/packages/types/src/tenant.ts":On,"/packages/types/src/training-example.ts":Nn,"/packages/types/src/twilio.ts":Ln,"/packages/types/src/usa-project-tracker.ts":Un,"/packages/types/src/workflow.ts":Bn}),k=f(!0),T=f([]),u=f(""),v=f("code"),H=[{id:"code",label:"Source Code"},{id:"tree",label:"Parsed View"},{id:"store",label:"Store Data"}],V=ce(),G=pe(),W=ue(),q=ge(),K=he(),P=fe(),R={lead:()=>Array.from(V.leads.values()),"event-customer":()=>Array.from(G.events.values()),tenant:()=>Array.from(W.tenants.values()),"app-template":()=>q.appTemplates,"campaign-send":()=>
30435Array.from(K.campaignSends.values()),"campaign-context":()=>Array.from(P.campaigns.values())},b=_(()=>{if(!u.value)return{records:[],total:0,storeName:null};const n=R[u.value];if(!n)return{records:[],total:0,storeName:null};const e=n();return{records:e.slice(0,3),total:e.length,storeName:u.value}});function w(n){const e=R[n];if(!e)return{hasStore:!1,hasData:!1,count:0};const t=e();return{hasStore:!0,hasData:t.length>0,count:t.length}}function Y(n){const e=w(n);return e.hasStore?e.hasData?`${e.count} records in store`:"Store mapped but no data loaded":"No Pinia store mapped"}const m=f(new Set([""]));function j(n){m.value.has(n)?m.value.delete(n):m.value.add(n),m.value=new Set(m.value)}function $(){const n=new Set([""]);function e(t,r){if(t&&typeof t=="object")for(const l of Object.keys(t)){const i=r?`${r}.${l}`:l;n.add(i),e(t[l],i)}}b.value.records.forEach((t,r)=>{e(t,String(r))}),m.value=n}function z(){m.value=new Set([""])}const g=f(new Set([""])),M=f(new Set),S=_(()=>T.value.find(n=>n.name===u.value)),O=_(()=>{const n=S.value?.content||"",{data:e,mandatoryPaths:t}=Q(n);return M.value=t,e});function Q(n){const e={exports:[],types:{},interfaces:{},constants:{},functions:[]},t=new Set,r=n.matchAll(/export\s+(?:type|interface|const|function|enum)\s+(\w+)/g);for(const o of r)e.exports.push(o[1]);const l=n.matchAll(/(?:export\s+)?type\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([^;]+(?:;|\n\s*\}|$))/gs);for(const o of l){const s=o[1],p=o[2].trim().replace(/;$/,"");e.types[s]=p.length>200?p.substring(0,200)+"...":p}const i=n.matchAll(/(?:export\s+)?interface\s+(\w+)\s*(?:extends\s+[^{]+)?\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs);for(const o of i){const s=o[1],p=o[2].trim(),{fields:I,fieldMandatoryPaths:ae}=X(p,`interfaces.${s}`);e.interfaces[s]=I;for(const re of ae)t.add(re)}const h=n.matchAll(/(?:export\s+)?const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(\[[^\]]*\]|\{[^}]*\}|['"][^'"]*['"]|\d+)/gs);for(const o of h){const s=o[1],p=o[2].trim();try{if(p.startsWith("[")){const I=p.replace(/'/g,'"').replace(/,\s*]/g,"]");e.constants[s]=JSON.parse(I)}else e.constants[s]=p}catch{e.constants[s]=p}}const A=n.matchAll(/(?:export\s+)?(?:async\s+)?function\s+(\w+)/g);for(const o of A)e.functions.push(o[1]);for(const o of Object.keys(e)){const s=e[o];(Array.isArray(s)&&s.length===0||typeof s=="object"&&s!==null&&Object.keys(s).length===0)&&delete e[o]}return{data:e,mandatoryPaths:t}}function X(n,e){const t={},r=[],l=n.split(` 30436`).map(i=>i.trim()).filter(i=>i&&!i.startsWith("//")&&!i.startsWith("/*")&&!i.startsWith("*"));for(const i of l){const h=i.match(/^(\w+)(\?)?:\s*(.+?);?$/);if(h){const A=h[1],o=!!h[2],s=h[3].replace(/;$/,"");t[A]=s,o||r.push(`${e}.${A}`)}}return{fields:t,fieldMandatoryPaths:r}}function Z(n){return M.value.has(n)}function J(n){g.value.has(n)?g.value.delete(n):g.value.add(n),g.value=new Set(g.value)}function ee(){const n=new Set([""]);function e(t,r){if(t&&typeof t=="object")for(const l of Object.keys(t)){const i=r?`${r}.${l}`:l;n.add(i),e(t[l],i)}}e(O.value,""),g.value=n}function ne(){g.value=new Set([""])}function te(){k.value=!0;const n=[];for(const[e,t]of Object.entries(F)){const r=e.split("/").pop()||"";if(r==="index.ts")continue;const l=r.replace(".ts",""),i=l.split("-").map(h=>h.charAt(0).toUpperCase()+h.slice(1)).join(" ");n.push({name:l,displayName:i,filename:r,content:t})}n.sort((e,t)=>e.displayName.localeCompare(t.displayName)),T.value=n,n.length>0&&!u.value&&(u.value=n[0].name),k.value=!1}return oe(u,()=>{g.value=new Set([""]),m.value=new Set([""])}),se(async()=>{te();try{await P.loadAll()}catch(n){console.error("[TypesView] Failed to load campaign contexts:",n)}}),(n,e)=>(c(),d("div",Fn,[a("section",Hn,[k.value?(c(),d("div",Vn,[...e[0]||(e[0]=[a("p",{class:"text-content-muted"},"Loading types...",-1)])])):(c(),d("div",Gn,[a("div",Wn,[(c(!0),d(C,null,N(T.value,t=>(c(),d("button",{key:t.name,class:U(["type-tab",{active:u.value===t.name,"has-store":w(t.name).hasStore,"has-data":w(t.name).hasData}]),onClick:r=>u.value=t.name,title:Y(t.name)},[D(y(t.displayName)+" ",1),w(t.name).hasData?(c(),d("span",Kn,y(w(t.name).count),1)):de("",!0)],10,qn))),128))]),a("div",Yn,[a("div",jn,[a("h2",$n,y(S.value?.displayName),1),a("span",zn,"packages/types/src/"+y(S.value?.filename),1)]),a("div",Qn,[(c(),d(C,null,N(H,t=>a("button",{key:t.id,class:U(["view-tab",{active:v.value===t.id}]),onClick:r=>v.value=t.id},y(t.label),11,Xn)),64))]),E(a("div",Zn,[a("div",Jn,[a("pre",et,[a("code",null,y(S.value?.content),1)])])],512),[[x,v.value==="code"]]),E(a("div",nt,[a("div",tt,[e[2]||(e[2]=a("h3",{class:"section-title"},"Parsed Structure",-1)),e[3]||(e[3]=a("p",{class:"section-description"},"Extracted type definitions and exports",-1)),a("div",{class:"json-tree-control
30436s"},[a("button",{type:"button",class:"btn-small",onClick:ee},"Expand All"),a("button",{type:"button",class:"btn-small",onClick:ne},"Collapse All"),e[1]||(e[1]=le('<span class="json-legend" data-v-f697c3ab><span class="legend-item mandatory" data-v-f697c3ab><span class="legend-dot" data-v-f697c3ab>â</span> Required</span><span class="legend-item optional" data-v-f697c3ab><span class="legend-dot" data-v-f697c3ab>â</span> Optional</span></span>',1))]),a("div",at,[L(B,{data:O.value,name:S.value?.displayName||"type","expanded-paths":g.value,path:"","mandatory-keys":Z,onToggle:J},null,8,["data","name","expanded-paths"])])])],512),[[x,v.value==="tree"]]),E(a("div",rt,[a("div",it,[e[6]||(e[6]=a("h3",{class:"section-title"},"Store Data",-1)),a("p",ot,[b.value.storeName?(c(),d(C,{key:0},[D(" First 3 of "+y(b.value.total)+" records from the Pinia store ",1)],64)):(c(),d(C,{key:1},[D(" No Pinia store mapped for this type ")],64))]),b.value.storeName?b.value.records.length===0?(c(),d("div",lt,[...e[5]||(e[5]=[a("p",null,"No records currently in the store. Navigate to other views to load data.",-1)])])):(c(),d(C,{key:2},[a("div",{class:"json-tree-controls"},[a("button",{type:"button",class:"btn-small",onClick:$},"Expand All"),a("button",{type:"button",class:"btn-small",onClick:z},"Collapse All")]),a("div",dt,[L(B,{data:b.value.records,name:"records","expanded-paths":m.value,path:"",onToggle:j},null,8,["data","expanded-paths"])])],64)):(c(),d("div",st,[...e[4]||(e[4]=[a("p",null,"This type does not have a corresponding Pinia store, or the store mapping has not been configured.",-1)])]))])],512),[[x,v.value==="store"]])])]))])]))}}),St=me(ct,[["__scopeId","data-v-f697c3ab"]]);export{St as default};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.