evo360-types 1.3.469 → 1.3.474

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,17 +28,27 @@ exports.zCampaignTypeSchema = zod_1.z.enum([
28
28
  "promotional",
29
29
  ]);
30
30
  exports.zCampaignChannelSchema = zod_1.z.enum(["whatsapp", "email"]);
31
+ /** ADDITIVE ONLY — mirrors CampaignVariableSource in types/evo-campaigns/index.ts.
32
+ * This list is SEPARATE from the TS union; dropping a value here rejects channel
33
+ * configs already persisted in Firestore. */
31
34
  exports.zCampaignVariableSourceSchema = zod_1.z.enum([
32
35
  "placeholder",
33
36
  "fixed",
34
37
  "csv_column",
35
38
  "qr_link",
39
+ // E-MAIL ONLY (F3.2): resolves to channels.email.media.url.
40
+ "campaign_media",
36
41
  ]);
42
+ /** ADDITIVE ONLY. The FE parses every recipient through this enum; a value written
43
+ * to Firestore but missing here makes it drop the WHOLE recipient document. */
37
44
  exports.zRecipientDispatchStateSchema = zod_1.z.enum([
38
45
  "pending",
39
46
  "dispatched",
40
47
  "done",
41
48
  "excluded",
49
+ // Declared in RecipientDispatchState but with no writer yet — listed here FIRST so
50
+ // whoever starts writing it doesn't take the FE's recipient list down with them.
51
+ "canceled",
42
52
  ]);
43
53
  exports.zRecipientChannelStatusSchema = zod_1.z.enum([
44
54
  "pending",
@@ -194,10 +204,15 @@ exports.zCampaignSchema = zod_schemas_1.zFireDocSchema
194
204
  new_lead_config: exports.zCampaignNewLeadConfigSchema.optional(),
195
205
  frequency: exports.zCampaignFrequencySchema,
196
206
  schedule: exports.zCampaignScheduleSchema,
207
+ // Backend-owned state maps: declared so the field is documented, deliberately
208
+ // left unvalidated. They gain fields slice by slice and the FE reads them
209
+ // defensively — a structural schema here would drop the whole campaign doc the
210
+ // first time the backend writes a counter the FE's evo-types doesn't know yet.
197
211
  dispatch_state: zod_1.z.record(zod_1.z.unknown()).optional(),
198
212
  prevalidation: zod_1.z.record(zod_1.z.unknown()).optional(),
199
213
  cost_estimate: zod_1.z.record(zod_1.z.unknown()).optional(),
200
214
  counters: zod_1.z.record(zod_1.z.unknown()).optional(),
215
+ last_materialize: zod_1.z.record(zod_1.z.unknown()).optional(),
201
216
  })
202
217
  .passthrough();
203
218
  // ----- Bucket doc -----------------------------------------------------------
@@ -234,6 +249,10 @@ exports.zCampaignRecipientSchema = zod_schemas_1.zFireDocSchema
234
249
  primary_bucket_id: zod_1.z.string(),
235
250
  csv_variables: zod_1.z.record(zod_1.z.string()).optional(),
236
251
  dispatch_state: exports.zRecipientDispatchStateSchema,
252
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
253
+ manual_excluded: zod_1.z.boolean().optional(),
254
+ // Same reasoning as the campaign state maps: unvalidated on purpose, so a new
255
+ // exclusion reason or channel field never costs the FE the whole recipient row.
237
256
  eligibility: zod_1.z.record(zod_1.z.unknown()),
238
257
  channels: zod_1.z.record(zod_1.z.unknown()),
239
258
  interaction_status: exports.zRecipientInteractionStatusSchema,
