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.
- package/dist/apps/evo-campaigns/zod-schemas.d.ts +355 -341
- package/dist/apps/evo-campaigns/zod-schemas.js +19 -0
- package/dist/apps/evo-campaigns/zod-schemas.ts +19 -0
- package/dist/types/evo-campaigns/index.d.ts +137 -8
- package/dist/types/evo-campaigns/index.ts +149 -8
- package/dist/types/evo-finops/common/contract.d.ts +17 -1
- package/dist/types/evo-finops/common/contract.js +14 -2
- package/dist/types/evo-finops/common/contract.ts +21 -2
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
267
|
-
*
|
|
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
|
|
296
|
-
|
|
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"
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
364
|
-
*
|
|
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
|
|
418
|
-
|
|
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
|
-
|
|
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'];
|