PageSourceSearch

https://shopzen.ai/assets/TypesView-DzDZMCbA.js

js shopzen.ai collected 2026-10-05 16:21:47 UTC 1,125,983 bytes, 30,436 lines download raw bytes

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.