evo360-types 1.3.469 → 1.3.472

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.
@@ -4,7 +4,9 @@ export type ICampaignStatus = z.infer<typeof zCampaignStatusSchema>;
4
4
  export declare const zCampaignTypeSchema: z.ZodEnum<["conversion", "informative", "relationship", "operational", "promotional"]>;
5
5
  export declare const zCampaignChannelSchema: z.ZodEnum<["whatsapp", "email"]>;
6
6
  export declare const zCampaignVariableSourceSchema: z.ZodEnum<["placeholder", "fixed", "csv_column", "qr_link"]>;
7
- export declare const zRecipientDispatchStateSchema: z.ZodEnum<["pending", "dispatched", "done", "excluded"]>;
7
+ /** ADDITIVE ONLY. The FE parses every recipient through this enum; a value written
8
+ * to Firestore but missing here makes it drop the WHOLE recipient document. */
9
+ export declare const zRecipientDispatchStateSchema: z.ZodEnum<["pending", "dispatched", "done", "excluded", "canceled"]>;
8
10
  export declare const zRecipientChannelStatusSchema: z.ZodEnum<["pending", "scheduled", "sent", "delivered", "read", "failed", "clicked", "replied"]>;
9
11
  export declare const zRecipientInteractionStatusSchema: z.ZodEnum<["pendente", "inelegivel", "agendado", "enviado", "entregue", "lido", "clicou", "respondeu", "convertido", "falhou", "optout", "cancelado"]>;