@@ -31,18 +31,28 @@ export const zCampaignTypeSchema = z.enum([
31
31
 
32
32
  export const zCampaignChannelSchema = z.enum(["whatsapp", "email"]);
33
33
 
34
+ /** ADDITIVE ONLY — mirrors CampaignVariableSource in types/evo-campaigns/index.ts.
35
+ * This list is SEPARATE from the TS union; dropping a value here rejects channel
36
+ * configs already persisted in Firestore. */
34
37
  export const zCampaignVariableSourceSchema = z.enum([
35
38
  "placeholder",
36
39
  "fixed",
37
40
  "csv_column",
38
41
  "qr_link",
42
+ // E-MAIL ONLY (F3.2): resolves to channels.email.media.url.
43
+ "campaign_media",
39
44
  ]);
40
45
 
46
+ /** ADDITIVE ONLY. The FE parses every recipient through this enum; a value written
47
+ * to Firestore but missing here makes it drop the WHOLE recipient document. */
41
48
  export const zRecipientDispatchStateSchema = z.enum([
42
49
  "pending",
43
50
  "dispatched",
44
51
  "done",
45
52
  "excluded",
53
+ // Declared in RecipientDispatchState but with no writer yet — listed here FIRST so
54
+ // whoever starts writing it doesn't take the FE's recipient list down with them.
55
+ "canceled",
46
56
  ]);
47
57
 
48
58
  export const zRecipientChannelStatusSchema = z.enum([
@@ -212,10 +222,15 @@ export const zCampaignSchema = zFireDocSchema
212
222
  new_lead_config: zCampaignNewLeadConfigSchema.optional(),
213
223
  frequency: zCampaignFrequencySchema,
214
224
  schedule: zCampaignScheduleSchema,
225
+ // Backend-owned state maps: declared so the field is documented, deliberately
226
+ // left unvalidated. They gain fields slice by slice and the FE reads them
227
+ // defensively — a structural schema here would drop the whole campaign doc the
228
+ // first time the backend writes a counter the FE's evo-types doesn't know yet.
215
229
  dispatch_state: z.record(z.unknown()).optional(),
216
230
  prevalidation: z.record(z.unknown()).optional(),
217
231
  cost_estimate: z.record(z.unknown()).optional(),
218
232
  counters: z.record(z.unknown()).optional(),
233
+ last_materialize: z.record(z.unknown()).optional(),
219
234
  })
220
235
  .passthrough();
221
236
 
@@ -255,6 +270,10 @@ export const zCampaignRecipientSchema = zFireDocSchema
255
270
  primary_bucket_id: z.string(),
256
271
  csv_variables: z.record(z.string()).optional(),
257
272
  dispatch_state: zRecipientDispatchStateSchema,
273
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
274
+ manual_excluded: z.boolean().optional(),
275
+ // Same reasoning as the campaign state maps: unvalidated on purpose, so a new
276
+ // exclusion reason or channel field never costs the FE the whole recipient row.
258
277
  eligibility: z.record(z.unknown()),
259
278
  channels: z.record(z.unknown()),
260
279
  interaction_status: zRecipientInteractionStatusSchema,
@@ -26,7 +26,7 @@ export type CampaignType = "conversion" | "informative" | "relationship" | "oper
26
26
  /** Delivery channel of a campaign. */
27
27
  export type CampaignChannel = "whatsapp" | "email";
28
28
  /** Where a template variable gets its value from at send time. */
29
- export type CampaignVariableSource = "placeholder" | "fixed" | "csv_column" | "qr_link";
29
+ export type CampaignVariableSource = "placeholder" | "fixed" | "csv_column" | "qr_link" | "campaign_media";
30
30
  export interface ICampaignVariableMapping {
31
31
  /** Template variable name/index this mapping fills. */
32
32
  variable: string;
@@ -76,10 +76,19 @@ export interface ICampaignTicketPolicy {
76
76
  note?: string;
77
77
  }
78
78
  export interface ICampaignMedia {
79
- /** 'image' | 'video' | 'document' — WhatsApp header media type. */
79
+ /** 'image' | 'video' | 'document' — WhatsApp header media type; always 'image' on e-mail. */
80
80
  type: string;
81
- /** Full gs:// URI in WABA_MEDIA_BUCKET under tenants/<tenant>/ (feat-082/084 flow). */
81
+ /**
82
+ * Full gs:// URI of the stored object.
83
+ * WhatsApp: WABA_MEDIA_BUCKET (private) under tenants/<tenant>/ (feat-082/084 flow).
84
+ * E-mail: the PUBLIC bucket (F3.2) — an e-mail client fetches the image
85
+ * unauthenticated, so a private object or a signed URL cannot work.
86
+ */
82
87
  gcs_path?: string;
88
+ /**
89
+ * Public https URL of the object. E-mail REQUIRES it (it is what goes in the
90
+ * <img src>); on WhatsApp it is optional (`gcs_path` wins at send time).
91
+ */
83
92
  url?: string;
84
93
  /** Mime type of the uploaded media (drives the header component). */
85
94
  mime_type?: string | null;
@@ -96,7 +105,11 @@ export interface ICampaignChannelConfig {
96
105
  /** WhatsApp: template category from introspection (MARKETING/UTILITY/AUTHENTICATION). */
97
106
  template_category?: string;
98
107
  variable_mappings: ICampaignVariableMapping[];
99
- /** WhatsApp header media (when the template requires it). */
108
+ /**
109
+ * WhatsApp: the template's header media (when the template requires it).
110
+ * E-mail: the campaign's image, reachable from the HTML through a
111
+ * `campaign_media` variable mapping (F3.2). PUBLICLY READABLE — never a patient asset.
112
+ */
100
113
  media?: ICampaignMedia;
101
114
  ctas: ICampaignCta[];
102
115
  /** Outbound department for the resulting ticket. */
@@ -181,16 +194,61 @@ export interface ICampaignCounters {
181
194
  unique?: number;
182
195
  eligible?: number;
183
196
  excluded_optout?: number;
197
+ /** Excluded by the 15-day fatigue rule. INCLUDES `excluded_frequency_unavailable`
198
+ * (the fail-closed subset) — never sum the two, the second is a breakdown of
199
+ * the first. */
184
200
  excluded_frequency?: number;
201
+ /**
202
+ * Subset of `excluded_frequency` excluded because the lead's marketing history
203
+ * could NOT be read, so the check failed CLOSED — not because the contact was
204
+ * touched recently. Reported apart because the two lead to opposite actions:
205
+ * "everybody was contacted in the last 15 days" (wait) vs "the history query is
206
+ * broken" (fix the index and re-materialize). Rolling them together made the
207
+ * screen state something actively false (F3.1 review A1).
208
+ */
209
+ excluded_frequency_unavailable?: number;
210
+ /** Always 0 today — no code path increments it (see audit note at the bottom). */
185
211
  excluded_invalid?: number;
186
212
  excluded_no_channel?: number;
187
213
  excluded_duplicate?: number;
214
+ /** Recipients removed by hand from the audience (`manual_excluded=true`). Written
215
+ * wholesale by the materializer and incremented/decremented by the exclude/restore
216
+ * endpoints. */
217
+ excluded_manual?: number;
188
218
  per_channel?: {
189
219
  whatsapp?: ICampaignChannelCounters;
190
220
  email?: ICampaignChannelCounters;
191
221
  };
192
222
  converted?: number;
193
223
  }
224
+ /** Caveat on a materialization that SUCCEEDED but whose result is not what it looks like. */
225
+ export type CampaignMaterializeWarning =
226
+ /** The campaign has no bucket at all — the audience is empty by construction,
227
+ * not because the filters matched nobody. */
228
+ "no_buckets"
229
+ /** The 15-day fatigue check could not read the lead history and failed closed:
230
+ * the recipients were excluded as a precaution, not for being contacted recently. */
231
+ | "frequency_check_unavailable";
232
+ /**
233
+ * Progress/outcome stamp of the last audience materialization, written by the
234
+ * `campaign-materialize` handler (deep merge, so `error`/`warning` are written
235
+ * explicitly as null when absent — a stale caveat must not survive the next run).
236
+ * Drives the FE progress indicator and gates the interactive re-publish
237
+ * (`shouldSuppressMaterialize`).
238
+ */
239
+ export interface ICampaignLastMaterialize {
240
+ status: "running" | "done" | "error";
241
+ /** Required — every one of the four write-sites sets it, and the suppression guard
242
+ * reads it. Firestore returns a Timestamp here: `.toDate()` before comparing. */
243
+ at: Date;
244
+ /** Written EXPLICITLY as null on a run without one — a caveat from the previous run
245
+ * must not survive the next (the stamp is a deep merge). */
246
+ error?: string | null;
247
+ warning?: CampaignMaterializeWarning | null;
248
+ /** Result snapshot (status='done' only) — mirrors counters.unique / counters.eligible. */
249
+ unique?: number;
250
+ eligible?: number;
251
+ }
194
252
  export interface ICampaign extends IFireDoc {
195
253
  name: string;
196
254
  description?: string | null;
@@ -215,6 +273,9 @@ export interface ICampaign extends IFireDoc {
215
273
  prevalidation?: ICampaignPrevalidation;
216
274
  cost_estimate?: ICampaignCostEstimate;
217
275
  counters?: ICampaignCounters;
276
+ /** Cleared (field delete) when the last bucket is removed — an audience of zero
277
+ * must not keep advertising the numbers of the previous run. */
278
+ last_materialize?: ICampaignLastMaterialize;
218
279
  created_by?: FirestoreDocumentReference;
219
280
  updated_by?: FirestoreDocumentReference;
220
281
  scheduled_by?: FirestoreDocumentReference;
@@ -245,9 +306,13 @@ export interface ICampaignBucketCsvFile {
245
306
  }>;
246
307
  }
247
308
  export interface ICampaignBucketCounts {
309
+ /** Rows enumerated by this bucket (CSV: row_count; Algolia: preview/enumeration count). */
248
310
  raw?: number;
311
+ /** CSV only, at upload: parseable contacts the bucket contributes. */
249
312
  unique_contribution?: number;
313
+ /** Set per bucket by the materializer (dot-path update, never set/merge). */
250
314
  eligible?: number;
315
+ /** CSV only, at upload: rows rejected by the parser. */
251
316
  invalid?: number;
252
317
  opt_out?: number;
253
318
  frequency_excluded?: number;
@@ -263,8 +328,15 @@ export interface ICampaignBucket extends IFireDoc {
263
328
  }
264
329
  /** Flat field driving the batch queue (indexed). */
265
330
  export type RecipientDispatchState = "pending" | "dispatched" | "done" | "excluded"
266
- /** Campaign canceled with this recipient still pending (feat-081 F3 — formalizes
267
- * what F2 stored as 'excluded'; readers must accept both on old docs). */
331
+ /**
332
+ * RESERVED — no writer today. `cancelCampaign` still parks pending recipients as
333
+ * `excluded` and carries the cancellation in `interaction_status='cancelado'`, so
334
+ * this value never reaches Firestore. Kept because the FSM wants it, but whoever
335
+ * starts writing it must check every `dispatch_state` reader first: the queue query
336
+ * filters on `=='pending'` (unaffected), but the FE parses recipients through
337
+ * `zRecipientDispatchStateSchema` and a value missing from that enum makes it drop
338
+ * the WHOLE document, not just the field.
339
+ */
268
340
  | "canceled";
269
341
  /** Per-channel delivery status of a recipient (monotonic). */
270
342
  export type RecipientChannelStatus = "pending" | "scheduled" | "sent" | "delivered" | "read" | "failed" | "clicked" | "replied";
@@ -276,6 +348,13 @@ export interface ICampaignRecipientContact {
276
348
  email?: string;
277
349
  external_id?: string;
278
350
  }
351
+ /**
352
+ * Per-channel delivery state. The six milestones (sent/delivered/read/clicked/
353
+ * replied/failed) are FACTS, each with its own timestamp — and the timestamp, not the
354
+ * label, is the idempotency key of the tracking consumers. `status` is a monotonic
355
+ * LABEL derived from them and is shared mutable state, so it cannot serve as a stamp:
356
+ * a late event that moves the label would silently un-stamp the milestone under it.
357
+ */
279
358
  export interface ICampaignRecipientChannelState {
280
359
  status: RecipientChannelStatus;
281
360
  task_id?: string;
@@ -284,16 +363,59 @@ export interface ICampaignRecipientChannelState {
284
363
  delivered_at?: Date | null;
285
364
  read_at?: Date | null;
286
365
  clicked_at?: Date | null;
366
+ /**
367
+ * CTA that produced `clicked_at` (F3.2). The click consumer already carried the
368
+ * `cta_id` to BigQuery but dropped it from the doc, so a conversion could only be
369
+ * written with `cta_id: null` and no report could answer WHICH call to action
370
+ * converted. Stamped in the same transaction as `clicked_at`, sharing that
371
+ * milestone's idempotency: the FIRST click wins, later ones never overwrite it.
372
+ */
373
+ clicked_cta_id?: string | null;
287
374
  replied_at?: Date | null;
375
+ /** Milestone stamp of a provider failure. Completes the six — `failed` was the one
376
+ * milestone without a timestamp, so its consumer had to key idempotency off
377
+ * `status === 'failed'` instead (F3.1). */
378
+ failed_at?: Date | null;
379
+ /** Provider error text that accompanies `failed_at`. */
288
380
  error?: string | null;
289
381
  }
382
+ /**
383
+ * Why a recipient was kept out. Closed union: every value here HAS a writer in
384
+ * `packages/model/src/evo-campaigns/audience/materializer.ts`, and the FE carries a
385
+ * label for each. Adding a value means adding its label in the same slice —
386
+ * `reasonLabel()` falls back to printing the raw code, which is how
387
+ * `frequency_check_unavailable` would have reached the operator as gibberish.
388
+ */
389
+ export type CampaignExclusionReason =
390
+ /** Contact opted out of the marketing level (feat-067). */
391
+ "optout"
392
+ /** Touched by another marketing campaign inside `frequency.min_days`. */
393
+ | "frequency"
394
+ /** The fatigue check could not read the lead history and failed CLOSED — the
395
+ * contact was excluded as a precaution, NOT for being contacted recently. */
396
+ | "frequency_check_unavailable"
397
+ /** No identifier for this channel (no mobile / no e-mail). */
398
+ | "no_channel"
399
+ /**
400
+ * There IS an identifier for this channel, but it cannot be delivered to — a phone
401
+ * that is not a valid E.164 number, an address that is not an e-mail (F3.2). Before
402
+ * this existed, eligibility was decided by `!!mobile` / `!!email`, so a malformed
403
+ * value counted as ELIGIBLE, burned a send and failed at the provider. Distinct from
404
+ * `no_channel` on purpose: "fix this contact's data" and "this contact has no such
405
+ * channel" are different instructions for whoever reads the audience.
406
+ */
407
+ | "invalid"
408
+ /** Removed by hand from the audience — always the value under the `manual` key. */
409
+ | "manual_removed";
290
410
  export interface ICampaignRecipientEligibility {
291
411
  per_channel: {
292
412
  whatsapp?: "eligible" | "excluded";
293
413
  email?: "eligible" | "excluded";
294
414
  };
295
- /** channel -> exclusion reason. */
296
- exclusion_reasons?: Record<string, string>;
415
+ /** Keys: `whatsapp` / `email` (per-channel) and `manual` (always `manual_removed`).
416
+ * A recipient can carry several at once — `manual` stacks on top of a real
417
+ * per-channel reason, which is what makes a restore keep it ineligible. */
418
+ exclusion_reasons?: Record<string, CampaignExclusionReason>;
297
419
  }
298
420
  export interface ICampaignRecipient extends IFireDoc {
299
421
  lead_id?: string;
@@ -305,6 +427,13 @@ export interface ICampaignRecipient extends IFireDoc {
305
427
  csv_variables?: Record<string, string>;
306
428
  /** FLAT field for the batch queue (indexed). */
307
429
  dispatch_state: RecipientDispatchState;
430
+ /**
431
+ * Removed by hand from the audience (F2.6/F2.7). This is the DETERMINISTIC marker:
432
+ * `eligibility.per_channel` is deliberately kept at its real value so a restore is
433
+ * symmetric, so per_channel alone cannot tell a manual removal from an organic
434
+ * exclusion. Survives re-materialization; deleted (not set false) on restore.
435
+ */
436
+ manual_excluded?: boolean;
308
437
  eligibility: ICampaignRecipientEligibility;
309
438
  channels: {
310
439
  whatsapp?: ICampaignRecipientChannelState;
@@ -74,7 +74,8 @@ export type CampaignVariableSource =
74
74
  | "placeholder" // resolved by the placeholder engine (feat-055) via `token`
75
75
  | "fixed" // literal `value`
76
76
  | "csv_column" // pulled from the recipient's csv_variables by `column`
77
- | "qr_link"; // a quick-reply /qr link for `cta_id`
77
+ | "qr_link" // a quick-reply /qr link for `cta_id`
78
+ | "campaign_media"; // E-MAIL ONLY: the public URL of `channels.email.media` (F3.2)
78
79
 
79
80
  export interface ICampaignVariableMapping {
80
81
  /** Template variable name/index this mapping fills. */
@@ -88,6 +89,8 @@ export interface ICampaignVariableMapping {
88
89
  column?: string;
89
90
  /** source='qr_link': the CTA whose /qr link is injected. */
90
91
  cta_id?: string;
92
+ // source='campaign_media' carries no extra field: it resolves to
93
+ // `channels.email.media.url` of the campaign it belongs to.
91
94
  }
92
95
 
93
96
  export interface ICampaignCta {
@@ -132,10 +135,19 @@ export interface ICampaignTicketPolicy {
132
135
  }
133
136
 
134
137
  export interface ICampaignMedia {
135
- /** 'image' | 'video' | 'document' — WhatsApp header media type. */
138
+ /** 'image' | 'video' | 'document' — WhatsApp header media type; always 'image' on e-mail. */
136
139
  type: string;
137
- /** Full gs:// URI in WABA_MEDIA_BUCKET under tenants/<tenant>/ (feat-082/084 flow). */
140
+ /**
141
+ * Full gs:// URI of the stored object.
142
+ * WhatsApp: WABA_MEDIA_BUCKET (private) under tenants/<tenant>/ (feat-082/084 flow).
143
+ * E-mail: the PUBLIC bucket (F3.2) — an e-mail client fetches the image
144
+ * unauthenticated, so a private object or a signed URL cannot work.
145
+ */
138
146
  gcs_path?: string;
147
+ /**
148
+ * Public https URL of the object. E-mail REQUIRES it (it is what goes in the
149
+ * <img src>); on WhatsApp it is optional (`gcs_path` wins at send time).
150
+ */
139
151
  url?: string;
140
152
  /** Mime type of the uploaded media (drives the header component). */
141
153
  mime_type?: string | null;
@@ -155,7 +167,11 @@ export interface ICampaignChannelConfig {
155
167
  /** WhatsApp: template category from introspection (MARKETING/UTILITY/AUTHENTICATION). */
156
168
  template_category?: string;
157
169
  variable_mappings: ICampaignVariableMapping[];
158
- /** WhatsApp header media (when the template requires it). */
170
+ /**
171
+ * WhatsApp: the template's header media (when the template requires it).
172
+ * E-mail: the campaign's image, reachable from the HTML through a
173
+ * `campaign_media` variable mapping (F3.2). PUBLICLY READABLE — never a patient asset.
174
+ */
159
175
  media?: ICampaignMedia;
160
176
  ctas: ICampaignCta[];
161
177
  /** Outbound department for the resulting ticket. */
@@ -255,10 +271,27 @@ export interface ICampaignCounters {
255
271
  unique?: number;
256
272
  eligible?: number;
257
273
  excluded_optout?: number;
274
+ /** Excluded by the 15-day fatigue rule. INCLUDES `excluded_frequency_unavailable`
275
+ * (the fail-closed subset) — never sum the two, the second is a breakdown of
276
+ * the first. */
258
277
  excluded_frequency?: number;
278
+ /**
279
+ * Subset of `excluded_frequency` excluded because the lead's marketing history
280
+ * could NOT be read, so the check failed CLOSED — not because the contact was
281
+ * touched recently. Reported apart because the two lead to opposite actions:
282
+ * "everybody was contacted in the last 15 days" (wait) vs "the history query is
283
+ * broken" (fix the index and re-materialize). Rolling them together made the
284
+ * screen state something actively false (F3.1 review A1).
285
+ */
286
+ excluded_frequency_unavailable?: number;
287
+ /** Always 0 today — no code path increments it (see audit note at the bottom). */
259
288
  excluded_invalid?: number;
260
289
  excluded_no_channel?: number;
261
290
  excluded_duplicate?: number;
291
+ /** Recipients removed by hand from the audience (`manual_excluded=true`). Written
292
+ * wholesale by the materializer and incremented/decremented by the exclude/restore
293
+ * endpoints. */
294
+ excluded_manual?: number;
262
295
  per_channel?: {
263
296
  whatsapp?: ICampaignChannelCounters;
264
297
  email?: ICampaignChannelCounters;
@@ -266,6 +299,38 @@ export interface ICampaignCounters {
266
299
  converted?: number;
267
300
  }
268
301
 
302
+ // ----- Last materialization stamp -------------------------------------------
303
+
304
+ /** Caveat on a materialization that SUCCEEDED but whose result is not what it looks like. */
305
+ export type CampaignMaterializeWarning =
306
+ /** The campaign has no bucket at all — the audience is empty by construction,
307
+ * not because the filters matched nobody. */
308
+ | "no_buckets"
309
+ /** The 15-day fatigue check could not read the lead history and failed closed:
310
+ * the recipients were excluded as a precaution, not for being contacted recently. */
311
+ | "frequency_check_unavailable";
312
+
313
+ /**
314
+ * Progress/outcome stamp of the last audience materialization, written by the
315
+ * `campaign-materialize` handler (deep merge, so `error`/`warning` are written
316
+ * explicitly as null when absent — a stale caveat must not survive the next run).
317
+ * Drives the FE progress indicator and gates the interactive re-publish
318
+ * (`shouldSuppressMaterialize`).
319
+ */
320
+ export interface ICampaignLastMaterialize {
321
+ status: "running" | "done" | "error";
322
+ /** Required — every one of the four write-sites sets it, and the suppression guard
323
+ * reads it. Firestore returns a Timestamp here: `.toDate()` before comparing. */
324
+ at: Date;
325
+ /** Written EXPLICITLY as null on a run without one — a caveat from the previous run
326
+ * must not survive the next (the stamp is a deep merge). */
327
+ error?: string | null;
328
+ warning?: CampaignMaterializeWarning | null;
329
+ /** Result snapshot (status='done' only) — mirrors counters.unique / counters.eligible. */
330
+ unique?: number;
331
+ eligible?: number;
332
+ }
333
+
269
334
  // ----- Campaign aggregate doc -----------------------------------------------
270
335
 
271
336
  export interface ICampaign extends IFireDoc {
@@ -297,6 +362,9 @@ export interface ICampaign extends IFireDoc {
297
362
  prevalidation?: ICampaignPrevalidation;
298
363
  cost_estimate?: ICampaignCostEstimate;
299
364
  counters?: ICampaignCounters;
365
+ /** Cleared (field delete) when the last bucket is removed — an audience of zero
366
+ * must not keep advertising the numbers of the previous run. */
367
+ last_materialize?: ICampaignLastMaterialize;
300
368
 
301
369
  // Audit (who/when per FSM transition)
302
370
  created_by?: FirestoreDocumentReference;
@@ -334,10 +402,18 @@ export interface ICampaignBucketCsvFile {
334
402
  }
335
403
 
336
404
  export interface ICampaignBucketCounts {
405
+ /** Rows enumerated by this bucket (CSV: row_count; Algolia: preview/enumeration count). */
337
406
  raw?: number;
407
+ /** CSV only, at upload: parseable contacts the bucket contributes. */
338
408
  unique_contribution?: number;
409
+ /** Set per bucket by the materializer (dot-path update, never set/merge). */
339
410
  eligible?: number;
411
+ /** CSV only, at upload: rows rejected by the parser. */
340
412
  invalid?: number;
413
+ // The three below have NO writer anywhere today — a per-bucket exclusion breakdown
414
+ // was never implemented (the breakdown lives on the campaign counters instead).
415
+ // Left declared, optional and empty rather than removed: they are the shape a future
416
+ // per-bucket breakdown should take, and nothing reads them meanwhile.
341
417
  opt_out?: number;
342
418
  frequency_excluded?: number;
343
419
  no_channel?: number;
@@ -360,8 +436,15 @@ export type RecipientDispatchState =
360
436
  | "dispatched"
361
437
  | "done"
362
438
  | "excluded"
363
- /** Campaign canceled with this recipient still pending (feat-081 F3 — formalizes
364
- * what F2 stored as 'excluded'; readers must accept both on old docs). */
439
+ /**
440
+ * RESERVED — no writer today. `cancelCampaign` still parks pending recipients as
441
+ * `excluded` and carries the cancellation in `interaction_status='cancelado'`, so
442
+ * this value never reaches Firestore. Kept because the FSM wants it, but whoever
443
+ * starts writing it must check every `dispatch_state` reader first: the queue query
444
+ * filters on `=='pending'` (unaffected), but the FE parses recipients through
445
+ * `zRecipientDispatchStateSchema` and a value missing from that enum makes it drop
446
+ * the WHOLE document, not just the field.
447
+ */
365
448
  | "canceled";
366
449
 
367
450
  /** Per-channel delivery status of a recipient (monotonic). */
@@ -397,6 +480,13 @@ export interface ICampaignRecipientContact {
397
480
  external_id?: string;
398
481
  }
399
482
 
483
+ /**
484
+ * Per-channel delivery state. The six milestones (sent/delivered/read/clicked/
485
+ * replied/failed) are FACTS, each with its own timestamp — and the timestamp, not the
486
+ * label, is the idempotency key of the tracking consumers. `status` is a monotonic
487
+ * LABEL derived from them and is shared mutable state, so it cannot serve as a stamp:
488
+ * a late event that moves the label would silently un-stamp the milestone under it.
489
+ */
400
490
  export interface ICampaignRecipientChannelState {
401
491
  status: RecipientChannelStatus;
402
492
  task_id?: string;
@@ -405,17 +495,61 @@ export interface ICampaignRecipientChannelState {
405
495
  delivered_at?: Date | null;
406
496
  read_at?: Date | null;
407
497
  clicked_at?: Date | null;
498
+ /**
499
+ * CTA that produced `clicked_at` (F3.2). The click consumer already carried the
500
+ * `cta_id` to BigQuery but dropped it from the doc, so a conversion could only be
501
+ * written with `cta_id: null` and no report could answer WHICH call to action
502
+ * converted. Stamped in the same transaction as `clicked_at`, sharing that
503
+ * milestone's idempotency: the FIRST click wins, later ones never overwrite it.
504
+ */
505
+ clicked_cta_id?: string | null;
408
506
  replied_at?: Date | null;
507
+ /** Milestone stamp of a provider failure. Completes the six — `failed` was the one
508
+ * milestone without a timestamp, so its consumer had to key idempotency off
509
+ * `status === 'failed'` instead (F3.1). */
510
+ failed_at?: Date | null;
511
+ /** Provider error text that accompanies `failed_at`. */
409
512
  error?: string | null;
410
513
  }
411
514
 
515
+ /**
516
+ * Why a recipient was kept out. Closed union: every value here HAS a writer in
517
+ * `packages/model/src/evo-campaigns/audience/materializer.ts`, and the FE carries a
518
+ * label for each. Adding a value means adding its label in the same slice —
519
+ * `reasonLabel()` falls back to printing the raw code, which is how
520
+ * `frequency_check_unavailable` would have reached the operator as gibberish.
521
+ */
522
+ export type CampaignExclusionReason =
523
+ /** Contact opted out of the marketing level (feat-067). */
524
+ | "optout"
525
+ /** Touched by another marketing campaign inside `frequency.min_days`. */
526
+ | "frequency"
527
+ /** The fatigue check could not read the lead history and failed CLOSED — the
528
+ * contact was excluded as a precaution, NOT for being contacted recently. */
529
+ | "frequency_check_unavailable"
530
+ /** No identifier for this channel (no mobile / no e-mail). */
531
+ | "no_channel"
532
+ /**
533
+ * There IS an identifier for this channel, but it cannot be delivered to — a phone
534
+ * that is not a valid E.164 number, an address that is not an e-mail (F3.2). Before
535
+ * this existed, eligibility was decided by `!!mobile` / `!!email`, so a malformed
536
+ * value counted as ELIGIBLE, burned a send and failed at the provider. Distinct from
537
+ * `no_channel` on purpose: "fix this contact's data" and "this contact has no such
538
+ * channel" are different instructions for whoever reads the audience.
539
+ */
540
+ | "invalid"
541
+ /** Removed by hand from the audience — always the value under the `manual` key. */
542
+ | "manual_removed";
543
+
412
544
  export interface ICampaignRecipientEligibility {
413
545
  per_channel: {
414
546
  whatsapp?: "eligible" | "excluded";
415
547
  email?: "eligible" | "excluded";
416
548
  };
417
- /** channel -> exclusion reason. */
418
- exclusion_reasons?: Record<string, string>;
549
+ /** Keys: `whatsapp` / `email` (per-channel) and `manual` (always `manual_removed`).
550
+ * A recipient can carry several at once — `manual` stacks on top of a real
551
+ * per-channel reason, which is what makes a restore keep it ineligible. */
552
+ exclusion_reasons?: Record<string, CampaignExclusionReason>;
419
553
  }
420
554
 
421
555
  export interface ICampaignRecipient extends IFireDoc {
@@ -428,6 +562,13 @@ export interface ICampaignRecipient extends IFireDoc {
428
562
  csv_variables?: Record<string, string>;
429
563
  /** FLAT field for the batch queue (indexed). */
430
564
  dispatch_state: RecipientDispatchState;
565
+ /**
566
+ * Removed by hand from the audience (F2.6/F2.7). This is the DETERMINISTIC marker:
567
+ * `eligibility.per_channel` is deliberately kept at its real value so a restore is
568
+ * symmetric, so per_channel alone cannot tell a manual removal from an organic
569
+ * exclusion. Survives re-materialization; deleted (not set false) on restore.
570
+ */
571
+ manual_excluded?: boolean;
431
572
  eligibility: ICampaignRecipientEligibility;
432
573
  channels: {
433
574
  whatsapp?: ICampaignRecipientChannelState;
@@ -1,5 +1,11 @@
1
1
  import type { IFireGlobalDoc } from '../../shared';
2
- export type NexFinopsContractStatus = 'draft' | 'active' | 'inactive' | 'ended' | 'canceled';
2
+ /**
3
+ * `cancellation_requested`: o cliente pediu o cancelamento e o contrato está em
4
+ * processo de encerramento — mas **continua em vigência** e continua faturando
5
+ * (ver `NEX_FINOPS_BILLABLE_CONTRACT_STATUSES`). Só sai desse estado ao encerrar
6
+ * (`ended`), ao ser anulado (`canceled`) ou se o cliente voltar atrás (`active`).
7
+ */
8
+ export type NexFinopsContractStatus = 'draft' | 'active' | 'cancellation_requested' | 'inactive' | 'ended' | 'canceled';
3
9
  export type NexFinopsContractRecurrence = 'one_time' | 'monthly';
4
10
  export type NexFinopsPaymentMethod = 'pix' | 'boleto' | 'payment_link';
5
11
  export interface INexFinopsContractServiceItem {
@@ -25,3 +31,13 @@ export interface INexFinopsContract extends IFireGlobalDoc {
25
31
  notes?: string | null;
26
32
  }
27
33
  export declare const NEX_FINOPS_CONTRACT_STATUS_TRANSITIONS: Record<NexFinopsContractStatus, NexFinopsContractStatus[]>;
34
+ /**
35
+ * Status em que um contrato ainda gera cobrança.
36
+ *
37
+ * `cancellation_requested` entra aqui de propósito: o contrato pediu cancelamento
38
+ * mas segue em vigência até encerrar, e deve continuar sendo cobrado no período.
39
+ *
40
+ * Tipado como `readonly string[]` (e não `readonly NexFinopsContractStatus[]`) para
41
+ * que `.includes(x)` aceite qualquer string sem cast no ponto de uso.
42
+ */
43
+ export declare const NEX_FINOPS_BILLABLE_CONTRACT_STATUSES: readonly string[];
@@ -1,11 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NEX_FINOPS_CONTRACT_STATUS_TRANSITIONS = void 0;
3
+ exports.NEX_FINOPS_BILLABLE_CONTRACT_STATUSES = exports.NEX_FINOPS_CONTRACT_STATUS_TRANSITIONS = void 0;
4
4
  // ── Status transitions ──
5
5
  exports.NEX_FINOPS_CONTRACT_STATUS_TRANSITIONS = {
6
6
  draft: ['active', 'canceled'],
7
- active: ['inactive', 'ended', 'canceled'],
7
+ active: ['cancellation_requested', 'inactive', 'ended', 'canceled'],
8
+ // Volta para 'active' cobre o cliente que desiste do cancelamento.
9
+ cancellation_requested: ['ended', 'canceled', 'active'],
8
10
  inactive: ['active'],
9
11
  ended: [],
10
12
  canceled: [],
11
13
  };
14
+ /**
15
+ * Status em que um contrato ainda gera cobrança.
16
+ *
17
+ * `cancellation_requested` entra aqui de propósito: o contrato pediu cancelamento
18
+ * mas segue em vigência até encerrar, e deve continuar sendo cobrado no período.
19
+ *
20
+ * Tipado como `readonly string[]` (e não `readonly NexFinopsContractStatus[]`) para
21
+ * que `.includes(x)` aceite qualquer string sem cast no ponto de uso.
22
+ */
23
+ exports.NEX_FINOPS_BILLABLE_CONTRACT_STATUSES = ['active', 'cancellation_requested'];