waha-shared 1.0.527 → 1.0.529
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/data/bibleAudios/bibleAudios.json +22 -14
- package/dist/data/bibleTexts/bibleTexts.json +35 -32
- package/dist/data/languages/index.d.ts +1 -0
- package/dist/data/languages/languages.json +23 -0
- package/dist/data/languages/languages.schema.json +5 -0
- package/dist/data/languages/languages.zod.d.ts +12 -0
- package/dist/data/languages/languages.zod.js +1 -0
- package/dist/data/mediaDurations/mediaDurations.json +17 -0
- package/dist/data/translationsApp/index.d.ts +1 -0
- package/dist/data/translationsApp/translationsApp.json +45 -0
- package/dist/data/translationsApp/translationsApp.schema.json +1 -0
- package/dist/data/translationsApp/translationsApp.zod.d.ts +2 -0
- package/dist/data/translationsApp/translationsApp.zod.js +1 -0
- package/dist/data/translationsIntroduction/translationsIntroduction.json +42 -0
- package/dist/data/translationsSet/translationsSet.json +1 -0
- package/dist/functions/resend.d.ts +153 -0
- package/dist/functions/resend.js +329 -0
- package/dist/functions/utils.d.ts +1 -0
- package/dist/functions/utils.js +4 -0
- package/dist/types/dripSequence.d.ts +1 -5
- package/dist/types/dripSequence.js +1 -1
- package/dist/types/languages.d.ts +6 -0
- package/dist/types/logs.d.ts +2 -2
- package/dist/types/resend.d.ts +60 -0
- package/dist/types/resend.js +69 -0
- package/dist/types/users.d.ts +6 -6
- package/dist/types/users.js +5 -1
- package/package.json +4 -3
- package/dist/functions/activeCampaign.d.ts +0 -26
- package/dist/functions/activeCampaign.js +0 -119
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { type ErrorResponse as ResendErrorResponse } from 'resend';
|
|
2
|
+
import { type DripEmailId, type DripLanguageId, type DripSequenceId } from '../types/resend';
|
|
3
|
+
/**
|
|
4
|
+
* Rate limits and server errors. Network failures (no status code) aren't
|
|
5
|
+
* retried: the request may have landed, and repeating a create or an event
|
|
6
|
+
* send could start an automation twice.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isRetryable(error: Pick<ResendErrorResponse, 'statusCode'>): boolean;
|
|
9
|
+
/** From: https://resend.com/audience/topics */
|
|
10
|
+
export declare enum Topics {
|
|
11
|
+
Newsletter = "83d566a5-a42d-47e9-85e7-b43fb5b10298"
|
|
12
|
+
}
|
|
13
|
+
export declare enum Segments {
|
|
14
|
+
/**
|
|
15
|
+
* App donors who asked for the thank-you sequence (plus donors carried over
|
|
16
|
+
* from AC).
|
|
17
|
+
*/
|
|
18
|
+
Donor = "74ee171a-f00b-4a10-94b6-ef8508635ee1",
|
|
19
|
+
/** Everyone who created an app account (enrolled in the onboarding drip). */
|
|
20
|
+
AppAccounts = "07e0f635-7e85-4d96-9c31-12a7e34f6869"
|
|
21
|
+
}
|
|
22
|
+
/** From: https://resend.com/automations/events */
|
|
23
|
+
export declare enum Events {
|
|
24
|
+
AppDonorSequence = "donor_thank_you_sequence",
|
|
25
|
+
NewsletterWelcome = "newsletter_welcome"
|
|
26
|
+
}
|
|
27
|
+
/** From: https://resend.com/audience/properties */
|
|
28
|
+
export declare enum Properties {
|
|
29
|
+
/** Set by the donor automation when it runs, so it runs once per contact. */
|
|
30
|
+
AppDonorSequence = "donor_thank_you_sequence",
|
|
31
|
+
/**
|
|
32
|
+
* Set by the welcome automation when it runs, so it runs once per contact.
|
|
33
|
+
*/
|
|
34
|
+
NewsletterWelcome = "newsletter_welcome",
|
|
35
|
+
/** The onboarding drip a contact was enrolled in, so it runs once each. */
|
|
36
|
+
NewAccountSequence = "drip_sequence",
|
|
37
|
+
/** The language a contact's onboarding drip was sent in. */
|
|
38
|
+
Language = "language"
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Adds a language id to a base id to create a unique drip identifier. A
|
|
42
|
+
* sequence's id gives its trigger event (and automation) name; an email's id
|
|
43
|
+
* gives its template alias.
|
|
44
|
+
*/
|
|
45
|
+
export declare function dripId(baseId: DripSequenceId | DripEmailId, languageId: DripLanguageId): string;
|
|
46
|
+
export declare function isDripLanguage(value: string): value is DripLanguageId;
|
|
47
|
+
/** Splits a single "name" form field into first and last name. */
|
|
48
|
+
export declare function splitName(name?: string): {
|
|
49
|
+
firstName?: string;
|
|
50
|
+
lastName?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Returns the existing contact for an email, or creates one. Existing contacts
|
|
54
|
+
* are returned untouched, so their newsletter choice and account-wide
|
|
55
|
+
* unsubscribe are never overridden here. New contacts start off the
|
|
56
|
+
* newsletter.
|
|
57
|
+
*/
|
|
58
|
+
export declare function upsertResendContact({ token, email, name, }: {
|
|
59
|
+
token: string;
|
|
60
|
+
email: string;
|
|
61
|
+
name?: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
id: string;
|
|
64
|
+
created: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Creates a contact for an email the caller already knows isn't one, skipping
|
|
68
|
+
* the lookup {@link upsertResendContact} makes. The contact starts off the
|
|
69
|
+
* newsletter. Returns the new contact's id.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createResendContact({ token, email, name, }: {
|
|
72
|
+
token: string;
|
|
73
|
+
email: string;
|
|
74
|
+
name?: string;
|
|
75
|
+
}): Promise<string>;
|
|
76
|
+
/**
|
|
77
|
+
* Every contact's email, lowercased, or only those in one segment when
|
|
78
|
+
* `segmentId` is given. Read-only.
|
|
79
|
+
*/
|
|
80
|
+
export declare function listContactEmails(token: string, segmentId?: string): Promise<Set<string>>;
|
|
81
|
+
/** Every segment's id and name. Read-only. */
|
|
82
|
+
export declare function listSegments(token: string): Promise<{
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
}[]>;
|
|
86
|
+
/** Creates a segment and returns its id. */
|
|
87
|
+
export declare function createSegment(token: string, name: string): Promise<string>;
|
|
88
|
+
/**
|
|
89
|
+
* Adds or removes the contact for an email in a segment. Adding someone already
|
|
90
|
+
* in it is harmless, and so is removing someone who isn't.
|
|
91
|
+
*/
|
|
92
|
+
export declare function setSegmentMembership({ token, email, segmentId, member, }: {
|
|
93
|
+
token: string;
|
|
94
|
+
email: string;
|
|
95
|
+
segmentId: string;
|
|
96
|
+
member: boolean;
|
|
97
|
+
}): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Deletes the contact for an email, taking its topic and segment memberships
|
|
100
|
+
* with it. A missing contact is already deleted, so it isn't an error.
|
|
101
|
+
*/
|
|
102
|
+
export declare function deleteResendContact({ token, email, }: {
|
|
103
|
+
token: string;
|
|
104
|
+
email: string;
|
|
105
|
+
}): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* An explicit newsletter signup. This is fresh consent, so it opts the contact
|
|
108
|
+
* back in even if they opted out of the newsletter or unsubscribed from
|
|
109
|
+
* everything before. Bounce suppressions still apply. Also triggers the welcome
|
|
110
|
+
* email, which the automation sends only once per contact.
|
|
111
|
+
*/
|
|
112
|
+
export declare function subscribeToNewsletter({ token, email, name, }: {
|
|
113
|
+
token: string;
|
|
114
|
+
email: string;
|
|
115
|
+
name?: string;
|
|
116
|
+
}): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Starts the donor thank-you sequence for a donor who asked for it and adds
|
|
119
|
+
* them to the Donor segment. A donor who ticked the newsletter box is also
|
|
120
|
+
* subscribed to it, without the welcome email. Donors who don't ask for the
|
|
121
|
+
* sequence aren't added to Resend at all.
|
|
122
|
+
*/
|
|
123
|
+
export declare function enrollInDonorSequence({ token, email, name, newsletter, }: {
|
|
124
|
+
token: string;
|
|
125
|
+
email: string;
|
|
126
|
+
name?: string;
|
|
127
|
+
newsletter?: boolean;
|
|
128
|
+
}): Promise<void>;
|
|
129
|
+
export type DripEnrollment = {
|
|
130
|
+
status: 'enrolled';
|
|
131
|
+
} | {
|
|
132
|
+
status: 'already_enrolled';
|
|
133
|
+
sequence: string;
|
|
134
|
+
};
|
|
135
|
+
export interface EnrollInDripSequenceParams {
|
|
136
|
+
token: string;
|
|
137
|
+
email: string;
|
|
138
|
+
firstName?: string;
|
|
139
|
+
sequence: DripSequenceId;
|
|
140
|
+
languageId: DripLanguageId;
|
|
141
|
+
/** Whether the user ticked the newsletter box when signing in. */
|
|
142
|
+
newsletter?: boolean;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Enrolls an app user in a localized drip sequence: adds them to the App
|
|
146
|
+
* Accounts segment and fires the event that starts that sequence and
|
|
147
|
+
* language's automation (provisioned by waha-website's
|
|
148
|
+
* `scripts/emailDrip/push.ts`). A contact already enrolled in any sequence is
|
|
149
|
+
* left alone, so each person gets at most one drip. A user who ticked the
|
|
150
|
+
* newsletter box is also subscribed to it: without the welcome email when
|
|
151
|
+
* their drip starts now, and as an explicit signup when it already ran.
|
|
152
|
+
*/
|
|
153
|
+
export declare function enrollInDripSequence({ token, email, firstName, sequence, languageId, newsletter, }: EnrollInDripSequenceParams): Promise<DripEnrollment>;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Properties = exports.Events = exports.Segments = exports.Topics = void 0;
|
|
4
|
+
exports.isRetryable = isRetryable;
|
|
5
|
+
exports.dripId = dripId;
|
|
6
|
+
exports.isDripLanguage = isDripLanguage;
|
|
7
|
+
exports.splitName = splitName;
|
|
8
|
+
exports.upsertResendContact = upsertResendContact;
|
|
9
|
+
exports.createResendContact = createResendContact;
|
|
10
|
+
exports.listContactEmails = listContactEmails;
|
|
11
|
+
exports.listSegments = listSegments;
|
|
12
|
+
exports.createSegment = createSegment;
|
|
13
|
+
exports.setSegmentMembership = setSegmentMembership;
|
|
14
|
+
exports.deleteResendContact = deleteResendContact;
|
|
15
|
+
exports.subscribeToNewsletter = subscribeToNewsletter;
|
|
16
|
+
exports.enrollInDonorSequence = enrollInDonorSequence;
|
|
17
|
+
exports.enrollInDripSequence = enrollInDripSequence;
|
|
18
|
+
const resend_1 = require("resend");
|
|
19
|
+
const resend_2 = require("../types/resend");
|
|
20
|
+
const utils_1 = require("./utils");
|
|
21
|
+
/**
|
|
22
|
+
* Rate limits and server errors. Network failures (no status code) aren't
|
|
23
|
+
* retried: the request may have landed, and repeating a create or an event
|
|
24
|
+
* send could start an automation twice.
|
|
25
|
+
*/
|
|
26
|
+
function isRetryable(error) {
|
|
27
|
+
const status = error.statusCode;
|
|
28
|
+
return status !== null && (status === 429 || status >= 500);
|
|
29
|
+
}
|
|
30
|
+
/** Makes a Resend call, retrying transient failures twice with backoff. */
|
|
31
|
+
async function withRetry(call) {
|
|
32
|
+
for (let attempt = 1;; attempt++) {
|
|
33
|
+
const result = await call();
|
|
34
|
+
if (!result.error || !isRetryable(result.error) || attempt === 3)
|
|
35
|
+
return result;
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 500));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The Resend SDK resolves to `{ data, error }` instead of throwing, so an
|
|
41
|
+
* unchecked call fails silently and the next step gets `undefined`.
|
|
42
|
+
*/
|
|
43
|
+
function verify(result, context) {
|
|
44
|
+
if (result.error)
|
|
45
|
+
throw new Error(`${context}: ${result.error.message}`);
|
|
46
|
+
if (!result.data)
|
|
47
|
+
throw new Error(`${context}: Resend returned no data.`);
|
|
48
|
+
return result.data;
|
|
49
|
+
}
|
|
50
|
+
/** From: https://resend.com/audience/topics */
|
|
51
|
+
var Topics;
|
|
52
|
+
(function (Topics) {
|
|
53
|
+
Topics["Newsletter"] = "83d566a5-a42d-47e9-85e7-b43fb5b10298";
|
|
54
|
+
})(Topics || (exports.Topics = Topics = {}));
|
|
55
|
+
/* From: https://resend.com/audience/segments */
|
|
56
|
+
var Segments;
|
|
57
|
+
(function (Segments) {
|
|
58
|
+
/**
|
|
59
|
+
* App donors who asked for the thank-you sequence (plus donors carried over
|
|
60
|
+
* from AC).
|
|
61
|
+
*/
|
|
62
|
+
Segments["Donor"] = "74ee171a-f00b-4a10-94b6-ef8508635ee1";
|
|
63
|
+
/** Everyone who created an app account (enrolled in the onboarding drip). */
|
|
64
|
+
Segments["AppAccounts"] = "07e0f635-7e85-4d96-9c31-12a7e34f6869";
|
|
65
|
+
})(Segments || (exports.Segments = Segments = {}));
|
|
66
|
+
/** From: https://resend.com/automations/events */
|
|
67
|
+
var Events;
|
|
68
|
+
(function (Events) {
|
|
69
|
+
Events["AppDonorSequence"] = "donor_thank_you_sequence";
|
|
70
|
+
Events["NewsletterWelcome"] = "newsletter_welcome";
|
|
71
|
+
// Drip sequence events are per language and thus generated dynamically, so
|
|
72
|
+
// they aren't in this enum.
|
|
73
|
+
})(Events || (exports.Events = Events = {}));
|
|
74
|
+
/** From: https://resend.com/audience/properties */
|
|
75
|
+
var Properties;
|
|
76
|
+
(function (Properties) {
|
|
77
|
+
/** Set by the donor automation when it runs, so it runs once per contact. */
|
|
78
|
+
Properties["AppDonorSequence"] = "donor_thank_you_sequence";
|
|
79
|
+
/**
|
|
80
|
+
* Set by the welcome automation when it runs, so it runs once per contact.
|
|
81
|
+
*/
|
|
82
|
+
Properties["NewsletterWelcome"] = "newsletter_welcome";
|
|
83
|
+
/** The onboarding drip a contact was enrolled in, so it runs once each. */
|
|
84
|
+
Properties["NewAccountSequence"] = "drip_sequence";
|
|
85
|
+
/** The language a contact's onboarding drip was sent in. */
|
|
86
|
+
Properties["Language"] = "language";
|
|
87
|
+
})(Properties || (exports.Properties = Properties = {}));
|
|
88
|
+
/**
|
|
89
|
+
* Adds a language id to a base id to create a unique drip identifier. A
|
|
90
|
+
* sequence's id gives its trigger event (and automation) name; an email's id
|
|
91
|
+
* gives its template alias.
|
|
92
|
+
*/
|
|
93
|
+
function dripId(baseId, languageId) {
|
|
94
|
+
return `${baseId}_${languageId}`;
|
|
95
|
+
}
|
|
96
|
+
function isDripLanguage(value) {
|
|
97
|
+
return resend_2.DRIP_LANGUAGES.includes(value);
|
|
98
|
+
}
|
|
99
|
+
/** Splits a single "name" form field into first and last name. */
|
|
100
|
+
function splitName(name) {
|
|
101
|
+
const [firstName, ...rest] = (name ?? '').trim().split(/\s+/);
|
|
102
|
+
return {
|
|
103
|
+
firstName: firstName || undefined,
|
|
104
|
+
lastName: rest.join(' ') || undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Returns the existing contact for an email, or creates one. Existing contacts
|
|
109
|
+
* are returned untouched, so their newsletter choice and account-wide
|
|
110
|
+
* unsubscribe are never overridden here. New contacts start off the
|
|
111
|
+
* newsletter.
|
|
112
|
+
*/
|
|
113
|
+
async function upsertResendContact({ token, email, name, }) {
|
|
114
|
+
const resend = new resend_1.Resend(token);
|
|
115
|
+
const normalized = (0, utils_1.lower)(email);
|
|
116
|
+
const existing = await withRetry(() => resend.contacts.get({ email: normalized }));
|
|
117
|
+
if (existing.data)
|
|
118
|
+
return { id: existing.data.id, created: false };
|
|
119
|
+
if (existing.error?.name !== 'not_found')
|
|
120
|
+
verify(existing, `Looking up contact ${normalized}`);
|
|
121
|
+
return {
|
|
122
|
+
id: await createResendContact({ token, email, name }),
|
|
123
|
+
created: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Creates a contact for an email the caller already knows isn't one, skipping
|
|
128
|
+
* the lookup {@link upsertResendContact} makes. The contact starts off the
|
|
129
|
+
* newsletter. Returns the new contact's id.
|
|
130
|
+
*/
|
|
131
|
+
async function createResendContact({ token, email, name, }) {
|
|
132
|
+
const resend = new resend_1.Resend(token);
|
|
133
|
+
const normalized = (0, utils_1.lower)(email);
|
|
134
|
+
return verify(await withRetry(() => resend.contacts.create({ email: normalized, ...splitName(name) })), `Creating contact ${normalized}`).id;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every contact's email, lowercased, or only those in one segment when
|
|
138
|
+
* `segmentId` is given. Read-only.
|
|
139
|
+
*/
|
|
140
|
+
async function listContactEmails(token, segmentId) {
|
|
141
|
+
const resend = new resend_1.Resend(token);
|
|
142
|
+
const emails = new Set();
|
|
143
|
+
for (let after;;) {
|
|
144
|
+
const page = verify(await withRetry(() => resend.contacts.list({
|
|
145
|
+
limit: 100,
|
|
146
|
+
...(segmentId && { segmentId }),
|
|
147
|
+
...(after && { after }),
|
|
148
|
+
})), `Listing contacts${segmentId ? ` in segment ${segmentId}` : ''} after ${after ?? 'the start'}`);
|
|
149
|
+
for (const contact of page.data)
|
|
150
|
+
emails.add((0, utils_1.lower)(contact.email));
|
|
151
|
+
const last = page.data[page.data.length - 1];
|
|
152
|
+
if (!page.has_more || !last)
|
|
153
|
+
return emails;
|
|
154
|
+
after = last.id;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Every segment's id and name. Read-only. */
|
|
158
|
+
async function listSegments(token) {
|
|
159
|
+
const resend = new resend_1.Resend(token);
|
|
160
|
+
const segments = [];
|
|
161
|
+
for (let after;;) {
|
|
162
|
+
const page = verify(await withRetry(() => resend.segments.list({ limit: 100, ...(after && { after }) })), `Listing segments after ${after ?? 'the start'}`);
|
|
163
|
+
for (const { id, name } of page.data)
|
|
164
|
+
segments.push({ id, name });
|
|
165
|
+
const last = page.data[page.data.length - 1];
|
|
166
|
+
if (!page.has_more || !last)
|
|
167
|
+
return segments;
|
|
168
|
+
after = last.id;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Creates a segment and returns its id. */
|
|
172
|
+
async function createSegment(token, name) {
|
|
173
|
+
const resend = new resend_1.Resend(token);
|
|
174
|
+
return verify(await withRetry(() => resend.segments.create({ name })), `Creating segment "${name}"`).id;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Adds or removes the contact for an email in a segment. Adding someone already
|
|
178
|
+
* in it is harmless, and so is removing someone who isn't.
|
|
179
|
+
*/
|
|
180
|
+
async function setSegmentMembership({ token, email, segmentId, member, }) {
|
|
181
|
+
const resend = new resend_1.Resend(token);
|
|
182
|
+
const options = { email: (0, utils_1.lower)(email), segmentId };
|
|
183
|
+
if (member) {
|
|
184
|
+
verify(await withRetry(() => resend.contacts.segments.add(options)), `Adding ${options.email} to segment ${segmentId}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const result = await withRetry(() => resend.contacts.segments.remove(options));
|
|
188
|
+
if (result.error?.name === 'not_found')
|
|
189
|
+
return;
|
|
190
|
+
verify(result, `Removing ${options.email} from segment ${segmentId}`);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Deletes the contact for an email, taking its topic and segment memberships
|
|
194
|
+
* with it. A missing contact is already deleted, so it isn't an error.
|
|
195
|
+
*/
|
|
196
|
+
async function deleteResendContact({ token, email, }) {
|
|
197
|
+
const resend = new resend_1.Resend(token);
|
|
198
|
+
const normalized = (0, utils_1.lower)(email);
|
|
199
|
+
const result = await withRetry(() => resend.contacts.remove({ email: normalized }));
|
|
200
|
+
if (result.error?.name === 'not_found')
|
|
201
|
+
return;
|
|
202
|
+
verify(result, `Deleting contact ${normalized}`);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* An explicit newsletter signup. This is fresh consent, so it opts the contact
|
|
206
|
+
* back in even if they opted out of the newsletter or unsubscribed from
|
|
207
|
+
* everything before. Bounce suppressions still apply. Also triggers the welcome
|
|
208
|
+
* email, which the automation sends only once per contact.
|
|
209
|
+
*/
|
|
210
|
+
async function subscribeToNewsletter({ token, email, name, }) {
|
|
211
|
+
const resend = new resend_1.Resend(token);
|
|
212
|
+
// Upsert the contact.
|
|
213
|
+
const contact = await upsertResendContact({ token, email, name });
|
|
214
|
+
// If contact exists, resubscribe them in case they previously unsubscribed.
|
|
215
|
+
if (!contact.created)
|
|
216
|
+
verify(await withRetry(() => resend.contacts.update({ id: contact.id, unsubscribed: false })), `Resubscribing contact ${contact.id}`);
|
|
217
|
+
// Add them to the newsletter topic.
|
|
218
|
+
verify(await withRetry(() => resend.contacts.topics.update({
|
|
219
|
+
id: contact.id,
|
|
220
|
+
topics: [{ id: Topics.Newsletter, subscription: 'opt_in' }],
|
|
221
|
+
})), `Subscribing contact ${contact.id} to the newsletter`);
|
|
222
|
+
// Send them a welcome email.
|
|
223
|
+
verify(await withRetry(() => resend.events.send({
|
|
224
|
+
event: Events.NewsletterWelcome,
|
|
225
|
+
contactId: contact.id,
|
|
226
|
+
})), `Sending ${Events.NewsletterWelcome} for contact ${contact.id}`);
|
|
227
|
+
}
|
|
228
|
+
/** Adds a contact to a segment. Adding someone already in it is harmless. */
|
|
229
|
+
async function addToSegment(resend, contactId, segmentId) {
|
|
230
|
+
verify(await withRetry(() => resend.contacts.segments.add({ contactId, segmentId })), `Adding contact ${contactId} to segment ${segmentId}`);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Opts a contact who ticked the newsletter box alongside another signup (an
|
|
234
|
+
* app account or a donation) in to the newsletter, without the welcome email:
|
|
235
|
+
* the sequence they're starting already welcomes them. For a contact that
|
|
236
|
+
* signup just created, the welcome's run-once property is set so a later
|
|
237
|
+
* explicit signup doesn't send it either. An existing contact's is left alone,
|
|
238
|
+
* since it may record a welcome they really got.
|
|
239
|
+
*
|
|
240
|
+
* Never throws: the sequence has already started, so a failure here is logged
|
|
241
|
+
* instead of failing the request.
|
|
242
|
+
*/
|
|
243
|
+
async function subscribeAlongsideSequence(resend, contactId, created) {
|
|
244
|
+
const subscribed = await withRetry(() => resend.contacts.topics.update({
|
|
245
|
+
id: contactId,
|
|
246
|
+
topics: [{ id: Topics.Newsletter, subscription: 'opt_in' }],
|
|
247
|
+
}));
|
|
248
|
+
if (subscribed.error) {
|
|
249
|
+
console.error(`Subscribing contact ${contactId} to the newsletter failed:`, subscribed.error);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!created)
|
|
253
|
+
return;
|
|
254
|
+
const marked = await withRetry(() => resend.contacts.update({
|
|
255
|
+
id: contactId,
|
|
256
|
+
properties: {
|
|
257
|
+
[Properties.NewsletterWelcome]: `skipped|${new Date().toISOString()}`,
|
|
258
|
+
},
|
|
259
|
+
}));
|
|
260
|
+
if (marked.error)
|
|
261
|
+
console.error(`Marking contact ${contactId} welcomed failed:`, marked.error);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Starts the donor thank-you sequence for a donor who asked for it and adds
|
|
265
|
+
* them to the Donor segment. A donor who ticked the newsletter box is also
|
|
266
|
+
* subscribed to it, without the welcome email. Donors who don't ask for the
|
|
267
|
+
* sequence aren't added to Resend at all.
|
|
268
|
+
*/
|
|
269
|
+
async function enrollInDonorSequence({ token, email, name, newsletter, }) {
|
|
270
|
+
const resend = new resend_1.Resend(token);
|
|
271
|
+
const { id, created } = await upsertResendContact({ token, email, name });
|
|
272
|
+
await addToSegment(resend, id, Segments.Donor);
|
|
273
|
+
verify(await withRetry(() => resend.events.send({
|
|
274
|
+
event: Events.AppDonorSequence,
|
|
275
|
+
contactId: id,
|
|
276
|
+
})), `Sending ${Events.AppDonorSequence} for contact ${id}`);
|
|
277
|
+
if (newsletter)
|
|
278
|
+
await subscribeAlongsideSequence(resend, id, created);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Enrolls an app user in a localized drip sequence: adds them to the App
|
|
282
|
+
* Accounts segment and fires the event that starts that sequence and
|
|
283
|
+
* language's automation (provisioned by waha-website's
|
|
284
|
+
* `scripts/emailDrip/push.ts`). A contact already enrolled in any sequence is
|
|
285
|
+
* left alone, so each person gets at most one drip. A user who ticked the
|
|
286
|
+
* newsletter box is also subscribed to it: without the welcome email when
|
|
287
|
+
* their drip starts now, and as an explicit signup when it already ran.
|
|
288
|
+
*/
|
|
289
|
+
async function enrollInDripSequence({ token, email, firstName, sequence, languageId, newsletter, }) {
|
|
290
|
+
const resend = new resend_1.Resend(token);
|
|
291
|
+
const normalized = (0, utils_1.lower)(email);
|
|
292
|
+
const existing = await withRetry(() => resend.contacts.get({ email: normalized }));
|
|
293
|
+
if (existing.error?.name !== 'not_found' && !existing.data)
|
|
294
|
+
verify(existing, `Looking up contact ${normalized}`);
|
|
295
|
+
const contact = existing.data;
|
|
296
|
+
const enrolledIn = contact?.properties?.[Properties.NewAccountSequence]?.value;
|
|
297
|
+
if (enrolledIn) {
|
|
298
|
+
// No drip starts to welcome them, so this is an ordinary signup.
|
|
299
|
+
if (newsletter)
|
|
300
|
+
await subscribeToNewsletter({ token, email, name: firstName }).catch((e) => console.error(`Subscribing ${normalized} to the newsletter failed:`, e));
|
|
301
|
+
return { status: 'already_enrolled', sequence: `${enrolledIn}` };
|
|
302
|
+
}
|
|
303
|
+
/*
|
|
304
|
+
* The sequence property is deliberately not set yet. Marking a contact
|
|
305
|
+
* enrolled before the event below fires would strand them if it failed: the
|
|
306
|
+
* check above would skip them forever though their drip never started.
|
|
307
|
+
*/
|
|
308
|
+
const properties = { [Properties.Language]: languageId };
|
|
309
|
+
const id = contact
|
|
310
|
+
? verify(await withRetry(() => resend.contacts.update({ id: contact.id, firstName, properties })), `Updating contact ${contact.id}`).id
|
|
311
|
+
: verify(await withRetry(() => resend.contacts.create({ email: normalized, firstName, properties })), `Creating contact ${normalized}`).id;
|
|
312
|
+
await addToSegment(resend, id, Segments.AppAccounts);
|
|
313
|
+
const event = dripId(sequence, languageId);
|
|
314
|
+
verify(await withRetry(() => resend.events.send({ event, contactId: id })), `Sending ${event} for contact ${id}`);
|
|
315
|
+
/*
|
|
316
|
+
* The drip has started, so a failure here only weakens the guard against a
|
|
317
|
+
* second enrollment. It's logged rather than failing a request that
|
|
318
|
+
* otherwise succeeded.
|
|
319
|
+
*/
|
|
320
|
+
const marked = await withRetry(() => resend.contacts.update({
|
|
321
|
+
id,
|
|
322
|
+
properties: { [Properties.NewAccountSequence]: sequence },
|
|
323
|
+
}));
|
|
324
|
+
if (marked.error)
|
|
325
|
+
console.error(`Marking contact ${id} enrolled failed:`, marked.error);
|
|
326
|
+
if (newsletter)
|
|
327
|
+
await subscribeAlongsideSequence(resend, id, !contact);
|
|
328
|
+
return { status: 'enrolled' };
|
|
329
|
+
}
|
package/dist/functions/utils.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.dirname = exports.first = exports.basename = exports.join = void 0;
|
|
4
4
|
exports.getErrorInfo = getErrorInfo;
|
|
5
|
+
exports.lower = lower;
|
|
5
6
|
const join = (...parts) => parts.join('/').replace(/\/+/g, '/');
|
|
6
7
|
exports.join = join;
|
|
7
8
|
const basename = (p) => p.slice(p.lastIndexOf('/') + 1);
|
|
@@ -21,3 +22,6 @@ function getErrorInfo(e) {
|
|
|
21
22
|
const message = e instanceof Error ? e.message : undefined;
|
|
22
23
|
return { code, message };
|
|
23
24
|
}
|
|
25
|
+
function lower(string) {
|
|
26
|
+
return string.trim().toLowerCase();
|
|
27
|
+
}
|
|
@@ -18,11 +18,7 @@ export declare const SubscribeDripBody: z.ZodObject<{
|
|
|
18
18
|
drip_believer: "drip_believer";
|
|
19
19
|
drip_seeker: "drip_seeker";
|
|
20
20
|
}>;
|
|
21
|
-
languageId: z.
|
|
22
|
-
eng: "eng";
|
|
23
|
-
ara: "ara";
|
|
24
|
-
swe: "swe";
|
|
25
|
-
}>;
|
|
21
|
+
languageId: z.ZodString;
|
|
26
22
|
firstName: z.ZodOptional<z.ZodString>;
|
|
27
23
|
}, z.core.$strip>;
|
|
28
24
|
export type SubscribeDripBody = z.infer<typeof SubscribeDripBody>;
|
|
@@ -12,6 +12,6 @@ exports.DRIP_LANGUAGES = ['eng', 'ara', 'swe'];
|
|
|
12
12
|
exports.DripLanguageId = zod_1.default.enum(exports.DRIP_LANGUAGES);
|
|
13
13
|
exports.SubscribeDripBody = zod_1.default.object({
|
|
14
14
|
sequence: exports.DripSequenceId,
|
|
15
|
-
languageId:
|
|
15
|
+
languageId: zod_1.default.string(),
|
|
16
16
|
firstName: zod_1.default.string().optional(),
|
|
17
17
|
});
|
|
@@ -55,6 +55,12 @@ export declare const LanguageInfoStatuses: z.ZodObject<{
|
|
|
55
55
|
testing: "testing";
|
|
56
56
|
launched: "launched";
|
|
57
57
|
}>;
|
|
58
|
+
drip: z.ZodOptional<z.ZodEnum<{
|
|
59
|
+
notStarted: "notStarted";
|
|
60
|
+
inProgress: "inProgress";
|
|
61
|
+
testing: "testing";
|
|
62
|
+
launched: "launched";
|
|
63
|
+
}>>;
|
|
58
64
|
dmCourse: z.ZodEnum<{
|
|
59
65
|
notStarted: "notStarted";
|
|
60
66
|
inProgress: "inProgress";
|
package/dist/types/logs.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
export declare const WahaShareContent: z.ZodEnum<{
|
|
3
3
|
lesson: "lesson";
|
|
4
|
-
note: "note";
|
|
5
4
|
lessonLink: "lessonLink";
|
|
6
5
|
setLink: "setLink";
|
|
7
6
|
app: "app";
|
|
@@ -21,6 +20,7 @@ export declare const WahaShareContent: z.ZodEnum<{
|
|
|
21
20
|
scriptureAudio: "scriptureAudio";
|
|
22
21
|
inviteGroup: "inviteGroup";
|
|
23
22
|
offlineVersionLink: "offlineVersionLink";
|
|
23
|
+
note: "note";
|
|
24
24
|
customSet: "customSet";
|
|
25
25
|
microLesson: "microLesson";
|
|
26
26
|
}>;
|
|
@@ -29,7 +29,6 @@ export declare const ShareLogEvent: z.ZodObject<{
|
|
|
29
29
|
time: z.ZodNumber;
|
|
30
30
|
content: z.ZodEnum<{
|
|
31
31
|
lesson: "lesson";
|
|
32
|
-
note: "note";
|
|
33
32
|
lessonLink: "lessonLink";
|
|
34
33
|
setLink: "setLink";
|
|
35
34
|
app: "app";
|
|
@@ -49,6 +48,7 @@ export declare const ShareLogEvent: z.ZodObject<{
|
|
|
49
48
|
scriptureAudio: "scriptureAudio";
|
|
50
49
|
inviteGroup: "inviteGroup";
|
|
51
50
|
offlineVersionLink: "offlineVersionLink";
|
|
51
|
+
note: "note";
|
|
52
52
|
customSet: "customSet";
|
|
53
53
|
microLesson: "microLesson";
|
|
54
54
|
}>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import type { Status } from '../data/languages/languages.zod';
|
|
3
|
+
/** The two onboarding drip audiences a new signup can be enrolled in. */
|
|
4
|
+
export declare const DripSequenceId: z.ZodEnum<{
|
|
5
|
+
drip_believer: "drip_believer";
|
|
6
|
+
drip_seeker: "drip_seeker";
|
|
7
|
+
}>;
|
|
8
|
+
export type DripSequenceId = z.infer<typeof DripSequenceId>;
|
|
9
|
+
/** Ids of languages whose `status.drip` in languages.json is one of these. */
|
|
10
|
+
export declare function dripLanguagesWithStatus(...statuses: Status[]): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Languages the onboarding drip is live in: provisioned in Resend and enrolled
|
|
13
|
+
* into on signup. Driven by `status.drip` in languages.json.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DRIP_LANGUAGES: string[];
|
|
16
|
+
export declare const DripLanguageId: z.ZodString;
|
|
17
|
+
export type DripLanguageId = z.infer<typeof DripLanguageId>;
|
|
18
|
+
/**
|
|
19
|
+
* A single drip email's id: its sequence's id plus its position. The same as
|
|
20
|
+
* the file name for the email's source .md file.
|
|
21
|
+
*/
|
|
22
|
+
export type DripEmailId = `${DripSequenceId}_${number}`;
|
|
23
|
+
/** `POST /api/submit-subscribe` (form data): an explicit newsletter signup. */
|
|
24
|
+
export declare const NewsletterSignupBody: z.ZodObject<{
|
|
25
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
26
|
+
name: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type NewsletterSignupBody = z.infer<typeof NewsletterSignupBody>;
|
|
29
|
+
/** `POST /api/submit-contact` (form data): the website's contact form. */
|
|
30
|
+
export declare const ContactFormBody: z.ZodObject<{
|
|
31
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
32
|
+
name: z.ZodString;
|
|
33
|
+
message: z.ZodString;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
export type ContactFormBody = z.infer<typeof ContactFormBody>;
|
|
36
|
+
/**
|
|
37
|
+
* `POST /api/add-to-donation-sequence` (JSON): starts the donor thank-you
|
|
38
|
+
* drip.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DonorSequenceBody: z.ZodObject<{
|
|
41
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
42
|
+
name: z.ZodOptional<z.ZodString>;
|
|
43
|
+
newsletter: z.ZodOptional<z.ZodBoolean>;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export type DonorSequenceBody = z.infer<typeof DonorSequenceBody>;
|
|
46
|
+
/**
|
|
47
|
+
* `POST /api/subscribe-drip` (JSON, Firebase ID token). Any language is
|
|
48
|
+
* accepted so callers don't need to know which ones have a drip; unsupported
|
|
49
|
+
* ones are a no-op.
|
|
50
|
+
*/
|
|
51
|
+
export declare const SubscribeDripBody: z.ZodObject<{
|
|
52
|
+
sequence: z.ZodEnum<{
|
|
53
|
+
drip_believer: "drip_believer";
|
|
54
|
+
drip_seeker: "drip_seeker";
|
|
55
|
+
}>;
|
|
56
|
+
languageId: z.ZodString;
|
|
57
|
+
firstName: z.ZodOptional<z.ZodString>;
|
|
58
|
+
newsletter: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
+
}, z.core.$strip>;
|
|
60
|
+
export type SubscribeDripBody = z.infer<typeof SubscribeDripBody>;
|