@cowliss/cli 0.6.0 → 0.7.0
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/README.md +1 -1
- package/dist/guest/{driver-DBt5l1Z5.js → driver-C1bsCjZT.js} +4 -1
- package/dist/guest/driver.d.ts +1 -1
- package/dist/guest/driver.js +1 -1
- package/dist/guest/emails.d.ts +6 -0
- package/dist/guest/{index-CXcCEcAg.d.ts → index-3PMqJKmc.d.ts} +104 -12
- package/dist/guest/{journeys-BrcKEXz0.js → journeys-Dpsp225V.js} +223 -43
- package/dist/guest/journeys.d.ts +37 -12
- package/dist/guest/journeys.js +1 -1
- package/dist/guest/wasi.js +1 -1
- package/dist/index.js +777 -290
- package/examples/abandoned-checkout/journeys/abandoned-checkout.ts +9 -1
- package/examples/activity-decay/journeys/activity-decay.ts +13 -2
- package/examples/cross-app-pitch/journeys/cross-app-pitch.ts +6 -1
- package/examples/winback/journeys/winback.ts +10 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -82,30 +82,30 @@ const TOPUP_PRESETS_MICROS = [
|
|
|
82
82
|
2e8
|
|
83
83
|
];
|
|
84
84
|
/**
|
|
85
|
-
* Fixed consent purposes for the prototype. Consent is a per-purpose map on
|
|
86
|
-
* the profile, checked at send-step execution time.
|
|
87
|
-
*
|
|
88
|
-
* The two names describe what the recipient agreed to, not the pipe it
|
|
89
|
-
* arrives on: "emails I did not ask for individually" and "my data leaving
|
|
90
|
-
* for somewhere else". Naming them after the channel (`email`, `webhook`)
|
|
91
|
-
* said nothing a recipient could consent to, and transactional mail already
|
|
92
|
-
* bypasses the email purpose, so it was only ever marketing consent.
|
|
93
|
-
*/
|
|
94
|
-
const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
|
|
95
|
-
/**
|
|
96
85
|
* The marketing purpose by name, since it is the one every gate, the
|
|
97
86
|
* unsubscribe route, and the developer's own toggle all reach for.
|
|
98
87
|
*/
|
|
99
|
-
const
|
|
88
|
+
const MARKETING = "marketing";
|
|
89
|
+
/**
|
|
90
|
+
* A legal journey `purpose` that is not a consent purpose: the send gates
|
|
91
|
+
* pass it unconditionally, no profile's map stores it and no editor renders
|
|
92
|
+
* it. What protects the channel itself (suppression, the SES gates, the
|
|
93
|
+
* quota) still applies.
|
|
94
|
+
*/
|
|
95
|
+
const TRANSACTIONAL = "transactional";
|
|
96
|
+
/**
|
|
97
|
+
* The purposes a project may not declare, which are the same two that sit
|
|
98
|
+
* outside the `marketing` umbrella: `marketing` is the umbrella itself and
|
|
99
|
+
* `transactional` is the case consent does not govern. Everything a project
|
|
100
|
+
* declares is a marketing-mail category and so sits under it.
|
|
101
|
+
*/
|
|
102
|
+
const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
|
|
100
103
|
/**
|
|
101
104
|
* What a purpose means when the profile's map does not answer it, matching
|
|
102
|
-
* the `profiles.consent` column default: marketing is asked for,
|
|
103
|
-
*
|
|
105
|
+
* the `profiles.consent` column default: marketing is asked for, never
|
|
106
|
+
* assumed.
|
|
104
107
|
*/
|
|
105
|
-
const CONSENT_PURPOSE_DEFAULTS = {
|
|
106
|
-
emailMarketing: false,
|
|
107
|
-
dataProcessing: true
|
|
108
|
-
};
|
|
108
|
+
const CONSENT_PURPOSE_DEFAULTS = { marketing: false };
|
|
109
109
|
/**
|
|
110
110
|
* The `defaults` argument `consentGranted` takes, built from an org's
|
|
111
111
|
* purpose rows. Every surface that renders or gates a purpose reads those
|
|
@@ -3341,6 +3341,10 @@ const deliveryKindEnum = pgEnum("delivery_kind", [
|
|
|
3341
3341
|
* `skipped_suppressed` is the recipient's address sitting in the
|
|
3342
3342
|
* suppression mirror, which SES would have bounced off its own
|
|
3343
3343
|
* suppression list anyway.
|
|
3344
|
+
* `skipped_unknown_purpose` is the version naming a purpose the
|
|
3345
|
+
* organization does not have, which is a broken manifest rather than a
|
|
3346
|
+
* consent decision: the `error` column names the purpose, and the fix is
|
|
3347
|
+
* another push (ADR 0017).
|
|
3344
3348
|
* - `would_*` are dry-run outcomes: terminal, never reach SES, and
|
|
3345
3349
|
* excluded from feedback, quotas, and reconciliation.
|
|
3346
3350
|
*
|
|
@@ -3357,6 +3361,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
|
|
|
3357
3361
|
"skipped_paused",
|
|
3358
3362
|
"skipped_suppressed",
|
|
3359
3363
|
"skipped_quota",
|
|
3364
|
+
"skipped_unknown_purpose",
|
|
3360
3365
|
"skipped_frequency_cap",
|
|
3361
3366
|
"skipped_consent",
|
|
3362
3367
|
"skipped_domain",
|
|
@@ -3366,6 +3371,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
|
|
|
3366
3371
|
"would_skip_paused",
|
|
3367
3372
|
"would_skip_suppressed",
|
|
3368
3373
|
"would_skip_quota",
|
|
3374
|
+
"would_skip_unknown_purpose",
|
|
3369
3375
|
"would_skip_frequency_cap",
|
|
3370
3376
|
"would_skip_consent",
|
|
3371
3377
|
"would_skip_domain",
|
|
@@ -3551,7 +3557,7 @@ const executions$1 = pgTable("executions", {
|
|
|
3551
3557
|
})
|
|
3552
3558
|
}, (table) => [
|
|
3553
3559
|
index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
|
|
3554
|
-
|
|
3560
|
+
index("executions_org_id_workflow_id_status_idx").on(table.orgId, table.workflowId, table.status),
|
|
3555
3561
|
index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
|
|
3556
3562
|
]);
|
|
3557
3563
|
const selectExecutionSchema = createSelectSchema(executions$1);
|
|
@@ -3619,11 +3625,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
|
|
|
3619
3625
|
* creating one when none is known.
|
|
3620
3626
|
*
|
|
3621
3627
|
* traits is the merged trait bag (RFC 7386 key-level merge on write).
|
|
3622
|
-
* consent is the
|
|
3623
|
-
*
|
|
3624
|
-
*
|
|
3625
|
-
*
|
|
3626
|
-
*
|
|
3628
|
+
* consent is the per-purpose consent map. Marketing starts denied: nobody
|
|
3629
|
+
* is subscribed by the act of being ingested. `transactional` is absent by
|
|
3630
|
+
* design (ADR 0017): it is not a purpose anyone may withhold, so it is not
|
|
3631
|
+
* stored. The map is written by the identify path (a caller passing
|
|
3632
|
+
* `consent`), the consent editor, and the automatic revocations.
|
|
3627
3633
|
*
|
|
3628
3634
|
* `mergedInto` is the merge pointer (spec: Identity). Null on a live
|
|
3629
3635
|
* profile; on a profile a merge folded away it names the survivor, whose
|
|
@@ -3641,10 +3647,7 @@ const profiles = pgTable("profiles", {
|
|
|
3641
3647
|
appId: text("app_id").notNull(),
|
|
3642
3648
|
sourceId: text("source_id").notNull(),
|
|
3643
3649
|
traits: jsonb("traits").$type().notNull().default({}),
|
|
3644
|
-
consent: jsonb("consent").$type().notNull().default({
|
|
3645
|
-
emailMarketing: false,
|
|
3646
|
-
dataProcessing: true
|
|
3647
|
-
}),
|
|
3650
|
+
consent: jsonb("consent").$type().notNull().default({ marketing: false }),
|
|
3648
3651
|
mergedInto: text("merged_into"),
|
|
3649
3652
|
createdAt: createdAt(),
|
|
3650
3653
|
updatedAt: updatedAt()
|
|
@@ -3745,6 +3748,18 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3745
3748
|
//#endregion
|
|
3746
3749
|
//#region ../../packages/db/src/schema/journeys.ts
|
|
3747
3750
|
/**
|
|
3751
|
+
* The one operational state a manager sets (ADR 0018): `on` (recipients
|
|
3752
|
+
* enroll and executions run), `off` (nothing enrolls, executions in flight
|
|
3753
|
+
* finish) or `paused` (nothing enrolls, executions in flight hold before
|
|
3754
|
+
* their next step). Three values rather than two booleans: a second flag
|
|
3755
|
+
* beside the first would spell two combinations that mean nothing.
|
|
3756
|
+
*/
|
|
3757
|
+
const journeyStatusEnum = pgEnum("journey_status", [
|
|
3758
|
+
"on",
|
|
3759
|
+
"off",
|
|
3760
|
+
"paused"
|
|
3761
|
+
]);
|
|
3762
|
+
/**
|
|
3748
3763
|
* Derived journey rows: one per (org, key), upserted from the manifest a
|
|
3749
3764
|
* push carried. Nothing here is authored through the API, which is why
|
|
3750
3765
|
* there is no id of its own: the key is the name the author gave the file,
|
|
@@ -3757,9 +3772,9 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
|
|
|
3757
3772
|
* what makes a key another project owns a refused push rather than a silent
|
|
3758
3773
|
* overwrite. Removing a journey is an explicit delete (ADR 0009).
|
|
3759
3774
|
*
|
|
3760
|
-
* Whether a journey fires is one
|
|
3775
|
+
* Whether a journey fires is one field and one only: `status`, which a
|
|
3761
3776
|
* push never writes, so deploying code never turns anything on or off. The
|
|
3762
|
-
* author has no second gate of their own (ADR 0011).
|
|
3777
|
+
* author has no second gate of their own (ADR 0011, ADR 0018).
|
|
3763
3778
|
*
|
|
3764
3779
|
* The descriptive columns are what its latest ready version reported, so a
|
|
3765
3780
|
* failed compile leaves both the code and its description as they were.
|
|
@@ -3782,12 +3797,18 @@ const journeys$1 = pgTable("journeys", {
|
|
|
3782
3797
|
* the fixed pair.
|
|
3783
3798
|
*/
|
|
3784
3799
|
purpose: text("purpose").notNull(),
|
|
3800
|
+
/**
|
|
3801
|
+
* The author's own sentence about what the journey does, from the
|
|
3802
|
+
* manifest. Null when they wrote none.
|
|
3803
|
+
*/
|
|
3804
|
+
description: text("description"),
|
|
3785
3805
|
spine: jsonb("spine").$type().notNull(),
|
|
3786
3806
|
/**
|
|
3787
3807
|
* The one gate on the journey: off until `cow enable` or a manager's
|
|
3788
|
-
*
|
|
3808
|
+
* control, and never touched by a push (ADR 0011, amended by ADR 0013
|
|
3809
|
+
* and ADR 0018).
|
|
3789
3810
|
*/
|
|
3790
|
-
|
|
3811
|
+
status: journeyStatusEnum("status").notNull().default("off"),
|
|
3791
3812
|
createdAt: createdAt(),
|
|
3792
3813
|
updatedAt: updatedAt()
|
|
3793
3814
|
}, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
|
|
@@ -3814,17 +3835,16 @@ const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"])
|
|
|
3814
3835
|
* switch, the allowance override, and Clerk's cached profile.
|
|
3815
3836
|
*
|
|
3816
3837
|
* `slug` and `name` are the exception to "only what Cowliss owns": they are
|
|
3817
|
-
* Clerk's, cached here by the API's org-sync middleware
|
|
3818
|
-
*
|
|
3819
|
-
* `<org-slug>@<shared domain>` with the org's name as the from-name.
|
|
3838
|
+
* Clerk's, cached here by the API's org-sync middleware, and they are what a
|
|
3839
|
+
* template test-send composes its from-address out of (`orgFromAddress`).
|
|
3820
3840
|
*/
|
|
3821
3841
|
const orgSettings = pgTable("org_settings", {
|
|
3822
3842
|
orgId: text("org_id").primaryKey(),
|
|
3823
3843
|
/**
|
|
3824
3844
|
* The Clerk org's slug and display name, synced by the API on request.
|
|
3825
|
-
* Null until a member of the org has hit the dashboard API once
|
|
3826
|
-
*
|
|
3827
|
-
*
|
|
3845
|
+
* Null until a member of the org has hit the dashboard API once, when
|
|
3846
|
+
* `orgFromAddress` falls back to an orgId-derived local part and the tool
|
|
3847
|
+
* name.
|
|
3828
3848
|
*/
|
|
3829
3849
|
slug: text("slug"),
|
|
3830
3850
|
name: text("name"),
|
|
@@ -4038,9 +4058,17 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
|
|
|
4038
4058
|
//#endregion
|
|
4039
4059
|
//#region ../../packages/db/src/schema/segments.ts
|
|
4040
4060
|
/**
|
|
4041
|
-
* Segments:
|
|
4042
|
-
*
|
|
4043
|
-
*
|
|
4061
|
+
* Segments: membership rules over profiles. Membership is computed, never
|
|
4062
|
+
* hand-maintained: the engine in packages/segments recomputes on every write
|
|
4063
|
+
* and materializes the result into segment_members.
|
|
4064
|
+
*
|
|
4065
|
+
* A row is either standalone (built in the dashboard, `journey_key` null) or
|
|
4066
|
+
* owned by one journey whose trigger inlined its definition (ADR 0016). An
|
|
4067
|
+
* owned row is named after its journey's key, is written only by the push,
|
|
4068
|
+
* and goes when the journey does — which is what the composite foreign key
|
|
4069
|
+
* says: it points at `journeys(org_id, key)` and cascades. A null
|
|
4070
|
+
* `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
|
|
4071
|
+
* are unconstrained.
|
|
4044
4072
|
*
|
|
4045
4073
|
* Timestamps use millisecond precision, same rationale as apps: JS Dates
|
|
4046
4074
|
* carry ms only and cursor pagination compares createdAt for equality.
|
|
@@ -4051,9 +4079,19 @@ const segments$1 = pgTable("segments", {
|
|
|
4051
4079
|
name: text("name").notNull(),
|
|
4052
4080
|
description: text("description"),
|
|
4053
4081
|
definition: jsonb("definition").$type().notNull(),
|
|
4082
|
+
/** The journey that owns this row, or null for a standalone segment. */
|
|
4083
|
+
journeyKey: text("journey_key"),
|
|
4054
4084
|
createdAt: createdAt(),
|
|
4055
4085
|
updatedAt: updatedAt()
|
|
4056
|
-
}, (table) => [
|
|
4086
|
+
}, (table) => [
|
|
4087
|
+
index("segments_org_id_idx").on(table.orgId),
|
|
4088
|
+
uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name),
|
|
4089
|
+
foreignKey({
|
|
4090
|
+
columns: [table.orgId, table.journeyKey],
|
|
4091
|
+
foreignColumns: [journeys$1.orgId, journeys$1.key],
|
|
4092
|
+
name: "segments_journey_fk"
|
|
4093
|
+
}).onDelete("cascade")
|
|
4094
|
+
]);
|
|
4057
4095
|
/**
|
|
4058
4096
|
* Materialized membership: one row per (segment, profile) currently in the
|
|
4059
4097
|
* segment. Deleting a segment drops its members with it, and erasing a
|
|
@@ -4089,10 +4127,16 @@ const insertSegmentSchema = createInsertSchema(segments$1);
|
|
|
4089
4127
|
* emits no domain events, so the mirror refreshes by pulling
|
|
4090
4128
|
* `GetEmailIdentity` on a schedule and on the manual re-check button.
|
|
4091
4129
|
*
|
|
4092
|
-
*
|
|
4093
|
-
*
|
|
4094
|
-
*
|
|
4095
|
-
*
|
|
4130
|
+
* A domain is claimed when it VERIFIES, not when it is added (ADR 0021).
|
|
4131
|
+
* Any number of orgs may hold the same unverified string; the partial unique
|
|
4132
|
+
* below is what makes at most one of them verified, and so the owner. The
|
|
4133
|
+
* cross-org parent/child guard (a query, not a constraint) refuses a claim
|
|
4134
|
+
* under or over a name another org already verified.
|
|
4135
|
+
*
|
|
4136
|
+
* SES keys ONE identity by the domain name, so its DKIM records, and the
|
|
4137
|
+
* SUCCESS it reports for them, are shared by every org holding the name.
|
|
4138
|
+
* They therefore prove that SOMEBODY controls the DNS, never which org, and
|
|
4139
|
+
* `challenge_token` is the part that is per org: the claim needs both.
|
|
4096
4140
|
*/
|
|
4097
4141
|
/**
|
|
4098
4142
|
* SES's identity status vocabulary, lower-cased like every other enum here.
|
|
@@ -4100,13 +4144,19 @@ const insertSegmentSchema = createInsertSchema(segments$1);
|
|
|
4100
4144
|
* all report the same five words. `temporary_failure` is SES's retryable
|
|
4101
4145
|
* state, which is neither verified nor a dead end, so it is kept distinct
|
|
4102
4146
|
* from `failed`.
|
|
4147
|
+
*
|
|
4148
|
+
* `blocked` is the one value SES never says: the DNS checks out, but another
|
|
4149
|
+
* organization has already verified this name or a parent or child of it, so
|
|
4150
|
+
* the claim cannot be granted here (ADR 0021). It is a terminal-looking
|
|
4151
|
+
* state that clears itself, because every refresh re-asks the question.
|
|
4103
4152
|
*/
|
|
4104
4153
|
const senderDomainStatusEnum = pgEnum("sender_domain_status", [
|
|
4105
4154
|
"not_started",
|
|
4106
4155
|
"pending",
|
|
4107
4156
|
"success",
|
|
4108
4157
|
"failed",
|
|
4109
|
-
"temporary_failure"
|
|
4158
|
+
"temporary_failure",
|
|
4159
|
+
"blocked"
|
|
4110
4160
|
]);
|
|
4111
4161
|
const senderDomains = pgTable("sender_domains", {
|
|
4112
4162
|
id: text("id").primaryKey(),
|
|
@@ -4120,12 +4170,25 @@ const senderDomains = pgTable("sender_domains", {
|
|
|
4120
4170
|
spfStatus: text("spf_status"),
|
|
4121
4171
|
/**
|
|
4122
4172
|
* The per-domain click-tracking toggle, Cowliss's own setting: the send
|
|
4123
|
-
* picks SES's tracked configuration set when it is on. Off by default
|
|
4124
|
-
*
|
|
4125
|
-
* at all): one setting cannot serve every org sharing it.
|
|
4173
|
+
* picks SES's tracked configuration set when it is on. Off by default:
|
|
4174
|
+
* link rewriting is a thing an org opts into.
|
|
4126
4175
|
*/
|
|
4127
4176
|
clickTracking: boolean("click_tracking").notNull().default(false),
|
|
4128
4177
|
dnsRecords: jsonb("dns_records").$type().notNull().default([]),
|
|
4178
|
+
/**
|
|
4179
|
+
* This org's own proof of control, published as a TXT record under the
|
|
4180
|
+
* domain. Random per row and never re-issued, so two orgs holding the
|
|
4181
|
+
* same name publish different values and only the one that controls the
|
|
4182
|
+
* DNS can publish its own. Verified once, at the moment the claim is
|
|
4183
|
+
* granted; a live sender never loses its domain to one failed lookup.
|
|
4184
|
+
*/
|
|
4185
|
+
challengeToken: text("challenge_token").notNull().default(sql`replace(gen_random_uuid()::text, '-', '')`),
|
|
4186
|
+
/** When this org's own TXT record was last seen. Null until it is. */
|
|
4187
|
+
challengeVerifiedAt: timestamp("challenge_verified_at", {
|
|
4188
|
+
withTimezone: true,
|
|
4189
|
+
mode: "date",
|
|
4190
|
+
precision: 3
|
|
4191
|
+
}),
|
|
4129
4192
|
/** When the mirror last asked SES; drives the refresh sweep. */
|
|
4130
4193
|
lastCheckedAt: timestamp("last_checked_at", {
|
|
4131
4194
|
withTimezone: true,
|
|
@@ -4134,7 +4197,11 @@ const senderDomains = pgTable("sender_domains", {
|
|
|
4134
4197
|
}),
|
|
4135
4198
|
createdAt: createdAt(),
|
|
4136
4199
|
updatedAt: updatedAt()
|
|
4137
|
-
}, (table) => [
|
|
4200
|
+
}, (table) => [
|
|
4201
|
+
uniqueIndex("sender_domains_org_id_domain_unique").on(table.orgId, table.domain),
|
|
4202
|
+
uniqueIndex("sender_domains_verified_domain_unique").on(table.domain).where(sql`${table.status} = 'success'`),
|
|
4203
|
+
index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)
|
|
4204
|
+
]);
|
|
4138
4205
|
const selectSenderDomainSchema = createSelectSchema(senderDomains);
|
|
4139
4206
|
const insertSenderDomainSchema = createInsertSchema(senderDomains);
|
|
4140
4207
|
|
|
@@ -4552,6 +4619,92 @@ const identifierDtoSchema = z.object({
|
|
|
4552
4619
|
createdAt: z.iso.datetime()
|
|
4553
4620
|
});
|
|
4554
4621
|
|
|
4622
|
+
//#endregion
|
|
4623
|
+
//#region ../../packages/shared/src/segment-definition.ts
|
|
4624
|
+
/**
|
|
4625
|
+
* A segment definition: a flat list of predicates over traits and event
|
|
4626
|
+
* history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
|
|
4627
|
+
* prototype, and a flat list keeps the pure evaluator a fold.
|
|
4628
|
+
*
|
|
4629
|
+
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
4630
|
+
* per-profile and profiles merge across apps, so there is nothing
|
|
4631
|
+
* app-shaped to filter on the trait side.
|
|
4632
|
+
*
|
|
4633
|
+
* A definition and the membership it produces are both the
|
|
4634
|
+
* organization's.
|
|
4635
|
+
*
|
|
4636
|
+
* Apart from `./segments` because a journey's trigger carries a definition
|
|
4637
|
+
* and the trigger schema is bundled into the wasm guest, where an edge to
|
|
4638
|
+
* `@cowliss/db` (which `./segments` has, for the row schema) is a hard
|
|
4639
|
+
* bundler failure. Nothing here imports anything but zod.
|
|
4640
|
+
*
|
|
4641
|
+
* The object is strict: a definition holding the retired `sourceId` key
|
|
4642
|
+
* (which meant the app) fails loudly instead of parsing as an unfiltered
|
|
4643
|
+
* definition that evaluates over every app. There is deliberately no pipe
|
|
4644
|
+
* filter here — filtering by source is a later feature, and accepting one
|
|
4645
|
+
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
4646
|
+
* not exist and silently match nothing.
|
|
4647
|
+
*/
|
|
4648
|
+
const SEGMENT_TRAIT_OPS = [
|
|
4649
|
+
"eq",
|
|
4650
|
+
"neq",
|
|
4651
|
+
"gt",
|
|
4652
|
+
"gte",
|
|
4653
|
+
"lt",
|
|
4654
|
+
"lte",
|
|
4655
|
+
"exists",
|
|
4656
|
+
"notExists",
|
|
4657
|
+
"contains"
|
|
4658
|
+
];
|
|
4659
|
+
/** Operators that read no comparison value: presence of the key is the test. */
|
|
4660
|
+
const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
|
|
4661
|
+
const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
|
|
4662
|
+
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
4663
|
+
kind: z.literal("trait"),
|
|
4664
|
+
name: predicateNameSchema,
|
|
4665
|
+
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
4666
|
+
/**
|
|
4667
|
+
* Compared against the stored trait, which is `unknown` because
|
|
4668
|
+
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
4669
|
+
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
4670
|
+
* form-field-friendly reading rather than strict JSON equality:
|
|
4671
|
+
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
4672
|
+
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
4673
|
+
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
4674
|
+
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
4675
|
+
* type: "0" never equals false. The numeric net is as wide as
|
|
4676
|
+
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
4677
|
+
* matters most for opaque ids: "007" is authored as the number 7.
|
|
4678
|
+
*
|
|
4679
|
+
* contains reads three ways. Against an array trait it is membership
|
|
4680
|
+
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
4681
|
+
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
4682
|
+
* no coercion, since substrings only mean something between strings.
|
|
4683
|
+
* Against anything else it never matches. Ordering never coerces
|
|
4684
|
+
* booleans.
|
|
4685
|
+
*/
|
|
4686
|
+
value: z.unknown().optional()
|
|
4687
|
+
}), z.object({
|
|
4688
|
+
kind: z.literal("event"),
|
|
4689
|
+
name: predicateNameSchema,
|
|
4690
|
+
op: z.enum(["performed", "notPerformed"]),
|
|
4691
|
+
/** How many matching events the predicate counts as "performed". */
|
|
4692
|
+
atLeast: z.number().int().min(1).default(1),
|
|
4693
|
+
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
4694
|
+
withinDays: z.number().int().min(1).optional()
|
|
4695
|
+
})]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
|
|
4696
|
+
const appIdSchema = z.string().trim().min(1);
|
|
4697
|
+
const segmentDefinitionSchema = z.strictObject({
|
|
4698
|
+
match: z.enum(["all", "any"]).default("all"),
|
|
4699
|
+
/**
|
|
4700
|
+
* Optional app filter over the event side; null/absent spans apps.
|
|
4701
|
+
* A list matches any of the named apps, which is how one definition
|
|
4702
|
+
* names the development and the production id of the same app.
|
|
4703
|
+
*/
|
|
4704
|
+
appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
|
|
4705
|
+
predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
|
|
4706
|
+
});
|
|
4707
|
+
|
|
4555
4708
|
//#endregion
|
|
4556
4709
|
//#region ../../packages/shared/src/journeys-v2/manifest.ts
|
|
4557
4710
|
/**
|
|
@@ -4561,6 +4714,34 @@ const identifierDtoSchema = z.object({
|
|
|
4561
4714
|
* changed, and stores that entry on the version it creates.
|
|
4562
4715
|
*/
|
|
4563
4716
|
/**
|
|
4717
|
+
* A duration as journey code writes it: an ms-style string ("2d") or
|
|
4718
|
+
* milliseconds. It lives here rather than with the guest protocol because a
|
|
4719
|
+
* manifest carries one too (a journey's enrollment cooldown) and `./guest`
|
|
4720
|
+
* already imports this module, so the other direction would be a cycle.
|
|
4721
|
+
*/
|
|
4722
|
+
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
4723
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
4724
|
+
const DURATION_UNIT_MS = {
|
|
4725
|
+
ms: 1,
|
|
4726
|
+
s: 1e3,
|
|
4727
|
+
m: 6e4,
|
|
4728
|
+
h: 36e5,
|
|
4729
|
+
d: 864e5,
|
|
4730
|
+
w: 6048e5
|
|
4731
|
+
};
|
|
4732
|
+
/**
|
|
4733
|
+
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
4734
|
+
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
4735
|
+
* runs in the CLI, the other inside workflow code).
|
|
4736
|
+
*/
|
|
4737
|
+
function parseDuration(duration) {
|
|
4738
|
+
if (typeof duration === "number") return duration;
|
|
4739
|
+
const match = DURATION_PATTERN.exec(duration.trim());
|
|
4740
|
+
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
4741
|
+
const unit = DURATION_UNIT_MS[match[2]];
|
|
4742
|
+
return Math.round(Number(match[1]) * unit);
|
|
4743
|
+
}
|
|
4744
|
+
/**
|
|
4564
4745
|
* A journey or template key: the file basename under `journeys/` or
|
|
4565
4746
|
* `emails/`, kebab-case and unique across the project. Becomes part of the
|
|
4566
4747
|
* Temporal workflow id and travels in the journey chain, so it stays short.
|
|
@@ -4581,8 +4762,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
|
|
|
4581
4762
|
*/
|
|
4582
4763
|
const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
|
|
4583
4764
|
/**
|
|
4584
|
-
* A consent purpose key: camelCase, matching the
|
|
4585
|
-
* `
|
|
4765
|
+
* A consent purpose key: camelCase, matching the reserved `marketing` and
|
|
4766
|
+
* `transactional`. Purposes are keys in the `consent` map a customer reads
|
|
4586
4767
|
* on their own profile, which is why they are not the kebab-case of a
|
|
4587
4768
|
* journey key.
|
|
4588
4769
|
*/
|
|
@@ -4597,22 +4778,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
|
|
4597
4778
|
const consentPurposeKeySchema = z.string().max(50, "a purpose key must be at most 50 characters").regex(CONSENT_PURPOSE_KEY_PATTERN, "a consent purpose key must be camelCase (a letter first, then letters and digits)");
|
|
4598
4779
|
/**
|
|
4599
4780
|
* One purpose a project declares in `cow.json` (spec: Decisions). A declared
|
|
4600
|
-
* purpose is marketing-class and sits under the `
|
|
4601
|
-
*
|
|
4602
|
-
*
|
|
4603
|
-
*
|
|
4781
|
+
* purpose is marketing-class and sits under the `marketing` umbrella, so
|
|
4782
|
+
* `denied` is the only default it may carry: the purpose is absent on every
|
|
4783
|
+
* profile that already exists, and a granted default would answer for all of
|
|
4784
|
+
* them at once.
|
|
4604
4785
|
*
|
|
4605
4786
|
* The field stays required rather than disappearing, so every `cow.json` and
|
|
4606
|
-
* every stored manifest written before this still parses
|
|
4607
|
-
*
|
|
4608
|
-
* is a refusal at declaration time, not a narrower storage shape.
|
|
4787
|
+
* every stored manifest written before this still parses: this is a refusal
|
|
4788
|
+
* at declaration time, not a narrower storage shape.
|
|
4609
4789
|
*
|
|
4610
|
-
*
|
|
4611
|
-
*
|
|
4612
|
-
*
|
|
4790
|
+
* Neither reserved purpose can be declared: `marketing` is the master switch
|
|
4791
|
+
* every other project's journeys hang off, and `transactional` is not a
|
|
4792
|
+
* consent purpose at all, so declaring it would promise a switch that no
|
|
4793
|
+
* send ever reads.
|
|
4613
4794
|
*/
|
|
4614
4795
|
const declaredPurposeSchema = z.strictObject({
|
|
4615
|
-
key: consentPurposeKeySchema.refine((key) => !
|
|
4796
|
+
key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
|
|
4616
4797
|
/** What the dashboard and the account modal render beside the switch. */
|
|
4617
4798
|
label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
|
|
4618
4799
|
/** What the purpose means for a profile whose map does not answer it. */
|
|
@@ -4634,18 +4815,26 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
|
|
|
4634
4815
|
const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
|
|
4635
4816
|
/**
|
|
4636
4817
|
* What starts a journey: an event (optionally narrowed to an app id or a
|
|
4637
|
-
* list of them) or
|
|
4638
|
-
* reuses it.
|
|
4639
|
-
*
|
|
4640
|
-
*
|
|
4641
|
-
*
|
|
4642
|
-
*
|
|
4643
|
-
*
|
|
4818
|
+
* list of them), or entry into the segment the trigger itself describes.
|
|
4819
|
+
* The registry DTO in `../journeys` reuses it.
|
|
4820
|
+
*
|
|
4821
|
+
* A segment trigger carries the predicate list, not a name: the push
|
|
4822
|
+
* materializes one segment row per journey that inlines a definition, owned
|
|
4823
|
+
* by the journey and named after its key, so the segment exists because the
|
|
4824
|
+
* journey exists and there is no order to get wrong (ADR 0016). The
|
|
4825
|
+
* definition is data — validated here, carried on the version, never
|
|
4826
|
+
* compiled and never executed.
|
|
4827
|
+
*
|
|
4828
|
+
* Both members are strict, so a journey holding the retired `source` key, or
|
|
4829
|
+
* the retired `{ segment: "name" }` form, fails to build instead of silently
|
|
4830
|
+
* triggering on every app or on nothing. There is deliberately no pipe
|
|
4831
|
+
* filter: a trigger narrows by the app the write is attributed to, the same
|
|
4832
|
+
* token a segment definition names.
|
|
4644
4833
|
*/
|
|
4645
4834
|
const triggerSchema = z.union([z.strictObject({
|
|
4646
4835
|
event: patternSchema,
|
|
4647
4836
|
appId: patternSchema.optional()
|
|
4648
|
-
}), z.strictObject({ segment:
|
|
4837
|
+
}), z.strictObject({ segment: segmentDefinitionSchema })]);
|
|
4649
4838
|
/**
|
|
4650
4839
|
* The address half of a from-header: a local part, an `@`, and a dotted
|
|
4651
4840
|
* domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
|
|
@@ -4748,10 +4937,35 @@ const manifestJourneySchema = z.object({
|
|
|
4748
4937
|
*/
|
|
4749
4938
|
purpose: consentPurposeKeySchema,
|
|
4750
4939
|
/**
|
|
4940
|
+
* How often one recipient may enter. Enrollment derives from the purpose
|
|
4941
|
+
* (ADR 0015), so the only thing an author writes is how long after a
|
|
4942
|
+
* completed run the journey re-opens: absent means once, ever. Refused on
|
|
4943
|
+
* a transactional journey, which enrolls on every trigger — see
|
|
4944
|
+
* `manifestSchema` below, where the cross-field check lives (a refinement
|
|
4945
|
+
* on this object would break the `.pick()` the guest SDK and the guest
|
|
4946
|
+
* protocol both take of it).
|
|
4947
|
+
*/
|
|
4948
|
+
enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
|
|
4949
|
+
try {
|
|
4950
|
+
parseDuration(value);
|
|
4951
|
+
return true;
|
|
4952
|
+
} catch {
|
|
4953
|
+
return false;
|
|
4954
|
+
}
|
|
4955
|
+
}, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
|
|
4956
|
+
/**
|
|
4957
|
+
* The author's own sentence about what this journey does, shown wherever
|
|
4958
|
+
* the journey is read. Capped like a segment's description; absent when
|
|
4959
|
+
* the author wrote none.
|
|
4960
|
+
*/
|
|
4961
|
+
description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
|
|
4962
|
+
/**
|
|
4751
4963
|
* The address every `send.email` in this journey goes out as, unless the
|
|
4752
|
-
* call names its own.
|
|
4753
|
-
*
|
|
4754
|
-
*
|
|
4964
|
+
* call names its own. Required of the author (`defineJourney` types it so,
|
|
4965
|
+
* and `cow build` refuses a journey without it), and still optional here:
|
|
4966
|
+
* releases pushed before ADR 0014 was amended carry none, and this schema
|
|
4967
|
+
* parses stored manifests as well as new ones. A push with no `from` is
|
|
4968
|
+
* refused by the domain gate, which says what to do about it.
|
|
4755
4969
|
*/
|
|
4756
4970
|
from: fromSchema.optional(),
|
|
4757
4971
|
spine: z.array(spineEntrySchema),
|
|
@@ -4764,6 +4978,13 @@ const manifestTemplateSchema = z.object({
|
|
|
4764
4978
|
sendClass: z.enum(SEND_CLASSES),
|
|
4765
4979
|
/** True asks the host to mint a signed `verifyUrl` prop at send time. */
|
|
4766
4980
|
verifyLink: z.boolean(),
|
|
4981
|
+
/**
|
|
4982
|
+
* True asks the host to mint a signed `unsubscribeUrl` prop at send time,
|
|
4983
|
+
* for the author's own footer link. Default false: a marketing send whose
|
|
4984
|
+
* HTML carries no unsubscribe link at all still gets one, appended as a
|
|
4985
|
+
* platform footer, so the link is never the author's to forget.
|
|
4986
|
+
*/
|
|
4987
|
+
unsubscribeLink: z.boolean().default(false),
|
|
4767
4988
|
/** JSON Schema of the template's `props`, converted by `cow build`. */
|
|
4768
4989
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
4769
4990
|
bundle: digestSchema
|
|
@@ -4814,6 +5035,15 @@ const manifestSchema = z.object({
|
|
|
4814
5035
|
uniqueKeys(manifest.journeys, ctx, "journeys");
|
|
4815
5036
|
uniqueKeys(manifest.templates, ctx, "templates");
|
|
4816
5037
|
uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
|
|
5038
|
+
for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
|
|
5039
|
+
code: "custom",
|
|
5040
|
+
message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
|
|
5041
|
+
path: [
|
|
5042
|
+
"journeys",
|
|
5043
|
+
index,
|
|
5044
|
+
"enrollment"
|
|
5045
|
+
]
|
|
5046
|
+
});
|
|
4817
5047
|
});
|
|
4818
5048
|
/**
|
|
4819
5049
|
* One entry of a stored manifest: what a version is a snapshot of. Journeys
|
|
@@ -4877,7 +5107,7 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
|
|
|
4877
5107
|
* `identify` carries it too: the grant has to have a route in through
|
|
4878
5108
|
* ingestion, or the only thing anyone can express is the revocation.
|
|
4879
5109
|
*/
|
|
4880
|
-
const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [
|
|
5110
|
+
const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
|
|
4881
5111
|
/**
|
|
4882
5112
|
* The identify payload fields, shared between the single-call body schema
|
|
4883
5113
|
* and the batch item schema (which drops `sourceId`: a batch names one
|
|
@@ -5523,6 +5753,7 @@ const dnsRecordSchema = z.object({
|
|
|
5523
5753
|
/** One sending domain on the wire: dates become ISO 8601 strings. */
|
|
5524
5754
|
const senderDomainSchema = selectSenderDomainSchema.extend({
|
|
5525
5755
|
dnsRecords: z.array(dnsRecordSchema),
|
|
5756
|
+
challengeVerifiedAt: z.iso.datetime().nullable(),
|
|
5526
5757
|
lastCheckedAt: z.iso.datetime().nullable(),
|
|
5527
5758
|
createdAt: z.iso.datetime(),
|
|
5528
5759
|
updatedAt: z.iso.datetime()
|
|
@@ -5645,29 +5876,6 @@ function patternPlaceholder(pattern) {
|
|
|
5645
5876
|
* a guest returns with these schemas before anything acts on it; the guest
|
|
5646
5877
|
* SDK and the Node simulator produce and consume the same shapes.
|
|
5647
5878
|
*/
|
|
5648
|
-
/** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
|
|
5649
|
-
const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
|
|
5650
|
-
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
|
|
5651
|
-
const DURATION_UNIT_MS = {
|
|
5652
|
-
ms: 1,
|
|
5653
|
-
s: 1e3,
|
|
5654
|
-
m: 6e4,
|
|
5655
|
-
h: 36e5,
|
|
5656
|
-
d: 864e5,
|
|
5657
|
-
w: 6048e5
|
|
5658
|
-
};
|
|
5659
|
-
/**
|
|
5660
|
-
* A duration in milliseconds. The simulator's virtual clock and the runner's
|
|
5661
|
-
* timers both need it, and neither may pull in Temporal's `msToNumber` (one
|
|
5662
|
-
* runs in the CLI, the other inside workflow code).
|
|
5663
|
-
*/
|
|
5664
|
-
function parseDuration(duration) {
|
|
5665
|
-
if (typeof duration === "number") return duration;
|
|
5666
|
-
const match = DURATION_PATTERN.exec(duration.trim());
|
|
5667
|
-
if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
|
|
5668
|
-
const unit = DURATION_UNIT_MS[match[2]];
|
|
5669
|
-
return Math.round(Number(match[1]) * unit);
|
|
5670
|
-
}
|
|
5671
5879
|
/** The event a journey runs for, or waits on: name, properties, and when. */
|
|
5672
5880
|
const guestEventSchema = z.object({
|
|
5673
5881
|
name: z.string().min(1),
|
|
@@ -5707,8 +5915,9 @@ const commandSchema = z.discriminatedUnion("name", [
|
|
|
5707
5915
|
/**
|
|
5708
5916
|
* The address this mail leaves as. The guest SDK fills in the
|
|
5709
5917
|
* journey's own whenever the call does not name one, so the host has
|
|
5710
|
-
* one resolution path and never reads the manifest to find it
|
|
5711
|
-
* absent on both is
|
|
5918
|
+
* one resolution path and never reads the manifest to find it. A
|
|
5919
|
+
* journey must name one, so absent on both is a release pushed before
|
|
5920
|
+
* that was true, and the `domain` gate refuses it.
|
|
5712
5921
|
*/
|
|
5713
5922
|
from: fromSchema.optional(),
|
|
5714
5923
|
/** Where a reply to this one mail goes, instead of the from-address. */
|
|
@@ -5852,11 +6061,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
|
|
|
5852
6061
|
const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
|
|
5853
6062
|
trigger: true,
|
|
5854
6063
|
purpose: true,
|
|
6064
|
+
enrollment: true,
|
|
6065
|
+
description: true,
|
|
5855
6066
|
from: true,
|
|
5856
6067
|
tags: true
|
|
5857
6068
|
}).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
|
|
5858
6069
|
sendClass: true,
|
|
5859
6070
|
verifyLink: true,
|
|
6071
|
+
unsubscribeLink: true,
|
|
5860
6072
|
propsSchema: true,
|
|
5861
6073
|
tags: true
|
|
5862
6074
|
}).extend({ kind: z.literal("template") })]);
|
|
@@ -6086,7 +6298,7 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
|
|
|
6086
6298
|
});
|
|
6087
6299
|
/**
|
|
6088
6300
|
* One key of a project as `cow status` and the MCP `status` tool report it:
|
|
6089
|
-
* the code the server holds for it,
|
|
6301
|
+
* the code the server holds for it, what state it is in, and what is still
|
|
6090
6302
|
* running. Journeys and templates share the shape, because a developer asks
|
|
6091
6303
|
* the same question of both; the two fields only a journey has are null on a
|
|
6092
6304
|
* template.
|
|
@@ -6100,8 +6312,12 @@ const projectStatusKeySchema = z.object({
|
|
|
6100
6312
|
* server holds. Null only for a journey whose versions are all pruned.
|
|
6101
6313
|
*/
|
|
6102
6314
|
latestVersion: versionSchema.nullable(),
|
|
6103
|
-
/**
|
|
6104
|
-
|
|
6315
|
+
/**
|
|
6316
|
+
* The journey's one state; null on a template, which has none. Built from
|
|
6317
|
+
* the column here rather than imported from `journeys.ts`, which reads
|
|
6318
|
+
* this module for its version summary.
|
|
6319
|
+
*/
|
|
6320
|
+
status: z.enum(journeyStatusEnum.enumValues).nullable(),
|
|
6105
6321
|
/** Executions still running or waiting. */
|
|
6106
6322
|
liveExecutions: z.number().int()
|
|
6107
6323
|
});
|
|
@@ -6122,12 +6338,62 @@ const projectStatusSchema = z.object({
|
|
|
6122
6338
|
warnings: z.array(z.string())
|
|
6123
6339
|
});
|
|
6124
6340
|
|
|
6341
|
+
//#endregion
|
|
6342
|
+
//#region ../../packages/shared/src/segments.ts
|
|
6343
|
+
const segmentSchema = selectSegmentSchema.extend({
|
|
6344
|
+
definition: segmentDefinitionSchema,
|
|
6345
|
+
createdAt: z.iso.datetime(),
|
|
6346
|
+
updatedAt: z.iso.datetime()
|
|
6347
|
+
});
|
|
6348
|
+
/** The detail view adds the materialized member count. */
|
|
6349
|
+
const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
|
|
6350
|
+
const segmentMemberSchema = z.object({
|
|
6351
|
+
segmentId: z.string(),
|
|
6352
|
+
orgId: z.string(),
|
|
6353
|
+
profileId: z.string(),
|
|
6354
|
+
enteredAt: z.iso.datetime()
|
|
6355
|
+
});
|
|
6356
|
+
/**
|
|
6357
|
+
* The builder's design-time sanity check: run one not-yet-persisted
|
|
6358
|
+
* definition over the org's existing profiles. Members is a small sample of
|
|
6359
|
+
* matching profile ids, not the full member list: there is no segment row
|
|
6360
|
+
* and no membership entry, so there is no segmentId or enteredAt to report.
|
|
6361
|
+
*
|
|
6362
|
+
* The scan is bounded, so `memberCount` is the exact org-wide count only
|
|
6363
|
+
* when `truncated` is false. When it is true the scan stopped at the cap,
|
|
6364
|
+
* and `memberCount` is the count over the first `scanned` profiles only.
|
|
6365
|
+
*/
|
|
6366
|
+
const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
|
|
6367
|
+
const segmentPreviewSchema = z.object({
|
|
6368
|
+
memberCount: z.number().int().min(0),
|
|
6369
|
+
members: z.array(z.string()),
|
|
6370
|
+
/** Profiles the preview actually evaluated. */
|
|
6371
|
+
scanned: z.number().int().min(0),
|
|
6372
|
+
/** True when the scan hit its cap, so memberCount is a floor. */
|
|
6373
|
+
truncated: z.boolean()
|
|
6374
|
+
});
|
|
6375
|
+
const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
|
|
6376
|
+
const descriptionSchema = z.string().trim().max(500, `description must be at most ${500} characters`);
|
|
6377
|
+
const createSegmentBodySchema = z.object({ data: z.object({
|
|
6378
|
+
name: nameSchema,
|
|
6379
|
+
description: descriptionSchema.nullish(),
|
|
6380
|
+
definition: segmentDefinitionSchema
|
|
6381
|
+
}) });
|
|
6382
|
+
const updateSegmentBodySchema = z.object({ data: z.object({
|
|
6383
|
+
name: nameSchema.optional(),
|
|
6384
|
+
description: descriptionSchema.nullish(),
|
|
6385
|
+
definition: segmentDefinitionSchema.optional()
|
|
6386
|
+
}).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
|
|
6387
|
+
/** `q` is a substring search over the segment's name. */
|
|
6388
|
+
const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
|
|
6389
|
+
const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
|
|
6390
|
+
|
|
6125
6391
|
//#endregion
|
|
6126
6392
|
//#region ../../packages/shared/src/journeys.ts
|
|
6127
6393
|
/**
|
|
6128
6394
|
* Journeys as the API reports them: the rows a push derives from its
|
|
6129
6395
|
* manifest, one per (org, key). Nothing here is authored through the API, so
|
|
6130
|
-
* the only write is the
|
|
6396
|
+
* the only write is the status.
|
|
6131
6397
|
*
|
|
6132
6398
|
* Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
|
|
6133
6399
|
* share one source of truth; the jsonb columns get explicit wire schemas
|
|
@@ -6140,11 +6406,19 @@ const journeyTriggerSchema = triggerSchema;
|
|
|
6140
6406
|
* control flow is whatever its code does at run time.
|
|
6141
6407
|
*/
|
|
6142
6408
|
const journeySpineEntrySchema = spineEntrySchema;
|
|
6409
|
+
/**
|
|
6410
|
+
* The one operational state a manager sets (ADR 0018). `on` lets recipients
|
|
6411
|
+
* enroll and executions run; `off` enrolls nobody and lets what is in flight
|
|
6412
|
+
* finish; `paused` enrolls nobody and holds what is in flight before its next
|
|
6413
|
+
* step. Turning a paused journey on resumes it.
|
|
6414
|
+
*/
|
|
6415
|
+
const JOURNEY_STATUSES = journeyStatusEnum.enumValues;
|
|
6416
|
+
const journeyStatusSchema = z.enum(JOURNEY_STATUSES);
|
|
6143
6417
|
const journeySchema = selectJourneySchema.extend({
|
|
6144
6418
|
trigger: journeyTriggerSchema,
|
|
6145
6419
|
purpose: consentPurposeKeySchema,
|
|
6146
6420
|
spine: z.array(journeySpineEntrySchema),
|
|
6147
|
-
|
|
6421
|
+
status: journeyStatusSchema,
|
|
6148
6422
|
/**
|
|
6149
6423
|
* The newest version of this key, whatever it compiled to, so a list can
|
|
6150
6424
|
* say "pushed 2 hours ago" and "compiling" without a second read. What the
|
|
@@ -6157,14 +6431,24 @@ const journeySchema = selectJourneySchema.extend({
|
|
|
6157
6431
|
updatedAt: z.iso.datetime()
|
|
6158
6432
|
});
|
|
6159
6433
|
/**
|
|
6434
|
+
* One journey, with the segment it owns when its trigger inlines a
|
|
6435
|
+
* definition (ADR 0016): the same row and member count the segments API
|
|
6436
|
+
* reports, so the journey's page can show who is in its segment without a
|
|
6437
|
+
* second concept. Null for an event trigger.
|
|
6438
|
+
*
|
|
6439
|
+
* On the read of one journey only. A list would be one member count per row,
|
|
6440
|
+
* and a list already shows the trigger.
|
|
6441
|
+
*/
|
|
6442
|
+
const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.nullable() });
|
|
6443
|
+
/**
|
|
6160
6444
|
* Journey list query. `q` is a substring search over the key and the tags,
|
|
6161
6445
|
* the two things an author names a journey by; `tag` is "has this tag",
|
|
6162
6446
|
* exact and case-sensitive, so a badge in the table is the way into it.
|
|
6163
6447
|
* Both are server-side, like every other list filter.
|
|
6164
6448
|
*/
|
|
6165
6449
|
const listJourneysQuerySchema = paginationQuerySchema.extend({
|
|
6166
|
-
/** On or
|
|
6167
|
-
|
|
6450
|
+
/** On, off or paused; the journey's one state. */
|
|
6451
|
+
status: journeyStatusSchema.optional(),
|
|
6168
6452
|
q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
|
|
6169
6453
|
tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
|
|
6170
6454
|
});
|
|
@@ -6192,18 +6476,24 @@ const journeyStatsSchema = z.object({
|
|
|
6192
6476
|
deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
|
|
6193
6477
|
});
|
|
6194
6478
|
/**
|
|
6195
|
-
* Which journeys the
|
|
6196
|
-
* journey of one project. Exactly one of the
|
|
6197
|
-
* both would have to decide which it
|
|
6479
|
+
* Which journeys the status acts on, and which state to put them in: the
|
|
6480
|
+
* keys the caller named, or every journey of one project. Exactly one of the
|
|
6481
|
+
* two selectors, because a call carrying both would have to decide which it
|
|
6482
|
+
* meant.
|
|
6198
6483
|
*
|
|
6199
6484
|
* A list rather than one key per call so every surface can do what `cow
|
|
6200
6485
|
* enable` does: name several keys, or `--all` (which is `project`, resolved
|
|
6201
6486
|
* server-side, so an agent turning a project on is one call too).
|
|
6487
|
+
*
|
|
6488
|
+
* One body for three states rather than a route per verb (ADR 0018): the
|
|
6489
|
+
* transitions differ in what they do to what is in flight, not in who may
|
|
6490
|
+
* ask or what they name.
|
|
6202
6491
|
*/
|
|
6203
|
-
const
|
|
6492
|
+
const setJourneysStatusBodySchema = z.object({ data: z.object({
|
|
6204
6493
|
keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
|
|
6205
6494
|
/** Every journey of this project, by the name `cow.json` carries. */
|
|
6206
|
-
project: projectNameSchema.optional()
|
|
6495
|
+
project: projectNameSchema.optional(),
|
|
6496
|
+
status: journeyStatusSchema
|
|
6207
6497
|
}).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
|
|
6208
6498
|
/**
|
|
6209
6499
|
* Dry run: one real profile, the journey's latest ready version, and every
|
|
@@ -6212,6 +6502,106 @@ const setJourneysEnabledBodySchema = z.object({ data: z.object({
|
|
|
6212
6502
|
* now and a v1 instance was a Temporal read.
|
|
6213
6503
|
*/
|
|
6214
6504
|
const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
|
|
6505
|
+
/**
|
|
6506
|
+
* Enrollment (ADR 0015): what turning a journey on would do, or is doing,
|
|
6507
|
+
* to the people already in its audience.
|
|
6508
|
+
*
|
|
6509
|
+
* The same three numbers answer both questions, so they are one schema: a
|
|
6510
|
+
* preview walks the whole segment counting, and the running job counts the
|
|
6511
|
+
* same way as it goes. `scanned` is the segment's current membership,
|
|
6512
|
+
* `enrolled` the ones that entered (or in a preview would), and the skips
|
|
6513
|
+
* say why the rest did not.
|
|
6514
|
+
*/
|
|
6515
|
+
const enrollmentCountsSchema = z.object({
|
|
6516
|
+
scanned: z.number().int(),
|
|
6517
|
+
enrolled: z.number().int(),
|
|
6518
|
+
skipped: z.object({
|
|
6519
|
+
/** They have been through this journey, and it enrolls once. */
|
|
6520
|
+
completed: z.number().int(),
|
|
6521
|
+
/** They finished recently and the journey's cooldown has not elapsed. */
|
|
6522
|
+
cooldown: z.number().int(),
|
|
6523
|
+
/** They are in the journey right now. */
|
|
6524
|
+
running: z.number().int()
|
|
6525
|
+
})
|
|
6526
|
+
});
|
|
6527
|
+
/**
|
|
6528
|
+
* How each skip is said to a person, in the order the surfaces list them.
|
|
6529
|
+
* The confirm screen, the progress card and `cow enable` all report the same
|
|
6530
|
+
* three numbers, and the words for them live here so the three cannot drift:
|
|
6531
|
+
* `tile` names the count on its own, `reason` completes "N ...".
|
|
6532
|
+
*/
|
|
6533
|
+
const ENROLLMENT_SKIP_WORDS = {
|
|
6534
|
+
completed: {
|
|
6535
|
+
tile: "Already been through",
|
|
6536
|
+
reason: "have already been through it"
|
|
6537
|
+
},
|
|
6538
|
+
cooldown: {
|
|
6539
|
+
tile: "Too recently",
|
|
6540
|
+
reason: "went through it too recently"
|
|
6541
|
+
},
|
|
6542
|
+
running: {
|
|
6543
|
+
tile: "In it already",
|
|
6544
|
+
reason: "are in it right now"
|
|
6545
|
+
}
|
|
6546
|
+
};
|
|
6547
|
+
const enrollmentStatusSchema = enrollmentCountsSchema.extend({
|
|
6548
|
+
/**
|
|
6549
|
+
* `running` while the job works, `done` when it finished the segment,
|
|
6550
|
+
* `cancelled` when someone stopped it, `stopped` when the journey itself
|
|
6551
|
+
* stopped being on under it, `failed` when it could not finish. On any of
|
|
6552
|
+
* the three that end early the counts are what it reached, and the people
|
|
6553
|
+
* it already enrolled stay in the journey.
|
|
6554
|
+
*/
|
|
6555
|
+
state: z.enum([
|
|
6556
|
+
"running",
|
|
6557
|
+
"done",
|
|
6558
|
+
"cancelled",
|
|
6559
|
+
"stopped",
|
|
6560
|
+
"failed"
|
|
6561
|
+
]) });
|
|
6562
|
+
/**
|
|
6563
|
+
* What a journey turned on says about the audience it now has waiting.
|
|
6564
|
+
*
|
|
6565
|
+
* It rides on the status response rather than being fetched separately so
|
|
6566
|
+
* that every surface that moves the control sees it: `cow enable` prints it,
|
|
6567
|
+
* and the MCP tool, which is planned from the contract and has no code of
|
|
6568
|
+
* its own, returns it. An agent that cannot start the enrollment can still
|
|
6569
|
+
* tell the person who can how many people it would reach and where to go.
|
|
6570
|
+
*
|
|
6571
|
+
* Null on a journey whose trigger is an event: there is no standing audience
|
|
6572
|
+
* to reach. `counts` is null when the numbers could not be worked out just
|
|
6573
|
+
* then; the journey is on either way, because a count nobody could read is
|
|
6574
|
+
* no reason to leave the switch off.
|
|
6575
|
+
*/
|
|
6576
|
+
const enrollmentOfferSchema = z.object({
|
|
6577
|
+
counts: enrollmentCountsSchema.nullable(),
|
|
6578
|
+
/** The journey's page, where the offer can be accepted. */
|
|
6579
|
+
url: z.string()
|
|
6580
|
+
});
|
|
6581
|
+
/**
|
|
6582
|
+
* A journey as the status route answers, carrying that offer. Null on every
|
|
6583
|
+
* status but `on`: a journey turned off or held reaches nobody, so there is
|
|
6584
|
+
* no audience to offer.
|
|
6585
|
+
*/
|
|
6586
|
+
const journeyWithOfferSchema = journeySchema.extend({ enrollment: enrollmentOfferSchema.nullable() });
|
|
6587
|
+
/**
|
|
6588
|
+
* What holding a journey until a given day would end (ADR 0018). A wait
|
|
6589
|
+
* that comes due while the journey is held ends that person's run, so
|
|
6590
|
+
* before putting one on hold it is worth knowing how many runs that is.
|
|
6591
|
+
*
|
|
6592
|
+
* The day is the caller's, not a fixed horizon: "until tomorrow" and "until
|
|
6593
|
+
* next month" are different decisions, and the numbers behind them are what
|
|
6594
|
+
* tell them apart.
|
|
6595
|
+
*/
|
|
6596
|
+
const waitsDueQuerySchema = z.object({
|
|
6597
|
+
/** Count the waits that come due before this instant. */
|
|
6598
|
+
before: z.iso.datetime() });
|
|
6599
|
+
const waitsDueSchema = z.object({
|
|
6600
|
+
/** Executions in flight whose next step comes due before then. */
|
|
6601
|
+
due: z.number().int(),
|
|
6602
|
+
/** Executions in flight altogether, due or not. */
|
|
6603
|
+
inFlight: z.number().int()
|
|
6604
|
+
});
|
|
6215
6605
|
|
|
6216
6606
|
//#endregion
|
|
6217
6607
|
//#region ../../packages/shared/src/journeys-v2/sandbox.ts
|
|
@@ -6330,7 +6720,7 @@ const RESEND_MAX_TAGS = 50;
|
|
|
6330
6720
|
const recipientSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "Every recipient must be an address (\"ada@acme.com\") or a name and address (\"Ada <ada@acme.com>\")." });
|
|
6331
6721
|
const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
|
|
6332
6722
|
/** Resend takes one address or a list wherever it takes recipients. */
|
|
6333
|
-
const recipientsSchema = z.union([recipientSchema, recipientArraySchema]);
|
|
6723
|
+
const recipientsSchema$1 = z.union([recipientSchema, recipientArraySchema]);
|
|
6334
6724
|
/**
|
|
6335
6725
|
* Resend's wording for an absent required field. Three fields can be
|
|
6336
6726
|
* missing: `to`, `subject`, and a body. Which answer each one gets is read
|
|
@@ -6361,11 +6751,11 @@ const templateSlotSchema = z.strictObject({
|
|
|
6361
6751
|
/** Send body: Resend's fields, minus the ones Cowliss does not serve. */
|
|
6362
6752
|
const resendSendBodySchema = z.strictObject({
|
|
6363
6753
|
/**
|
|
6364
|
-
*
|
|
6365
|
-
*
|
|
6366
|
-
*
|
|
6754
|
+
* Required, exactly as Resend requires it. There is no address to fall
|
|
6755
|
+
* back to: a send leaves from a domain the organization verified, and
|
|
6756
|
+
* nothing else. Missing is its own issue for the same reason `to`'s is.
|
|
6367
6757
|
*/
|
|
6368
|
-
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional(),
|
|
6758
|
+
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional().nonoptional({ error: MISSING("from") }),
|
|
6369
6759
|
/**
|
|
6370
6760
|
* Required, and the absence is its own issue rather than the union's:
|
|
6371
6761
|
* `optional().nonoptional()` lets the value through to the union when
|
|
@@ -6382,9 +6772,9 @@ const resendSendBodySchema = z.strictObject({
|
|
|
6382
6772
|
subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
|
|
6383
6773
|
html: z.string().optional(),
|
|
6384
6774
|
text: z.string().optional(),
|
|
6385
|
-
cc: recipientsSchema.optional(),
|
|
6386
|
-
bcc: recipientsSchema.optional(),
|
|
6387
|
-
reply_to: recipientsSchema.optional(),
|
|
6775
|
+
cc: recipientsSchema$1.optional(),
|
|
6776
|
+
bcc: recipientsSchema$1.optional(),
|
|
6777
|
+
reply_to: recipientsSchema$1.optional(),
|
|
6388
6778
|
headers: z.record(z.string(), z.string()).optional(),
|
|
6389
6779
|
/** Accepted and dropped: Cowliss tags a delivery by its own fields. */
|
|
6390
6780
|
tags: z.array(z.object({
|
|
@@ -6573,133 +6963,71 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
|
|
|
6573
6963
|
});
|
|
6574
6964
|
|
|
6575
6965
|
//#endregion
|
|
6576
|
-
//#region ../../packages/shared/src/
|
|
6966
|
+
//#region ../../packages/shared/src/send-email.ts
|
|
6577
6967
|
/**
|
|
6578
|
-
*
|
|
6579
|
-
*
|
|
6580
|
-
*
|
|
6581
|
-
*
|
|
6582
|
-
*
|
|
6583
|
-
* `appId` scopes the EVENT side of a definition only. Traits are
|
|
6584
|
-
* per-profile and profiles merge across apps, so there is nothing
|
|
6585
|
-
* app-shaped to filter on the trait side.
|
|
6586
|
-
*
|
|
6587
|
-
* A definition and the membership it produces are both the
|
|
6588
|
-
* organization's.
|
|
6968
|
+
* `POST /v1/emails`: the first-party transactional send, the one a
|
|
6969
|
+
* `@cowliss/sdk` client calls. The Resend-compatible facade (ADR 0014) is
|
|
6970
|
+
* the same send in somebody else's wire format; this is ours, so it is
|
|
6971
|
+
* camelCase, enveloped, and answers with our error codes.
|
|
6589
6972
|
*
|
|
6590
|
-
* The
|
|
6591
|
-
*
|
|
6592
|
-
* definition that evaluates over every app. There is deliberately no pipe
|
|
6593
|
-
* filter here — filtering by source is a later feature, and accepting one
|
|
6594
|
-
* now would make a stale `sourceId` parse as a filter on a pipe that does
|
|
6595
|
-
* not exist and silently match nothing.
|
|
6973
|
+
* The limits are deliberately the same numbers as the facade's: two shapes
|
|
6974
|
+
* over one send path must not accept different messages.
|
|
6596
6975
|
*/
|
|
6597
|
-
const
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
|
|
6612
|
-
kind: z.literal("trait"),
|
|
6613
|
-
name: predicateNameSchema,
|
|
6614
|
-
op: z.enum(SEGMENT_TRAIT_OPS),
|
|
6615
|
-
/**
|
|
6616
|
-
* Compared against the stored trait, which is `unknown` because
|
|
6617
|
-
* identify accepts arbitrary JSON. The evaluator coerces both sides
|
|
6618
|
-
* before comparing, so authors here (CLI, MCP, dashboard) get the
|
|
6619
|
-
* form-field-friendly reading rather than strict JSON equality:
|
|
6620
|
-
* numeric-looking strings are compared as numbers for eq/neq and for
|
|
6621
|
-
* ordering ("150" matches 150, and orders like it), and "true"/"false"
|
|
6622
|
-
* are compared as booleans for eq/neq, trimmed and case-insensitively
|
|
6623
|
-
* (" TRUE " reads as true). Coercion needs both sides to agree on a
|
|
6624
|
-
* type: "0" never equals false. The numeric net is as wide as
|
|
6625
|
-
* `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
|
|
6626
|
-
* matters most for opaque ids: "007" is authored as the number 7.
|
|
6627
|
-
*
|
|
6628
|
-
* contains reads three ways. Against an array trait it is membership
|
|
6629
|
-
* under that same equality, so "true" matches `[true]` and "1" matches
|
|
6630
|
-
* `[1, 2]`. Against a string trait it is a plain substring search with
|
|
6631
|
-
* no coercion, since substrings only mean something between strings.
|
|
6632
|
-
* Against anything else it never matches. Ordering never coerces
|
|
6633
|
-
* booleans.
|
|
6634
|
-
*/
|
|
6635
|
-
value: z.unknown().optional()
|
|
6636
|
-
}), z.object({
|
|
6637
|
-
kind: z.literal("event"),
|
|
6638
|
-
name: predicateNameSchema,
|
|
6639
|
-
op: z.enum(["performed", "notPerformed"]),
|
|
6640
|
-
/** How many matching events the predicate counts as "performed". */
|
|
6641
|
-
atLeast: z.number().int().min(1).default(1),
|
|
6642
|
-
/** Rolling window, relative to evaluation time; absent means all history. */
|
|
6643
|
-
withinDays: z.number().int().min(1).optional()
|
|
6644
|
-
})]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
|
|
6645
|
-
const appIdSchema = z.string().trim().min(1);
|
|
6646
|
-
const segmentDefinitionSchema = z.strictObject({
|
|
6647
|
-
match: z.enum(["all", "any"]).default("all"),
|
|
6976
|
+
const addressSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "An address is \"ada@acme.com\" or a name and address, \"Ada <ada@acme.com>\"." });
|
|
6977
|
+
/** One address or a list, wherever a message names recipients. */
|
|
6978
|
+
const recipientsSchema = z.union([addressSchema, z.array(addressSchema).min(1).max(50)]);
|
|
6979
|
+
/** A template this organization pushed, plus the props it declares. */
|
|
6980
|
+
const templateSchema$1 = z.strictObject({
|
|
6981
|
+
key: journeyKeySchema,
|
|
6982
|
+
props: z.record(z.string(), z.unknown()).optional()
|
|
6983
|
+
});
|
|
6984
|
+
const sendEmailDataSchema = z.strictObject({
|
|
6985
|
+
sourceId: z.string(),
|
|
6986
|
+
to: recipientsSchema,
|
|
6987
|
+
cc: recipientsSchema.optional(),
|
|
6988
|
+
bcc: recipientsSchema.optional(),
|
|
6989
|
+
replyTo: recipientsSchema.optional(),
|
|
6648
6990
|
/**
|
|
6649
|
-
*
|
|
6650
|
-
*
|
|
6651
|
-
*
|
|
6991
|
+
* An address on a domain this organization verified. There is no other.
|
|
6992
|
+
* Shape is checked here, the same as the Resend body checks it, so an
|
|
6993
|
+
* address that is not one is a 422 naming the field rather than a 403
|
|
6994
|
+
* telling the caller to verify a domain they never named.
|
|
6652
6995
|
*/
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
6683
|
-
/** Profiles the preview actually evaluated. */
|
|
6684
|
-
scanned: z.number().int().min(0),
|
|
6685
|
-
/** True when the scan hit its cap, so memberCount is a floor. */
|
|
6686
|
-
truncated: z.boolean()
|
|
6996
|
+
from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }),
|
|
6997
|
+
subject: z.string().max(RESEND_MAX_SUBJECT).optional(),
|
|
6998
|
+
html: z.string().optional(),
|
|
6999
|
+
text: z.string().optional(),
|
|
7000
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
7001
|
+
template: templateSchema$1.optional()
|
|
7002
|
+
}).superRefine((body, ctx) => {
|
|
7003
|
+
const hasBody = body.html !== void 0 || body.text !== void 0;
|
|
7004
|
+
if (body.template) {
|
|
7005
|
+
for (const field of [
|
|
7006
|
+
"subject",
|
|
7007
|
+
"html",
|
|
7008
|
+
"text"
|
|
7009
|
+
]) if (body[field] !== void 0) ctx.addIssue({
|
|
7010
|
+
code: "custom",
|
|
7011
|
+
path: [field],
|
|
7012
|
+
message: `A message names a template or writes its own body, not both. Drop \`${field}\`.`
|
|
7013
|
+
});
|
|
7014
|
+
return;
|
|
7015
|
+
}
|
|
7016
|
+
if (!hasBody) ctx.addIssue({
|
|
7017
|
+
code: "custom",
|
|
7018
|
+
path: ["html"],
|
|
7019
|
+
message: "A message needs a template, or an html or text body."
|
|
7020
|
+
});
|
|
7021
|
+
if (body.subject === void 0) ctx.addIssue({
|
|
7022
|
+
code: "custom",
|
|
7023
|
+
path: ["subject"],
|
|
7024
|
+
message: "A message that writes its own body needs a subject."
|
|
7025
|
+
});
|
|
6687
7026
|
});
|
|
6688
|
-
const
|
|
6689
|
-
const
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
description: descriptionSchema.nullish(),
|
|
6693
|
-
definition: segmentDefinitionSchema
|
|
6694
|
-
}) });
|
|
6695
|
-
const updateSegmentBodySchema = z.object({ data: z.object({
|
|
6696
|
-
name: nameSchema.optional(),
|
|
6697
|
-
description: descriptionSchema.nullish(),
|
|
6698
|
-
definition: segmentDefinitionSchema.optional()
|
|
6699
|
-
}).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
|
|
6700
|
-
/** `q` is a substring search over the segment's name. */
|
|
6701
|
-
const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
|
|
6702
|
-
const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
|
|
7027
|
+
const sendEmailBodySchema = z.object({ data: sendEmailDataSchema });
|
|
7028
|
+
const sendEmailResultSchema = z.object({
|
|
7029
|
+
/** The delivery this send left behind: the row in Deliveries. */
|
|
7030
|
+
deliveryId: z.string() });
|
|
6703
7031
|
|
|
6704
7032
|
//#endregion
|
|
6705
7033
|
//#region ../../packages/shared/src/settings.ts
|
|
@@ -6722,9 +7050,9 @@ const orgSettingsDtoSchema = z.object({
|
|
|
6722
7050
|
/**
|
|
6723
7051
|
* Clerk's slug and display name for the org, cached by the API and
|
|
6724
7052
|
* READ-ONLY here: Clerk owns them, and the org profile is edited in
|
|
6725
|
-
* Clerk's own UI. Exposed because
|
|
6726
|
-
*
|
|
6727
|
-
*
|
|
7053
|
+
* Clerk's own UI. Exposed because a template test-send composes its
|
|
7054
|
+
* from-address out of them (`orgFromAddress`). Null until a member has hit
|
|
7055
|
+
* the dashboard API once.
|
|
6728
7056
|
*/
|
|
6729
7057
|
slug: z.string().nullable(),
|
|
6730
7058
|
name: z.string().nullable()
|
|
@@ -6863,6 +7191,12 @@ const templateSchema = z.object({
|
|
|
6863
7191
|
sendClass: z.enum(SEND_CLASSES),
|
|
6864
7192
|
/** True when the template asks for a signed `verifyUrl` prop at send time. */
|
|
6865
7193
|
verifyLink: z.boolean(),
|
|
7194
|
+
/**
|
|
7195
|
+
* True when the template asks for a signed `unsubscribeUrl` prop at send
|
|
7196
|
+
* time. A marketing template without one (or without rendering the link)
|
|
7197
|
+
* gets a platform footer with the link appended at send time instead.
|
|
7198
|
+
*/
|
|
7199
|
+
unsubscribeLink: z.boolean().default(false),
|
|
6866
7200
|
/** JSON Schema of the template's props, as `cow build` converted them. */
|
|
6867
7201
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
6868
7202
|
/** The newest version of this key, whatever it compiled to. */
|
|
@@ -6912,6 +7246,20 @@ const userSegmentSchema = z.object({
|
|
|
6912
7246
|
});
|
|
6913
7247
|
const listUserSegmentsQuerySchema = paginationQuerySchema;
|
|
6914
7248
|
/**
|
|
7249
|
+
* One journey the profile ran to completion
|
|
7250
|
+
* (GET /v1/users/:profileId/runs): which journey it was, when it finished,
|
|
7251
|
+
* and the execution it finished, which is null once that execution is no
|
|
7252
|
+
* longer kept. Paged on (completedAt, id) newest first, so the cursor's
|
|
7253
|
+
* sortAt slot carries the completion time.
|
|
7254
|
+
*/
|
|
7255
|
+
const userRunSchema = z.object({
|
|
7256
|
+
id: z.string(),
|
|
7257
|
+
journeyKey: z.string(),
|
|
7258
|
+
completedAt: z.iso.datetime(),
|
|
7259
|
+
executionId: z.string().nullable()
|
|
7260
|
+
});
|
|
7261
|
+
const listUserRunsQuerySchema = paginationQuerySchema;
|
|
7262
|
+
/**
|
|
6915
7263
|
* Profile list query. `q` is a free-text substring search over the
|
|
6916
7264
|
* profile's identifier values and the identity traits in
|
|
6917
7265
|
* PROFILE_SEARCH_TRAITS; absent means no filter. An empty or
|
|
@@ -7992,8 +8340,13 @@ async function buildProject(projectDir) {
|
|
|
7992
8340
|
const manifestJourneys = [];
|
|
7993
8341
|
const manifestTemplates = [];
|
|
7994
8342
|
for (const built of bundles) {
|
|
7995
|
-
|
|
7996
|
-
|
|
8343
|
+
let report;
|
|
8344
|
+
try {
|
|
8345
|
+
const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
|
|
8346
|
+
report = await runGuest(module, { kind: "manifest" });
|
|
8347
|
+
} catch (error) {
|
|
8348
|
+
throw new Error(`${built.source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
8349
|
+
}
|
|
7997
8350
|
const isJourney = built.source.kind === "journeys";
|
|
7998
8351
|
if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
|
|
7999
8352
|
if (report.kind === "journey") manifestJourneys.push({
|
|
@@ -8001,6 +8354,8 @@ async function buildProject(projectDir) {
|
|
|
8001
8354
|
tags: report.tags,
|
|
8002
8355
|
trigger: report.trigger,
|
|
8003
8356
|
purpose: report.purpose,
|
|
8357
|
+
enrollment: report.enrollment,
|
|
8358
|
+
description: report.description,
|
|
8004
8359
|
from: report.from,
|
|
8005
8360
|
spine: readSpine(await readFile(built.source.file, "utf8")),
|
|
8006
8361
|
bundle: built.digest
|
|
@@ -8010,6 +8365,7 @@ async function buildProject(projectDir) {
|
|
|
8010
8365
|
tags: report.tags,
|
|
8011
8366
|
sendClass: report.sendClass,
|
|
8012
8367
|
verifyLink: report.verifyLink,
|
|
8368
|
+
unsubscribeLink: report.unsubscribeLink,
|
|
8013
8369
|
propsSchema: report.propsSchema,
|
|
8014
8370
|
bundle: built.digest
|
|
8015
8371
|
});
|
|
@@ -8332,7 +8688,7 @@ function openBrowser(url) {
|
|
|
8332
8688
|
//#region src/commands/auth.ts
|
|
8333
8689
|
/** login/logout/whoami: session-token management, no contract route. */
|
|
8334
8690
|
function registerAuth(program, env, io) {
|
|
8335
|
-
program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a
|
|
8691
|
+
program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
|
|
8336
8692
|
const outcome = await login(env, {
|
|
8337
8693
|
token: opts.token,
|
|
8338
8694
|
webUrl: opts.web,
|
|
@@ -9300,11 +9656,11 @@ const domains = defineModule(defineRoute({
|
|
|
9300
9656
|
path: "/v1/domains",
|
|
9301
9657
|
operationId: "domains.create",
|
|
9302
9658
|
tags: ["domains"],
|
|
9303
|
-
summary: "
|
|
9659
|
+
summary: "Add a sending domain",
|
|
9304
9660
|
security: SESSION_AUTH,
|
|
9305
9661
|
request: { body: jsonBody(createSenderDomainBodySchema) },
|
|
9306
9662
|
responses: {
|
|
9307
|
-
201: envelope(senderDomainSchema, "The
|
|
9663
|
+
201: envelope(senderDomainSchema, "The domain with the DNS records to publish, which is what claims it"),
|
|
9308
9664
|
...sessionErrors,
|
|
9309
9665
|
...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
|
|
9310
9666
|
}
|
|
@@ -9357,7 +9713,7 @@ const domains = defineModule(defineRoute({
|
|
|
9357
9713
|
path: "/v1/domains/{id}",
|
|
9358
9714
|
operationId: "domains.delete",
|
|
9359
9715
|
tags: ["domains"],
|
|
9360
|
-
summary: "Give up a domain
|
|
9716
|
+
summary: "Give up a sending domain",
|
|
9361
9717
|
security: SESSION_AUTH,
|
|
9362
9718
|
request: { params: params$9 },
|
|
9363
9719
|
responses: {
|
|
@@ -9369,8 +9725,31 @@ const domains = defineModule(defineRoute({
|
|
|
9369
9725
|
|
|
9370
9726
|
//#endregion
|
|
9371
9727
|
//#region ../../packages/shared/src/contract/emails.ts
|
|
9372
|
-
/**
|
|
9728
|
+
/**
|
|
9729
|
+
* The transactional send a developer's own code makes, plus the public
|
|
9730
|
+
* email-verification pages.
|
|
9731
|
+
*
|
|
9732
|
+
* `emails.send` is the first-party shape of the same send the Resend facade
|
|
9733
|
+
* serves (ADR 0014): one service, two wire formats, and this one answers in
|
|
9734
|
+
* the envelope with our error codes. It authenticates with an org API key
|
|
9735
|
+
* like the rest of the write path, which is why it is hidden from the CLI
|
|
9736
|
+
* and MCP surfaces: those hold a session.
|
|
9737
|
+
*/
|
|
9373
9738
|
const emails = defineModule(defineRoute({
|
|
9739
|
+
method: "post",
|
|
9740
|
+
path: "/v1/emails",
|
|
9741
|
+
operationId: "emails.send",
|
|
9742
|
+
tags: ["emails"],
|
|
9743
|
+
summary: "Send one transactional email",
|
|
9744
|
+
security: API_KEY_AUTH,
|
|
9745
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
9746
|
+
request: { body: jsonBody(sendEmailBodySchema) },
|
|
9747
|
+
responses: {
|
|
9748
|
+
201: envelope(sendEmailResultSchema, "Handed over; the delivery's id"),
|
|
9749
|
+
...ingestionErrors,
|
|
9750
|
+
...errors("forbidden", "not_found", "conflict")
|
|
9751
|
+
}
|
|
9752
|
+
}), defineRoute({
|
|
9374
9753
|
method: "get",
|
|
9375
9754
|
path: "/v1/public/verify",
|
|
9376
9755
|
operationId: "emails.verifyPage",
|
|
@@ -9644,7 +10023,7 @@ const journeys = defineModule(defineRoute({
|
|
|
9644
10023
|
security: SESSION_AUTH,
|
|
9645
10024
|
request: { params: params$7 },
|
|
9646
10025
|
responses: {
|
|
9647
|
-
200: envelope(
|
|
10026
|
+
200: envelope(journeyDetailSchema),
|
|
9648
10027
|
...sessionErrors,
|
|
9649
10028
|
...errors("not_found")
|
|
9650
10029
|
}
|
|
@@ -9663,31 +10042,33 @@ const journeys = defineModule(defineRoute({
|
|
|
9663
10042
|
}
|
|
9664
10043
|
}), defineRoute({
|
|
9665
10044
|
method: "post",
|
|
9666
|
-
path: "/v1/journeys/
|
|
9667
|
-
operationId: "journeys.
|
|
10045
|
+
path: "/v1/journeys/status",
|
|
10046
|
+
operationId: "journeys.setStatus",
|
|
9668
10047
|
tags: ["journeys"],
|
|
9669
|
-
summary: "
|
|
10048
|
+
summary: "Put journeys on, off or on hold",
|
|
9670
10049
|
security: PIPELINE_AUTH,
|
|
9671
10050
|
surfaces: { cli: false },
|
|
9672
|
-
request: { body: jsonBody(
|
|
10051
|
+
request: { body: jsonBody(setJourneysStatusBodySchema) },
|
|
9673
10052
|
responses: {
|
|
9674
|
-
200: envelope(z.array(
|
|
10053
|
+
200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
|
|
9675
10054
|
...sessionErrors,
|
|
9676
10055
|
...errors("not_found", "project_missing", "validation_failed", "malformed_request")
|
|
9677
10056
|
}
|
|
9678
10057
|
}), defineRoute({
|
|
9679
|
-
method: "
|
|
9680
|
-
path: "/v1/journeys/
|
|
9681
|
-
operationId: "journeys.
|
|
10058
|
+
method: "get",
|
|
10059
|
+
path: "/v1/journeys/{key}/waits",
|
|
10060
|
+
operationId: "journeys.waitsDue",
|
|
9682
10061
|
tags: ["journeys"],
|
|
9683
|
-
summary: "
|
|
9684
|
-
security:
|
|
9685
|
-
|
|
9686
|
-
|
|
10062
|
+
summary: "Count the executions a hold until a given day would end",
|
|
10063
|
+
security: SESSION_AUTH,
|
|
10064
|
+
request: {
|
|
10065
|
+
params: params$7,
|
|
10066
|
+
query: waitsDueQuerySchema
|
|
10067
|
+
},
|
|
9687
10068
|
responses: {
|
|
9688
|
-
200: envelope(
|
|
10069
|
+
200: envelope(waitsDueSchema, "Executions in flight, and how many of them come due before that instant"),
|
|
9689
10070
|
...sessionErrors,
|
|
9690
|
-
...errors("not_found", "
|
|
10071
|
+
...errors("not_found", "validation_failed")
|
|
9691
10072
|
}
|
|
9692
10073
|
}), defineRoute({
|
|
9693
10074
|
method: "delete",
|
|
@@ -9702,6 +10083,59 @@ const journeys = defineModule(defineRoute({
|
|
|
9702
10083
|
...sessionErrors,
|
|
9703
10084
|
...errors("not_found")
|
|
9704
10085
|
}
|
|
10086
|
+
}), defineRoute({
|
|
10087
|
+
method: "get",
|
|
10088
|
+
path: "/v1/journeys/{key}/enrollment/preview",
|
|
10089
|
+
operationId: "journeys.enrollmentPreview",
|
|
10090
|
+
tags: ["journeys"],
|
|
10091
|
+
summary: "Count who turning this journey on would reach",
|
|
10092
|
+
security: SESSION_AUTH,
|
|
10093
|
+
request: { params: params$7 },
|
|
10094
|
+
responses: {
|
|
10095
|
+
200: envelope(enrollmentCountsSchema, "What enrolling the journey's current members would do"),
|
|
10096
|
+
...sessionErrors,
|
|
10097
|
+
...errors("not_found", "dependency_unavailable")
|
|
10098
|
+
}
|
|
10099
|
+
}), defineRoute({
|
|
10100
|
+
method: "post",
|
|
10101
|
+
path: "/v1/journeys/{key}/enrollment",
|
|
10102
|
+
operationId: "journeys.startEnrollment",
|
|
10103
|
+
tags: ["journeys"],
|
|
10104
|
+
summary: "Enroll the journey's current members",
|
|
10105
|
+
security: SESSION_AUTH,
|
|
10106
|
+
surfaces: HIDDEN_FROM_TOOLS,
|
|
10107
|
+
request: { params: params$7 },
|
|
10108
|
+
responses: {
|
|
10109
|
+
202: envelope(enrollmentStatusSchema, "The job, as it starts"),
|
|
10110
|
+
...sessionErrors,
|
|
10111
|
+
...errors("not_found")
|
|
10112
|
+
}
|
|
10113
|
+
}), defineRoute({
|
|
10114
|
+
method: "get",
|
|
10115
|
+
path: "/v1/journeys/{key}/enrollment",
|
|
10116
|
+
operationId: "journeys.enrollmentStatus",
|
|
10117
|
+
tags: ["journeys"],
|
|
10118
|
+
summary: "How far the journey's enrollment has got",
|
|
10119
|
+
security: SESSION_AUTH,
|
|
10120
|
+
request: { params: params$7 },
|
|
10121
|
+
responses: {
|
|
10122
|
+
200: envelope(enrollmentStatusSchema),
|
|
10123
|
+
...sessionErrors,
|
|
10124
|
+
...errors("not_found")
|
|
10125
|
+
}
|
|
10126
|
+
}), defineRoute({
|
|
10127
|
+
method: "post",
|
|
10128
|
+
path: "/v1/journeys/{key}/enrollment/cancel",
|
|
10129
|
+
operationId: "journeys.cancelEnrollment",
|
|
10130
|
+
tags: ["journeys"],
|
|
10131
|
+
summary: "Stop a running enrollment",
|
|
10132
|
+
security: SESSION_AUTH,
|
|
10133
|
+
request: { params: params$7 },
|
|
10134
|
+
responses: {
|
|
10135
|
+
200: envelope(enrollmentStatusSchema, "The job as it stood when the stop was asked for"),
|
|
10136
|
+
...sessionErrors,
|
|
10137
|
+
...errors("not_found")
|
|
10138
|
+
}
|
|
9705
10139
|
}), defineRoute({
|
|
9706
10140
|
method: "post",
|
|
9707
10141
|
path: "/v1/journeys/{key}/dry-run",
|
|
@@ -10080,7 +10514,7 @@ const segments = defineModule(defineRoute({
|
|
|
10080
10514
|
responses: {
|
|
10081
10515
|
200: envelope(segmentDetailSchema),
|
|
10082
10516
|
...sessionErrors,
|
|
10083
|
-
...errors("not_found"),
|
|
10517
|
+
...errors("not_found", "conflict"),
|
|
10084
10518
|
...writeErrors
|
|
10085
10519
|
}
|
|
10086
10520
|
}), defineRoute({
|
|
@@ -10094,7 +10528,7 @@ const segments = defineModule(defineRoute({
|
|
|
10094
10528
|
responses: {
|
|
10095
10529
|
200: envelope(deletedSchema),
|
|
10096
10530
|
...sessionErrors,
|
|
10097
|
-
...errors("not_found")
|
|
10531
|
+
...errors("not_found", "conflict")
|
|
10098
10532
|
}
|
|
10099
10533
|
}), defineRoute({
|
|
10100
10534
|
method: "get",
|
|
@@ -10567,6 +11001,23 @@ const users = defineModule(defineRoute({
|
|
|
10567
11001
|
...sessionErrors,
|
|
10568
11002
|
...errors("not_found", "validation_failed")
|
|
10569
11003
|
}
|
|
11004
|
+
}), defineRoute({
|
|
11005
|
+
method: "get",
|
|
11006
|
+
path: "/v1/users/{profileId}/runs",
|
|
11007
|
+
operationId: "users.listRuns",
|
|
11008
|
+
tags: ["users"],
|
|
11009
|
+
summary: "The journeys a profile has completed",
|
|
11010
|
+
security: SESSION_AUTH,
|
|
11011
|
+
request: {
|
|
11012
|
+
params: params$2,
|
|
11013
|
+
query: listUserRunsQuerySchema
|
|
11014
|
+
},
|
|
11015
|
+
responses: {
|
|
11016
|
+
200: list(userRunSchema),
|
|
11017
|
+
...mergedRedirect,
|
|
11018
|
+
...sessionErrors,
|
|
11019
|
+
...errors("not_found", "validation_failed")
|
|
11020
|
+
}
|
|
10570
11021
|
}));
|
|
10571
11022
|
|
|
10572
11023
|
//#endregion
|
|
@@ -11120,29 +11571,65 @@ function registerContractCommands(program, run) {
|
|
|
11120
11571
|
//#endregion
|
|
11121
11572
|
//#region src/commands/enable.ts
|
|
11122
11573
|
/**
|
|
11123
|
-
* `cow enable` and `cow
|
|
11124
|
-
* 0013). Hand-written rather than derived from
|
|
11125
|
-
* the commands are top-level names a developer
|
|
11126
|
-
* takes the same set of keys they do.
|
|
11574
|
+
* `cow enable`, `cow disable` and `cow pause`: the one gate on a journey
|
|
11575
|
+
* (ADR 0011, ADR 0013, ADR 0018). Hand-written rather than derived from
|
|
11576
|
+
* `journeys.setStatus`, because the commands are top-level names a developer
|
|
11577
|
+
* types; the route itself takes the same set of keys they do.
|
|
11578
|
+
*
|
|
11579
|
+
* `cow enable` on a journey on hold is how it resumes: the server has one
|
|
11580
|
+
* transition to `on` and no second word for it.
|
|
11127
11581
|
*/
|
|
11128
11582
|
/**
|
|
11129
11583
|
* One call, whichever way the keys were named: the route takes the set, and
|
|
11130
11584
|
* `--all` is the project name, resolved on the server against the rows it
|
|
11131
11585
|
* owns (so it reaches a key whose file the tree lost). A key that is not
|
|
11132
|
-
* there refuses the whole call, and nothing
|
|
11133
|
-
*/
|
|
11134
|
-
async function
|
|
11135
|
-
|
|
11136
|
-
|
|
11586
|
+
* there refuses the whole call, and nothing changes.
|
|
11587
|
+
*/
|
|
11588
|
+
async function setStatus(client, selector, status) {
|
|
11589
|
+
return (await client.request(contract.journeys["journeys.setStatus"], { body: {
|
|
11590
|
+
...selector,
|
|
11591
|
+
status
|
|
11592
|
+
} })).data;
|
|
11593
|
+
}
|
|
11594
|
+
/**
|
|
11595
|
+
* Who is already standing in a journey that was just turned on, and where
|
|
11596
|
+
* to let them in (ADR 0015). Printed, never acted on: enrolling a cohort
|
|
11597
|
+
* spends real money and reaches real people, so it stays a person's click
|
|
11598
|
+
* in the dashboard and no command here starts one.
|
|
11599
|
+
*/
|
|
11600
|
+
function segmentLines(journey) {
|
|
11601
|
+
const offer = journey.enrollment;
|
|
11602
|
+
if (!offer) return [];
|
|
11603
|
+
if (!offer.counts) return [` Who is in its segment could not be counted just now: ${offer.url}`];
|
|
11604
|
+
const { scanned, enrolled, skipped } = offer.counts;
|
|
11605
|
+
if (scanned === 0) return [" Nobody is in its segment yet."];
|
|
11606
|
+
return [
|
|
11607
|
+
` ${scanned} in its segment now, ${enrolled} of them can enter it now.`,
|
|
11608
|
+
...Object.entries(ENROLLMENT_SKIP_WORDS).map(([key, words]) => ({
|
|
11609
|
+
value: skipped[key],
|
|
11610
|
+
words
|
|
11611
|
+
})).filter(({ value }) => value > 0).map(({ value, words }) => ` ${value} ${words.reason}.`),
|
|
11612
|
+
...enrolled > 0 ? [` To let those ${enrolled} in: ${offer.url}`] : []
|
|
11613
|
+
];
|
|
11137
11614
|
}
|
|
11615
|
+
/** How the terminal names each state. */
|
|
11616
|
+
const STATE_WORD = {
|
|
11617
|
+
on: "on",
|
|
11618
|
+
off: "off",
|
|
11619
|
+
paused: "on hold"
|
|
11620
|
+
};
|
|
11138
11621
|
/** What the terminal says: one line per key, naming its new state. */
|
|
11139
|
-
function flipSummary(journeys,
|
|
11140
|
-
if (journeys.length === 0) return `This project has no journeys to
|
|
11141
|
-
return journeys.map((journey) => `${journey.key} is ${
|
|
11622
|
+
function flipSummary(journeys, status) {
|
|
11623
|
+
if (journeys.length === 0) return `This project has no journeys to put ${STATE_WORD[status]}.`;
|
|
11624
|
+
return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
|
|
11142
11625
|
}
|
|
11143
|
-
|
|
11144
|
-
|
|
11145
|
-
|
|
11626
|
+
const DESCRIPTIONS = {
|
|
11627
|
+
on: "turn journeys on",
|
|
11628
|
+
off: "turn journeys off",
|
|
11629
|
+
paused: "hold journeys: nobody enters, and everyone part-way through waits"
|
|
11630
|
+
};
|
|
11631
|
+
function register(program, clientFor, io, verb, status) {
|
|
11632
|
+
program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
|
|
11146
11633
|
const merged = {
|
|
11147
11634
|
...program.opts(),
|
|
11148
11635
|
...opts
|
|
@@ -11150,17 +11637,18 @@ function register(program, clientFor, io, enabled) {
|
|
|
11150
11637
|
if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
|
|
11151
11638
|
if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
|
|
11152
11639
|
const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
|
|
11153
|
-
const updated = await
|
|
11640
|
+
const updated = await setStatus(await clientFor(merged), selector, status);
|
|
11154
11641
|
if (merged.json === true) {
|
|
11155
11642
|
emit({ data: updated }, io, true);
|
|
11156
11643
|
return;
|
|
11157
11644
|
}
|
|
11158
|
-
io.stdout(`${flipSummary(updated,
|
|
11645
|
+
io.stdout(`${flipSummary(updated, status)}\n`);
|
|
11159
11646
|
});
|
|
11160
11647
|
}
|
|
11161
11648
|
function registerEnable(program, clientFor, io) {
|
|
11162
|
-
register(program, clientFor, io,
|
|
11163
|
-
register(program, clientFor, io,
|
|
11649
|
+
register(program, clientFor, io, "enable", "on");
|
|
11650
|
+
register(program, clientFor, io, "disable", "off");
|
|
11651
|
+
register(program, clientFor, io, "pause", "paused");
|
|
11164
11652
|
}
|
|
11165
11653
|
|
|
11166
11654
|
//#endregion
|
|
@@ -11699,8 +12187,7 @@ function versionPhrase(entry, now) {
|
|
|
11699
12187
|
return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
|
|
11700
12188
|
}
|
|
11701
12189
|
function flagsPhrase(entry) {
|
|
11702
|
-
|
|
11703
|
-
return entry.enabled ? "on" : "off";
|
|
12190
|
+
return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
|
|
11704
12191
|
}
|
|
11705
12192
|
function livePhrase(entry) {
|
|
11706
12193
|
return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
|