10
12
  export declare const zCampaignCodeNameSchema: z.ZodObject<{
@@ -3738,6 +3740,7 @@ export declare const zCampaignSchema: z.ZodObject<{
3738
3740
  prevalidation: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
3739
3741
  cost_estimate: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
3740
3742
  counters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
3743
+ last_materialize: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
3741
3744
  }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
3742
3745
  id: z.ZodString;
3743
3746
  ref: z.ZodAny;
@@ -6726,6 +6729,7 @@ export declare const zCampaignSchema: z.ZodObject<{
6726
6729
  prevalidation: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
6727
6730
  cost_estimate: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
6728
6731
  counters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
6732
+ last_materialize: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
6729
6733
  }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
6730
6734
  id: z.ZodString;
6731
6735
  ref: z.ZodAny;
@@ -9714,6 +9718,7 @@ export declare const zCampaignSchema: z.ZodObject<{
9714
9718
  prevalidation: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
9715
9719
  cost_estimate: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
9716
9720
  counters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
9721
+ last_materialize: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
9717
9722
  }, z.ZodTypeAny, "passthrough">>;
9718
9723
  export declare const zCampaignBucketSourceTypeSchema: z.ZodEnum<["leads", "patients", "appointments", "csv"]>;
9719
9724
  export declare const zCampaignBucketSchema: z.ZodObject<{
@@ -9790,7 +9795,9 @@ export declare const zCampaignRecipientSchema: z.ZodObject<{
9790
9795
  bucket_ids: z.ZodArray<z.ZodString, "many">;
9791
9796
  primary_bucket_id: z.ZodString;
9792
9797
  csv_variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9793
- dispatch_state: z.ZodEnum<["pending", "dispatched", "done", "excluded"]>;
9798
+ dispatch_state: z.ZodEnum<["pending", "dispatched", "done", "excluded", "canceled"]>;
9799
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
9800
+ manual_excluded: z.ZodOptional<z.ZodBoolean>;
9794
9801
  eligibility: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9795
9802
  channels: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9796
9803
  interaction_status: z.ZodEnum<["pendente", "inelegivel", "agendado", "enviado", "entregue", "lido", "clicou", "respondeu", "convertido", "falhou", "optout", "cancelado"]>;
@@ -9827,7 +9834,9 @@ export declare const zCampaignRecipientSchema: z.ZodObject<{
9827
9834
  bucket_ids: z.ZodArray<z.ZodString, "many">;
9828
9835
  primary_bucket_id: z.ZodString;
9829
9836
  csv_variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9830
- dispatch_state: z.ZodEnum<["pending", "dispatched", "done", "excluded"]>;
9837
+ dispatch_state: z.ZodEnum<["pending", "dispatched", "done", "excluded", "canceled"]>;
9838
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
9839
+ manual_excluded: z.ZodOptional<z.ZodBoolean>;
9831
9840
  eligibility: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9832
9841
  channels: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9833
9842
  interaction_status: z.ZodEnum<["pendente", "inelegivel", "agendado", "enviado", "entregue", "lido", "clicou", "respondeu", "convertido", "falhou", "optout", "cancelado"]>;
@@ -9864,7 +9873,9 @@ export declare const zCampaignRecipientSchema: z.ZodObject<{
9864
9873
  bucket_ids: z.ZodArray<z.ZodString, "many">;
9865
9874
  primary_bucket_id: z.ZodString;
9866
9875
  csv_variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9867
- dispatch_state: z.ZodEnum<["pending", "dispatched", "done", "excluded"]>;
9876
+ dispatch_state: z.ZodEnum<["pending", "dispatched", "done", "excluded", "canceled"]>;
9877
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
9878
+ manual_excluded: z.ZodOptional<z.ZodBoolean>;
9868
9879
  eligibility: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9869
9880
  channels: z.ZodRecord<z.ZodString, z.ZodUnknown>;
9870
9881
  interaction_status: z.ZodEnum<["pendente", "inelegivel", "agendado", "enviado", "entregue", "lido", "clicou", "respondeu", "convertido", "falhou", "optout", "cancelado"]>;
@@ -34,11 +34,16 @@ exports.zCampaignVariableSourceSchema = zod_1.z.enum([
34
34
  "csv_column",
35
35
  "qr_link",
36
36
  ]);
37
+ /** ADDITIVE ONLY. The FE parses every recipient through this enum; a value written
38
+ * to Firestore but missing here makes it drop the WHOLE recipient document. */
37
39
  exports.zRecipientDispatchStateSchema = zod_1.z.enum([
38
40
  "pending",
39
41
  "dispatched",
40
42
  "done",
41
43
  "excluded",
44
+ // Declared in RecipientDispatchState but with no writer yet — listed here FIRST so
45
+ // whoever starts writing it doesn't take the FE's recipient list down with them.
46
+ "canceled",
42
47
  ]);
43
48
  exports.zRecipientChannelStatusSchema = zod_1.z.enum([
44
49
  "pending",
@@ -194,10 +199,15 @@ exports.zCampaignSchema = zod_schemas_1.zFireDocSchema
194
199
  new_lead_config: exports.zCampaignNewLeadConfigSchema.optional(),
195
200
  frequency: exports.zCampaignFrequencySchema,
196
201
  schedule: exports.zCampaignScheduleSchema,
202
+ // Backend-owned state maps: declared so the field is documented, deliberately
203
+ // left unvalidated. They gain fields slice by slice and the FE reads them
204
+ // defensively — a structural schema here would drop the whole campaign doc the
205
+ // first time the backend writes a counter the FE's evo-types doesn't know yet.
197
206
  dispatch_state: zod_1.z.record(zod_1.z.unknown()).optional(),
198
207
  prevalidation: zod_1.z.record(zod_1.z.unknown()).optional(),
199
208
  cost_estimate: zod_1.z.record(zod_1.z.unknown()).optional(),
200
209
  counters: zod_1.z.record(zod_1.z.unknown()).optional(),
210
+ last_materialize: zod_1.z.record(zod_1.z.unknown()).optional(),
201
211
  })
202
212
  .passthrough();
203
213
  // ----- Bucket doc -----------------------------------------------------------
@@ -234,6 +244,10 @@ exports.zCampaignRecipientSchema = zod_schemas_1.zFireDocSchema
234
244
  primary_bucket_id: zod_1.z.string(),
235
245
  csv_variables: zod_1.z.record(zod_1.z.string()).optional(),
236
246
  dispatch_state: exports.zRecipientDispatchStateSchema,
247
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
248
+ manual_excluded: zod_1.z.boolean().optional(),
249
+ // Same reasoning as the campaign state maps: unvalidated on purpose, so a new
250
+ // exclusion reason or channel field never costs the FE the whole recipient row.
237
251
  eligibility: zod_1.z.record(zod_1.z.unknown()),
238
252
  channels: zod_1.z.record(zod_1.z.unknown()),
239
253
  interaction_status: exports.zRecipientInteractionStatusSchema,
@@ -38,11 +38,16 @@ export const zCampaignVariableSourceSchema = z.enum([
38
38
  "qr_link",
39
39
  ]);
40
40
 
41
+ /** ADDITIVE ONLY. The FE parses every recipient through this enum; a value written
42
+ * to Firestore but missing here makes it drop the WHOLE recipient document. */
41
43
  export const zRecipientDispatchStateSchema = z.enum([
42
44
  "pending",
43
45
  "dispatched",
44
46
  "done",
45
47
  "excluded",
48
+ // Declared in RecipientDispatchState but with no writer yet — listed here FIRST so
49
+ // whoever starts writing it doesn't take the FE's recipient list down with them.
50
+ "canceled",
46
51
  ]);
47
52
 
48
53
  export const zRecipientChannelStatusSchema = z.enum([
@@ -212,10 +217,15 @@ export const zCampaignSchema = zFireDocSchema
212
217
  new_lead_config: zCampaignNewLeadConfigSchema.optional(),
213
218
  frequency: zCampaignFrequencySchema,
214
219
  schedule: zCampaignScheduleSchema,
220
+ // Backend-owned state maps: declared so the field is documented, deliberately
221
+ // left unvalidated. They gain fields slice by slice and the FE reads them
222
+ // defensively — a structural schema here would drop the whole campaign doc the
223
+ // first time the backend writes a counter the FE's evo-types doesn't know yet.
215
224
  dispatch_state: z.record(z.unknown()).optional(),
216
225
  prevalidation: z.record(z.unknown()).optional(),
217
226
  cost_estimate: z.record(z.unknown()).optional(),
218
227
  counters: z.record(z.unknown()).optional(),
228
+ last_materialize: z.record(z.unknown()).optional(),
219
229
  })
220
230
  .passthrough();
221
231
 
@@ -255,6 +265,10 @@ export const zCampaignRecipientSchema = zFireDocSchema
255
265
  primary_bucket_id: z.string(),
256
266
  csv_variables: z.record(z.string()).optional(),
257
267
  dispatch_state: zRecipientDispatchStateSchema,
268
+ /** Absent (field-deleted), not false, once a manual exclusion is restored. */
269
+ manual_excluded: z.boolean().optional(),
270
+ // Same reasoning as the campaign state maps: unvalidated on purpose, so a new
271
+ // exclusion reason or channel field never costs the FE the whole recipient row.
258
272
  eligibility: z.record(z.unknown()),
259
273
  channels: z.record(z.unknown()),
260
274
  interaction_status: zRecipientInteractionStatusSchema,
@@ -181,16 +181,61 @@ export interface ICampaignCounters {
181
181
  unique?: number;
182
182
  eligible?: number;
183
183
  excluded_optout?: number;
184
+ /** Excluded by the 15-day fatigue rule. INCLUDES `excluded_frequency_unavailable`
185
+ * (the fail-closed subset) — never sum the two, the second is a breakdown of
186
+ * the first. */
184
187
  excluded_frequency?: number;
188
+ /**
189
+ * Subset of `excluded_frequency` excluded because the lead's marketing history
190
+ * could NOT be read, so the check failed CLOSED — not because the contact was
191
+ * touched recently. Reported apart because the two lead to opposite actions:
192
+ * "everybody was contacted in the last 15 days" (wait) vs "the history query is
193
+ * broken" (fix the index and re-materialize). Rolling them together made the
194
+ * screen state something actively false (F3.1 review A1).
195
+ */
196
+ excluded_frequency_unavailable?: number;
197
+ /** Always 0 today — no code path increments it (see audit note at the bottom). */
185
198
  excluded_invalid?: number;
186
199
  excluded_no_channel?: number;
187
200
  excluded_duplicate?: number;
201
+ /** Recipients removed by hand from the audience (`manual_excluded=true`). Written
202
+ * wholesale by the materializer and incremented/decremented by the exclude/restore
203
+ * endpoints. */
204
+ excluded_manual?: number;
188
205
  per_channel?: {
189
206
  whatsapp?: ICampaignChannelCounters;
190
207
  email?: ICampaignChannelCounters;
191
208
  };
192
209
  converted?: number;
193
210
  }
211
+ /** Caveat on a materialization that SUCCEEDED but whose result is not what it looks like. */
212
+ export type CampaignMaterializeWarning =
213
+ /** The campaign has no bucket at all — the audience is empty by construction,
214
+ * not because the filters matched nobody. */
215
+ "no_buckets"
216
+ /** The 15-day fatigue check could not read the lead history and failed closed:
217
+ * the recipients were excluded as a precaution, not for being contacted recently. */
218
+ | "frequency_check_unavailable";
219
+ /**
220
+ * Progress/outcome stamp of the last audience materialization, written by the
221
+ * `campaign-materialize` handler (deep merge, so `error`/`warning` are written
222
+ * explicitly as null when absent — a stale caveat must not survive the next run).
223
+ * Drives the FE progress indicator and gates the interactive re-publish
224
+ * (`shouldSuppressMaterialize`).
225
+ */
226
+ export interface ICampaignLastMaterialize {
227
+ status: "running" | "done" | "error";
228
+ /** Required — every one of the four write-sites sets it, and the suppression guard
229
+ * reads it. Firestore returns a Timestamp here: `.toDate()` before comparing. */
230
+ at: Date;
231
+ /** Written EXPLICITLY as null on a run without one — a caveat from the previous run
232
+ * must not survive the next (the stamp is a deep merge). */
233
+ error?: string | null;
234
+ warning?: CampaignMaterializeWarning | null;
235
+ /** Result snapshot (status='done' only) — mirrors counters.unique / counters.eligible. */
236
+ unique?: number;
237
+ eligible?: number;
238
+ }
194
239
  export interface ICampaign extends IFireDoc {
195
240
  name: string;
196
241
  description?: string | null;
@@ -215,6 +260,9 @@ export interface ICampaign extends IFireDoc {
215
260
  prevalidation?: ICampaignPrevalidation;
216
261
  cost_estimate?: ICampaignCostEstimate;
217
262
  counters?: ICampaignCounters;
263
+ /** Cleared (field delete) when the last bucket is removed — an audience of zero
264
+ * must not keep advertising the numbers of the previous run. */
265
+ last_materialize?: ICampaignLastMaterialize;
218
266
  created_by?: FirestoreDocumentReference;
219
267
  updated_by?: FirestoreDocumentReference;
220
268
  scheduled_by?: FirestoreDocumentReference;
@@ -245,9 +293,13 @@ export interface ICampaignBucketCsvFile {
245
293
  }>;
246
294
  }
247
295
  export interface ICampaignBucketCounts {
296
+ /** Rows enumerated by this bucket (CSV: row_count; Algolia: preview/enumeration count). */
248
297
  raw?: number;
298
+ /** CSV only, at upload: parseable contacts the bucket contributes. */
249
299
  unique_contribution?: number;
300
+ /** Set per bucket by the materializer (dot-path update, never set/merge). */
250
301
  eligible?: number;
302
+ /** CSV only, at upload: rows rejected by the parser. */
251
303
  invalid?: number;
252
304
  opt_out?: number;
253
305
  frequency_excluded?: number;
@@ -263,8 +315,15 @@ export interface ICampaignBucket extends IFireDoc {
263
315
  }
264
316
  /** Flat field driving the batch queue (indexed). */
265
317
  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). */
318
+ /**
319
+ * RESERVED — no writer today. `cancelCampaign` still parks pending recipients as
320
+ * `excluded` and carries the cancellation in `interaction_status='cancelado'`, so
321
+ * this value never reaches Firestore. Kept because the FSM wants it, but whoever
322
+ * starts writing it must check every `dispatch_state` reader first: the queue query
323
+ * filters on `=='pending'` (unaffected), but the FE parses recipients through
324
+ * `zRecipientDispatchStateSchema` and a value missing from that enum makes it drop
325
+ * the WHOLE document, not just the field.
326
+ */
268
327
  | "canceled";
269
328
  /** Per-channel delivery status of a recipient (monotonic). */
270
329
  export type RecipientChannelStatus = "pending" | "scheduled" | "sent" | "delivered" | "read" | "failed" | "clicked" | "replied";
@@ -276,6 +335,13 @@ export interface ICampaignRecipientContact {
276
335
  email?: string;
277
336
  external_id?: string;
278
337
  }
338
+ /**
339
+ * Per-channel delivery state. The six milestones (sent/delivered/read/clicked/
340
+ * replied/failed) are FACTS, each with its own timestamp — and the timestamp, not the
341
+ * label, is the idempotency key of the tracking consumers. `status` is a monotonic
342
+ * LABEL derived from them and is shared mutable state, so it cannot serve as a stamp:
343
+ * a late event that moves the label would silently un-stamp the milestone under it.
344
+ */
279
345
  export interface ICampaignRecipientChannelState {
280
346
  status: RecipientChannelStatus;
281
347
  task_id?: string;
@@ -285,15 +351,41 @@ export interface ICampaignRecipientChannelState {
285
351
  read_at?: Date | null;
286
352
  clicked_at?: Date | null;
287
353
  replied_at?: Date | null;
354
+ /** Milestone stamp of a provider failure. Completes the six — `failed` was the one
355
+ * milestone without a timestamp, so its consumer had to key idempotency off
356
+ * `status === 'failed'` instead (F3.1). */
357
+ failed_at?: Date | null;
358
+ /** Provider error text that accompanies `failed_at`. */
288
359
  error?: string | null;
289
360
  }
361
+ /**
362
+ * Why a recipient was kept out. Closed union: every value here HAS a writer in
363
+ * `packages/model/src/evo-campaigns/audience/materializer.ts`, and the FE carries a
364
+ * label for each. Adding a value means adding its label in the same slice —
365
+ * `reasonLabel()` falls back to printing the raw code, which is how
366
+ * `frequency_check_unavailable` would have reached the operator as gibberish.
367
+ */
368
+ export type CampaignExclusionReason =
369
+ /** Contact opted out of the marketing level (feat-067). */
370
+ "optout"
371
+ /** Touched by another marketing campaign inside `frequency.min_days`. */
372
+ | "frequency"
373
+ /** The fatigue check could not read the lead history and failed CLOSED — the
374
+ * contact was excluded as a precaution, NOT for being contacted recently. */
375
+ | "frequency_check_unavailable"
376
+ /** No identifier for this channel (no mobile / no e-mail). */
377
+ | "no_channel"
378
+ /** Removed by hand from the audience — always the value under the `manual` key. */
379
+ | "manual_removed";
290
380
  export interface ICampaignRecipientEligibility {
291
381
  per_channel: {
292
382
  whatsapp?: "eligible" | "excluded";
293
383
  email?: "eligible" | "excluded";
294
384
  };
295
- /** channel -> exclusion reason. */
296
- exclusion_reasons?: Record<string, string>;
385
+ /** Keys: `whatsapp` / `email` (per-channel) and `manual` (always `manual_removed`).
386
+ * A recipient can carry several at once — `manual` stacks on top of a real
387
+ * per-channel reason, which is what makes a restore keep it ineligible. */
388
+ exclusion_reasons?: Record<string, CampaignExclusionReason>;
297
389
  }
298
390
  export interface ICampaignRecipient extends IFireDoc {
299
391
  lead_id?: string;
@@ -305,6 +397,13 @@ export interface ICampaignRecipient extends IFireDoc {
305
397
  csv_variables?: Record<string, string>;
306
398
  /** FLAT field for the batch queue (indexed). */
307
399
  dispatch_state: RecipientDispatchState;
400
+ /**
401
+ * Removed by hand from the audience (F2.6/F2.7). This is the DETERMINISTIC marker:
402
+ * `eligibility.per_channel` is deliberately kept at its real value so a restore is
403
+ * symmetric, so per_channel alone cannot tell a manual removal from an organic
404
+ * exclusion. Survives re-materialization; deleted (not set false) on restore.
405
+ */
406
+ manual_excluded?: boolean;
308
407
  eligibility: ICampaignRecipientEligibility;
309
408
  channels: {
310
409
  whatsapp?: ICampaignRecipientChannelState;
@@ -255,10 +255,27 @@ export interface ICampaignCounters {
255
255
  unique?: number;
256
256
  eligible?: number;
257
257
  excluded_optout?: number;
258
+ /** Excluded by the 15-day fatigue rule. INCLUDES `excluded_frequency_unavailable`
259
+ * (the fail-closed subset) — never sum the two, the second is a breakdown of
260
+ * the first. */
258
261
  excluded_frequency?: number;
262
+ /**
263
+ * Subset of `excluded_frequency` excluded because the lead's marketing history
264
+ * could NOT be read, so the check failed CLOSED — not because the contact was
265
+ * touched recently. Reported apart because the two lead to opposite actions:
266
+ * "everybody was contacted in the last 15 days" (wait) vs "the history query is
267
+ * broken" (fix the index and re-materialize). Rolling them together made the
268
+ * screen state something actively false (F3.1 review A1).
269
+ */
270
+ excluded_frequency_unavailable?: number;
271
+ /** Always 0 today — no code path increments it (see audit note at the bottom). */
259
272
  excluded_invalid?: number;
260
273
  excluded_no_channel?: number;
261
274
  excluded_duplicate?: number;
275
+ /** Recipients removed by hand from the audience (`manual_excluded=true`). Written
276
+ * wholesale by the materializer and incremented/decremented by the exclude/restore
277
+ * endpoints. */
278
+ excluded_manual?: number;
262
279
  per_channel?: {
263
280
  whatsapp?: ICampaignChannelCounters;
264
281
  email?: ICampaignChannelCounters;
@@ -266,6 +283,38 @@ export interface ICampaignCounters {
266
283
  converted?: number;
267
284
  }
268
285
 
286
+ // ----- Last materialization stamp -------------------------------------------
287
+
288
+ /** Caveat on a materialization that SUCCEEDED but whose result is not what it looks like. */
289
+ export type CampaignMaterializeWarning =
290
+ /** The campaign has no bucket at all — the audience is empty by construction,
291
+ * not because the filters matched nobody. */
292
+ | "no_buckets"
293
+ /** The 15-day fatigue check could not read the lead history and failed closed:
294
+ * the recipients were excluded as a precaution, not for being contacted recently. */
295
+ | "frequency_check_unavailable";
296
+
297
+ /**
298
+ * Progress/outcome stamp of the last audience materialization, written by the
299
+ * `campaign-materialize` handler (deep merge, so `error`/`warning` are written
300
+ * explicitly as null when absent — a stale caveat must not survive the next run).
301
+ * Drives the FE progress indicator and gates the interactive re-publish
302
+ * (`shouldSuppressMaterialize`).
303
+ */
304
+ export interface ICampaignLastMaterialize {
305
+ status: "running" | "done" | "error";
306
+ /** Required — every one of the four write-sites sets it, and the suppression guard
307
+ * reads it. Firestore returns a Timestamp here: `.toDate()` before comparing. */
308
+ at: Date;
309
+ /** Written EXPLICITLY as null on a run without one — a caveat from the previous run
310
+ * must not survive the next (the stamp is a deep merge). */
311
+ error?: string | null;
312
+ warning?: CampaignMaterializeWarning | null;
313
+ /** Result snapshot (status='done' only) — mirrors counters.unique / counters.eligible. */
314
+ unique?: number;
315
+ eligible?: number;
316
+ }
317
+
269
318
  // ----- Campaign aggregate doc -----------------------------------------------
270
319
 
271
320
  export interface ICampaign extends IFireDoc {
@@ -297,6 +346,9 @@ export interface ICampaign extends IFireDoc {
297
346
  prevalidation?: ICampaignPrevalidation;
298
347
  cost_estimate?: ICampaignCostEstimate;
299
348
  counters?: ICampaignCounters;
349
+ /** Cleared (field delete) when the last bucket is removed — an audience of zero
350
+ * must not keep advertising the numbers of the previous run. */
351
+ last_materialize?: ICampaignLastMaterialize;
300
352
 
301
353
  // Audit (who/when per FSM transition)
302
354
  created_by?: FirestoreDocumentReference;
@@ -334,10 +386,18 @@ export interface ICampaignBucketCsvFile {
334
386
  }
335
387
 
336
388
  export interface ICampaignBucketCounts {
389
+ /** Rows enumerated by this bucket (CSV: row_count; Algolia: preview/enumeration count). */
337
390
  raw?: number;
391
+ /** CSV only, at upload: parseable contacts the bucket contributes. */
338
392
  unique_contribution?: number;
393
+ /** Set per bucket by the materializer (dot-path update, never set/merge). */
339
394
  eligible?: number;
395
+ /** CSV only, at upload: rows rejected by the parser. */
340
396
  invalid?: number;
397
+ // The three below have NO writer anywhere today — a per-bucket exclusion breakdown
398
+ // was never implemented (the breakdown lives on the campaign counters instead).
399
+ // Left declared, optional and empty rather than removed: they are the shape a future
400
+ // per-bucket breakdown should take, and nothing reads them meanwhile.
341
401
  opt_out?: number;
342
402
  frequency_excluded?: number;
343
403
  no_channel?: number;
@@ -360,8 +420,15 @@ export type RecipientDispatchState =
360
420
  | "dispatched"
361
421
  | "done"
362
422
  | "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). */
423
+ /**
424
+ * RESERVED — no writer today. `cancelCampaign` still parks pending recipients as
425
+ * `excluded` and carries the cancellation in `interaction_status='cancelado'`, so
426
+ * this value never reaches Firestore. Kept because the FSM wants it, but whoever
427
+ * starts writing it must check every `dispatch_state` reader first: the queue query
428
+ * filters on `=='pending'` (unaffected), but the FE parses recipients through
429
+ * `zRecipientDispatchStateSchema` and a value missing from that enum makes it drop
430
+ * the WHOLE document, not just the field.
431
+ */
365
432
  | "canceled";
366
433
 
367
434
  /** Per-channel delivery status of a recipient (monotonic). */
@@ -397,6 +464,13 @@ export interface ICampaignRecipientContact {
397
464
  external_id?: string;
398
465
  }
399
466
 
467
+ /**
468
+ * Per-channel delivery state. The six milestones (sent/delivered/read/clicked/
469
+ * replied/failed) are FACTS, each with its own timestamp — and the timestamp, not the
470
+ * label, is the idempotency key of the tracking consumers. `status` is a monotonic
471
+ * LABEL derived from them and is shared mutable state, so it cannot serve as a stamp:
472
+ * a late event that moves the label would silently un-stamp the milestone under it.
473
+ */
400
474
  export interface ICampaignRecipientChannelState {
401
475
  status: RecipientChannelStatus;
402
476
  task_id?: string;
@@ -406,16 +480,43 @@ export interface ICampaignRecipientChannelState {
406
480
  read_at?: Date | null;
407
481
  clicked_at?: Date | null;
408
482
  replied_at?: Date | null;
483
+ /** Milestone stamp of a provider failure. Completes the six — `failed` was the one
484
+ * milestone without a timestamp, so its consumer had to key idempotency off
485
+ * `status === 'failed'` instead (F3.1). */
486
+ failed_at?: Date | null;
487
+ /** Provider error text that accompanies `failed_at`. */
409
488
  error?: string | null;
410
489
  }
411
490
 
491
+ /**
492
+ * Why a recipient was kept out. Closed union: every value here HAS a writer in
493
+ * `packages/model/src/evo-campaigns/audience/materializer.ts`, and the FE carries a
494
+ * label for each. Adding a value means adding its label in the same slice —
495
+ * `reasonLabel()` falls back to printing the raw code, which is how
496
+ * `frequency_check_unavailable` would have reached the operator as gibberish.
497
+ */
498
+ export type CampaignExclusionReason =
499
+ /** Contact opted out of the marketing level (feat-067). */
500
+ | "optout"
501
+ /** Touched by another marketing campaign inside `frequency.min_days`. */
502
+ | "frequency"
503
+ /** The fatigue check could not read the lead history and failed CLOSED — the
504
+ * contact was excluded as a precaution, NOT for being contacted recently. */
505
+ | "frequency_check_unavailable"
506
+ /** No identifier for this channel (no mobile / no e-mail). */
507
+ | "no_channel"
508
+ /** Removed by hand from the audience — always the value under the `manual` key. */
509
+ | "manual_removed";
510
+
412
511
  export interface ICampaignRecipientEligibility {
413
512
  per_channel: {
414
513
  whatsapp?: "eligible" | "excluded";
415
514
  email?: "eligible" | "excluded";
416
515
  };
417
- /** channel -> exclusion reason. */
418
- exclusion_reasons?: Record<string, string>;
516
+ /** Keys: `whatsapp` / `email` (per-channel) and `manual` (always `manual_removed`).
517
+ * A recipient can carry several at once — `manual` stacks on top of a real
518
+ * per-channel reason, which is what makes a restore keep it ineligible. */
519
+ exclusion_reasons?: Record<string, CampaignExclusionReason>;
419
520
  }
420
521
 
421
522
  export interface ICampaignRecipient extends IFireDoc {
@@ -428,6 +529,13 @@ export interface ICampaignRecipient extends IFireDoc {
428
529
  csv_variables?: Record<string, string>;
429
530
  /** FLAT field for the batch queue (indexed). */
430
531
  dispatch_state: RecipientDispatchState;
532
+ /**
533
+ * Removed by hand from the audience (F2.6/F2.7). This is the DETERMINISTIC marker:
534
+ * `eligibility.per_channel` is deliberately kept at its real value so a restore is
535
+ * symmetric, so per_channel alone cannot tell a manual removal from an organic
536
+ * exclusion. Survives re-materialization; deleted (not set false) on restore.
537
+ */
538
+ manual_excluded?: boolean;
431
539
  eligibility: ICampaignRecipientEligibility;
432
540
  channels: {
433
541
  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'];
@@ -2,7 +2,13 @@ import type { IFireGlobalDoc } from '../../shared';
2
2
 
3
3
  // ── Status & Recurrence ──
4
4
 
5
- export type NexFinopsContractStatus = 'draft' | 'active' | 'inactive' | 'ended' | 'canceled';
5
+ /**
6
+ * `cancellation_requested`: o cliente pediu o cancelamento e o contrato está em
7
+ * processo de encerramento — mas **continua em vigência** e continua faturando
8
+ * (ver `NEX_FINOPS_BILLABLE_CONTRACT_STATUSES`). Só sai desse estado ao encerrar
9
+ * (`ended`), ao ser anulado (`canceled`) ou se o cliente voltar atrás (`active`).
10
+ */
11
+ export type NexFinopsContractStatus = 'draft' | 'active' | 'cancellation_requested' | 'inactive' | 'ended' | 'canceled';
6
12
  export type NexFinopsContractRecurrence = 'one_time' | 'monthly';
7
13
  export type NexFinopsPaymentMethod = 'pix' | 'boleto' | 'payment_link';
8
14
 
@@ -55,8 +61,21 @@ export interface INexFinopsContract extends IFireGlobalDoc {
55
61
 
56
62
  export const NEX_FINOPS_CONTRACT_STATUS_TRANSITIONS: Record<NexFinopsContractStatus, NexFinopsContractStatus[]> = {
57
63
  draft: ['active', 'canceled'],
58
- active: ['inactive', 'ended', 'canceled'],
64
+ active: ['cancellation_requested', 'inactive', 'ended', 'canceled'],
65
+ // Volta para 'active' cobre o cliente que desiste do cancelamento.
66
+ cancellation_requested: ['ended', 'canceled', 'active'],
59
67
  inactive: ['active'],
60
68
  ended: [],
61
69
  canceled: [],
62
70
  };
71
+
72
+ /**
73
+ * Status em que um contrato ainda gera cobrança.
74
+ *
75
+ * `cancellation_requested` entra aqui de propósito: o contrato pediu cancelamento
76
+ * mas segue em vigência até encerrar, e deve continuar sendo cobrado no período.
77
+ *
78
+ * Tipado como `readonly string[]` (e não `readonly NexFinopsContractStatus[]`) para
79
+ * que `.includes(x)` aceite qualquer string sem cast no ponto de uso.
80
+ */
81
+ export const NEX_FINOPS_BILLABLE_CONTRACT_STATUSES: readonly string[] = ['active', 'cancellation_requested'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evo360-types",
3
- "version": "1.3.469",
3
+ "version": "1.3.472",
4
4
  "description": "HREVO360 Shared Types",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",