@aglyn/tenant-data-admin 1.0.0-beta.230 → 1.0.0-beta.231

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.
Files changed (39) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/erase.js +14 -0
  3. package/src/lib/server/erase.js.map +1 -1
  4. package/src/lib/server/host-released-names.d.ts +22 -0
  5. package/src/lib/server/host-released-names.js +31 -0
  6. package/src/lib/server/host-released-names.js.map +1 -0
  7. package/src/lib/server/host-visitor-records.d.ts +1 -1
  8. package/src/lib/server/host-visitor-records.js +1 -1
  9. package/src/lib/server/host-visitor-records.js.map +1 -1
  10. package/src/lib/server/mobile-push-switch.d.ts +46 -0
  11. package/src/lib/server/mobile-push-switch.js +45 -0
  12. package/src/lib/server/mobile-push-switch.js.map +1 -0
  13. package/src/lib/server/native-push.d.ts +17 -0
  14. package/src/lib/server/native-push.js +35 -0
  15. package/src/lib/server/native-push.js.map +1 -0
  16. package/src/lib/server/notifications.js +23 -0
  17. package/src/lib/server/notifications.js.map +1 -1
  18. package/src/lib/server/personal-data-export.js +108 -0
  19. package/src/lib/server/personal-data-export.js.map +1 -1
  20. package/src/lib/server/plugin-event-outbox.d.ts +138 -0
  21. package/src/lib/server/plugin-event-outbox.js +229 -0
  22. package/src/lib/server/plugin-event-outbox.js.map +1 -0
  23. package/src/lib/server/push-apns.d.ts +73 -0
  24. package/src/lib/server/push-apns.js +225 -0
  25. package/src/lib/server/push-apns.js.map +1 -0
  26. package/src/lib/server/push-delivery.d.ts +78 -0
  27. package/src/lib/server/push-delivery.js +176 -0
  28. package/src/lib/server/push-delivery.js.map +1 -0
  29. package/src/lib/server/push-fcm.d.ts +29 -0
  30. package/src/lib/server/push-fcm.js +83 -0
  31. package/src/lib/server/push-fcm.js.map +1 -0
  32. package/src/lib/server/serve-media-cdn.d.ts +6 -0
  33. package/src/lib/server/serve-media-cdn.js +20 -3
  34. package/src/lib/server/serve-media-cdn.js.map +1 -1
  35. package/src/lib/server/site-marketing-sync.d.ts +146 -0
  36. package/src/lib/server/site-marketing-sync.js +315 -0
  37. package/src/lib/server/site-marketing-sync.js.map +1 -0
  38. package/src/lib/server/upsert-contact.js +6 -5
  39. package/src/lib/server/upsert-contact.js.map +1 -1
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * What an outside list should hold for one person.
19
+ *
20
+ * `withheld` is not a refusal: nobody recorded a basis this site may use
21
+ * (and none is grandfathered), so the person is left OFF the outside list
22
+ * rather than added as unsubscribed — adding them at all would copy an
23
+ * address the site has no standing to market to.
24
+ */
25
+ export type SiteMarketingStatus = 'subscribed' | 'unsubscribed' | 'withheld';
26
+ export interface SiteMarketingPerson {
27
+ email: string;
28
+ /** The person's record as the plugin that keeps people stores it, or `null`. */
29
+ data: Readonly<Record<string, unknown>> | null;
30
+ }
31
+ /**
32
+ * Each person's status for one site, in the order asked. Throws when any
33
+ * list cannot be read — see the module comment.
34
+ *
35
+ * @param org the owning org's document data: its consent policy and its
36
+ * declared consent groups.
37
+ */
38
+ export declare function readSiteMarketingStatuses(input: {
39
+ hostId: string;
40
+ org: Readonly<Record<string, unknown>> | null;
41
+ people: readonly SiteMarketingPerson[];
42
+ firestore?: any;
43
+ }): Promise<SiteMarketingStatus[]>;
44
+ /** One row of a site's suppression list, as the walk answers it. */
45
+ export interface SiteSuppressionChange {
46
+ email: string;
47
+ /** `unsubscribe`, `bounce`, `complaint`, `erasure`, … — every row means "do not market". */
48
+ reason: string;
49
+ /** Where an unsubscribe filed by {@link recordSiteUnsubscribe} came from; `null` for the site's own. */
50
+ via: string | null;
51
+ }
52
+ export interface SiteSuppressionChangesPage {
53
+ rows: SiteSuppressionChange[];
54
+ /** The cursor to store; `null` when nothing was written after the one asked with. */
55
+ next: string | null;
56
+ }
57
+ /** Reads a suppression-walk cursor, or `null` for anything this module did not hand out. */
58
+ export declare function decodeSiteSuppressionCursor(cursor: string | null | undefined): {
59
+ seconds: number;
60
+ nanoseconds: number;
61
+ id: string;
62
+ } | null;
63
+ /**
64
+ * The site's suppression list in the order it was last written
65
+ * (`suppressedAt`, then the id), after a cursor. A single-field ordering, so
66
+ * no composite index is involved. A row with no `suppressedAt` (written
67
+ * before the field) is not on the walk; the person's next change reaches the
68
+ * outside list through {@link readSiteMarketingStatuses} instead.
69
+ */
70
+ export declare function listSiteSuppressionChanges(input: {
71
+ hostId: string;
72
+ after: string | null;
73
+ limit: number;
74
+ firestore?: any;
75
+ }): Promise<SiteSuppressionChangesPage>;
76
+ /**
77
+ * Files an unsubscribe the person made on an outside list as the site's own
78
+ * (`reason: 'unsubscribe'`), marked `via` — `<plugin>:<list>` — so the walk
79
+ * can tell it apart and only the same list can lift it.
80
+ *
81
+ * The same rules as the site's own unsubscribe link: a row of another kind
82
+ * (a bounce, an erasure) stays that kind and is not rewritten; an existing
83
+ * unsubscribe keeps the date and the door it was first filed by; a new one
84
+ * says so on the person's record and, on the platform's own marketing site,
85
+ * on the account's answer (AGL-3305).
86
+ *
87
+ * @returns whether this call created the row.
88
+ */
89
+ export declare function recordSiteUnsubscribe(input: {
90
+ hostId: string;
91
+ email: string;
92
+ via: string;
93
+ /** The words on the person's record, e.g. `Unsubscribed in Mailchimp.` */
94
+ detail: string;
95
+ firestore?: any;
96
+ }): Promise<{
97
+ created: boolean;
98
+ }>;
99
+ /**
100
+ * Lifts an unsubscribe filed by {@link recordSiteUnsubscribe} from the SAME
101
+ * outside list — the person subscribed again where they had left. Anything
102
+ * else stands: the site's own unsubscribe (the person told the site), a row
103
+ * another list filed, a bounce, a complaint, an erasure.
104
+ *
105
+ * A lifted row is mirrored onto the account's answer the way the site's own
106
+ * resubscribe link is (AGL-3305): only on the platform's marketing site, and
107
+ * only a No an email door recorded — which is the No the unsubscribe above
108
+ * mirrored — so the account's answer and the site's list move together.
109
+ *
110
+ * @returns whether a row was lifted.
111
+ */
112
+ export declare function releaseSiteUnsubscribe(input: {
113
+ hostId: string;
114
+ email: string;
115
+ via: string;
116
+ firestore?: any;
117
+ }): Promise<boolean>;
118
+ /**
119
+ * {@link recordSiteUnsubscribe} for a page of addresses an outside list
120
+ * reported, reading the site's rows in one round trip first: an address
121
+ * that already has a row of any kind is left alone without a transaction,
122
+ * which is most of them on every run after the first — a list reports back
123
+ * the unsubscribes it was just sent.
124
+ *
125
+ * @returns how many rows this call created.
126
+ */
127
+ export declare function recordSiteUnsubscribes(input: {
128
+ hostId: string;
129
+ emails: readonly string[];
130
+ via: string;
131
+ detail: string;
132
+ firestore?: any;
133
+ }): Promise<number>;
134
+ /**
135
+ * {@link releaseSiteUnsubscribe} for a page of addresses an outside list
136
+ * reported as subscribed again, reading the rows in one round trip first:
137
+ * only a row that list filed is worth a transaction.
138
+ *
139
+ * @returns how many rows this call lifted.
140
+ */
141
+ export declare function releaseSiteUnsubscribes(input: {
142
+ hostId: string;
143
+ emails: readonly string[];
144
+ via: string;
145
+ firestore?: any;
146
+ }): Promise<number>;
@@ -0,0 +1,315 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * A SITE'S MARKETING CONSENT, AS AN OUTSIDE LIST MIRRORS IT (AGL-3639).
18
+ *
19
+ * A connector that keeps a merchant's own email platform in step with the
20
+ * site — their Mailchimp audience, their Klaviyo list — has two questions to
21
+ * ask of the platform and one fact to hand back, and all three are answered
22
+ * from the records every send already reads:
23
+ *
24
+ * - **May this site market to this person?** The consent basis on the
25
+ * person's record, under the org's policy and the site's consent group
26
+ * (`marketing-consent.ts`), then the two suppression lists: the platform's
27
+ * `emailSuppressions` and the group's `hosts/{hostId}/suppressions`. The
28
+ * same decision a campaign makes, so the outside list and the site's own
29
+ * sends never disagree about one person.
30
+ * - **Who left lately?** The site's suppression list, walked in the order
31
+ * it was written, so an unsubscribe on the site reaches the outside list
32
+ * without the person's record having to change.
33
+ * - **This person left over there.** Filed as the site's own unsubscribe —
34
+ * the record the site's unsubscribe link writes, read by every sender —
35
+ * marked with WHERE it came from (`via`), so the walk above does not hand
36
+ * it straight back to the list it came from, and so only that list can
37
+ * lift it again.
38
+ *
39
+ * Only the site's suppression list is written. The person's consent basis is
40
+ * never changed from outside: declining it would stop every stream on every
41
+ * site of the group, and a person who unsubscribed from one newsletter tool
42
+ * said no more than that (the same rule AGL-3305 set for the account side).
43
+ *
44
+ * ## Reads fail LOUD here
45
+ *
46
+ * The send path's filters fail closed — a list that could not be read
47
+ * answers "suppressed". A mirror cannot take that posture: "suppressed" here
48
+ * becomes an unsubscribe WRITTEN to the merchant's outside list, for every
49
+ * person on a page, over a read that failed for an unrelated reason. So
50
+ * every read in this module throws, and the connector's run fails and is
51
+ * retried rather than acting on an answer nobody measured.
52
+ */ import { consentGroupForHost, consentGroupOptOutHosts } from "@aglyn/aglyn/app-utils/consent-groups";
53
+ import { emailSearchTokens } from "@aglyn/aglyn/app-utils/email-search";
54
+ import { marketingConsentDecision, readMarketingBasis, resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
55
+ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
56
+ import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
57
+ import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
58
+ import { EMAIL_SUPPRESSIONS_COLLECTION, HOST_SUPPRESSIONS_SUBCOLLECTION, UNSUBSCRIBE_SUPPRESSION_REASON } from "./email-suppression.js";
59
+ import firebaseAdmin from "./firebase-admin.js";
60
+ import { mirrorPlatformResubscribe, mirrorPlatformUnsubscribe } from "./platform-marketing-consent.js";
61
+ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
62
+ /**
63
+ * Each person's status for one site, in the order asked. Throws when any
64
+ * list cannot be read — see the module comment.
65
+ *
66
+ * @param org the owning org's document data: its consent policy and its
67
+ * declared consent groups.
68
+ */ export async function readSiteMarketingStatuses(input) {
69
+ var _input_firestore, _input_org;
70
+ var _input_org1;
71
+ if (!input.people.length) return [];
72
+ const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
73
+ const group = consentGroupForHost((_input_org = input.org) != null ? _input_org : null, input.hostId);
74
+ const policy = resolveMarketingConsentPolicy((_input_org1 = input.org) == null ? void 0 : _input_org1['marketingConsentPolicy']);
75
+ const keys = input.people.map((person)=>personKey(person.email));
76
+ const keyed = keys.filter((key)=>Boolean(key));
77
+ const suppressed = new Set();
78
+ if (keyed.length) {
79
+ const platform = await db.getAll(...keyed.map((key)=>db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)));
80
+ platform.forEach((snapshot, index)=>{
81
+ if ((snapshot == null ? void 0 : snapshot.exists) && !snapshot.get('releasedAt')) suppressed.add(keyed[index]);
82
+ });
83
+ const sites = await Promise.all(consentGroupOptOutHosts(group).map((siteId)=>{
84
+ const list = db.collection('hosts').doc(siteId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION);
85
+ return db.getAll(...keyed.map((key)=>list.doc(key)));
86
+ }));
87
+ for (const snapshots of sites){
88
+ snapshots.forEach((snapshot, index)=>{
89
+ if (snapshot == null ? void 0 : snapshot.exists) suppressed.add(keyed[index]);
90
+ });
91
+ }
92
+ }
93
+ return input.people.map((person, index)=>{
94
+ var _person_data;
95
+ const key = keys[index];
96
+ if (!key) return 'withheld';
97
+ if (suppressed.has(key)) return 'unsubscribed';
98
+ const decision = marketingConsentDecision(readMarketingBasis((_person_data = person.data) != null ? _person_data : null, group), policy);
99
+ if (decision.reason === 'declined') return 'unsubscribed';
100
+ return decision.verdict === 'withheld' ? 'withheld' : 'subscribed';
101
+ });
102
+ }
103
+ const encodeCursor = (stamp, id)=>{
104
+ var _ref;
105
+ const seconds = Number(stamp == null ? void 0 : stamp.seconds);
106
+ const nanoseconds = Number((_ref = stamp == null ? void 0 : stamp.nanoseconds) != null ? _ref : 0);
107
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanoseconds)) return null;
108
+ return `${Math.trunc(seconds)}.${Math.trunc(nanoseconds)}.${id}`;
109
+ };
110
+ /** Reads a suppression-walk cursor, or `null` for anything this module did not hand out. */ export function decodeSiteSuppressionCursor(cursor) {
111
+ const match = /^(\d{1,12})\.(\d{1,9})\.([A-Za-z0-9_-]{1,128})$/.exec(String(cursor != null ? cursor : ''));
112
+ return match ? {
113
+ seconds: Number(match[1]),
114
+ nanoseconds: Number(match[2]),
115
+ id: match[3]
116
+ } : null;
117
+ }
118
+ /**
119
+ * The site's suppression list in the order it was last written
120
+ * (`suppressedAt`, then the id), after a cursor. A single-field ordering, so
121
+ * no composite index is involved. A row with no `suppressedAt` (written
122
+ * before the field) is not on the walk; the person's next change reaches the
123
+ * outside list through {@link readSiteMarketingStatuses} instead.
124
+ */ export async function listSiteSuppressionChanges(input) {
125
+ var _input_firestore;
126
+ const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
127
+ const limit = Math.min(500, Math.max(1, Math.floor(Number(input.limit) || 0)));
128
+ let query = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).orderBy('suppressedAt', 'asc').orderBy(FieldPath.documentId(), 'asc');
129
+ const after = decodeSiteSuppressionCursor(input.after);
130
+ if (after) query = query.startAfter(new Timestamp(after.seconds, after.nanoseconds), after.id);
131
+ const snapshot = await query.limit(limit).get();
132
+ const rows = [];
133
+ let next = null;
134
+ for (const doc of snapshot.docs){
135
+ var _encodeCursor, _doc_get, _doc_get1;
136
+ next = (_encodeCursor = encodeCursor(doc.get('suppressedAt'), doc.id)) != null ? _encodeCursor : next;
137
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '').trim().toLowerCase();
138
+ if (!email) continue;
139
+ rows.push({
140
+ email,
141
+ reason: String((_doc_get1 = doc.get('reason')) != null ? _doc_get1 : UNSUBSCRIBE_SUPPRESSION_REASON),
142
+ via: typeof doc.get('via') === 'string' ? doc.get('via') : null
143
+ });
144
+ }
145
+ return {
146
+ rows,
147
+ next
148
+ };
149
+ }
150
+ /** A `via` tag: who filed an unsubscribe from outside the site. */ const VIA = /^[a-z][a-z0-9-]{1,62}:[a-z][a-z0-9-]{1,62}$/;
151
+ /**
152
+ * Files an unsubscribe the person made on an outside list as the site's own
153
+ * (`reason: 'unsubscribe'`), marked `via` — `<plugin>:<list>` — so the walk
154
+ * can tell it apart and only the same list can lift it.
155
+ *
156
+ * The same rules as the site's own unsubscribe link: a row of another kind
157
+ * (a bounce, an erasure) stays that kind and is not rewritten; an existing
158
+ * unsubscribe keeps the date and the door it was first filed by; a new one
159
+ * says so on the person's record and, on the platform's own marketing site,
160
+ * on the account's answer (AGL-3305).
161
+ *
162
+ * @returns whether this call created the row.
163
+ */ export async function recordSiteUnsubscribe(input) {
164
+ var _input_firestore;
165
+ const key = personKey(input.email);
166
+ if (!key || !input.hostId || !VIA.test(input.via)) return {
167
+ created: false
168
+ };
169
+ const email = String(input.email).trim().toLowerCase();
170
+ const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
171
+ const ref = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key);
172
+ const created = await db.runTransaction(async (transaction)=>{
173
+ const existing = await transaction.get(ref);
174
+ if (existing.exists) return false;
175
+ transaction.set(ref, {
176
+ email,
177
+ emailTokens: emailSearchTokens(email),
178
+ reason: UNSUBSCRIBE_SUPPRESSION_REASON,
179
+ via: input.via,
180
+ suppressedAt: FieldValue.serverTimestamp(),
181
+ createdAt: FieldValue.serverTimestamp()
182
+ });
183
+ return true;
184
+ });
185
+ if (created) {
186
+ await stampRecordEmailState({
187
+ hostId: input.hostId,
188
+ email,
189
+ state: {
190
+ status: 'unsubscribed',
191
+ atMs: Date.now(),
192
+ source: 'campaign',
193
+ detail: input.detail
194
+ }
195
+ });
196
+ await mirrorPlatformUnsubscribe({
197
+ hostId: input.hostId,
198
+ email,
199
+ left: 'everything'
200
+ });
201
+ }
202
+ return {
203
+ created
204
+ };
205
+ }
206
+ /**
207
+ * Lifts an unsubscribe filed by {@link recordSiteUnsubscribe} from the SAME
208
+ * outside list — the person subscribed again where they had left. Anything
209
+ * else stands: the site's own unsubscribe (the person told the site), a row
210
+ * another list filed, a bounce, a complaint, an erasure.
211
+ *
212
+ * A lifted row is mirrored onto the account's answer the way the site's own
213
+ * resubscribe link is (AGL-3305): only on the platform's marketing site, and
214
+ * only a No an email door recorded — which is the No the unsubscribe above
215
+ * mirrored — so the account's answer and the site's list move together.
216
+ *
217
+ * @returns whether a row was lifted.
218
+ */ export async function releaseSiteUnsubscribe(input) {
219
+ var _input_firestore;
220
+ const key = personKey(input.email);
221
+ if (!key || !input.hostId || !VIA.test(input.via)) return false;
222
+ const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
223
+ const ref = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key);
224
+ const released = await db.runTransaction(async (transaction)=>{
225
+ const existing = await transaction.get(ref);
226
+ if (!existing.exists) return false;
227
+ if (existing.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON) return false;
228
+ if (existing.get('via') !== input.via) return false;
229
+ transaction.delete(ref);
230
+ return true;
231
+ });
232
+ if (released) {
233
+ await mirrorPlatformResubscribe({
234
+ hostId: input.hostId,
235
+ email: String(input.email).trim().toLowerCase(),
236
+ via: 'email-resubscribe',
237
+ firestore: db
238
+ });
239
+ }
240
+ return released;
241
+ }
242
+ /** Distinct, keyable addresses, each with its key. */ function keyedEmails(emails) {
243
+ const out = new Map();
244
+ for (const raw of emails){
245
+ const email = String(raw != null ? raw : '').trim().toLowerCase();
246
+ const key = personKey(email);
247
+ if (key && !out.has(key)) out.set(key, {
248
+ email,
249
+ key
250
+ });
251
+ }
252
+ return [
253
+ ...out.values()
254
+ ];
255
+ }
256
+ /**
257
+ * {@link recordSiteUnsubscribe} for a page of addresses an outside list
258
+ * reported, reading the site's rows in one round trip first: an address
259
+ * that already has a row of any kind is left alone without a transaction,
260
+ * which is most of them on every run after the first — a list reports back
261
+ * the unsubscribes it was just sent.
262
+ *
263
+ * @returns how many rows this call created.
264
+ */ export async function recordSiteUnsubscribes(input) {
265
+ var _input_firestore;
266
+ const entries = keyedEmails(input.emails);
267
+ if (!entries.length || !input.hostId || !VIA.test(input.via)) return 0;
268
+ const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
269
+ const list = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION);
270
+ const snapshots = await db.getAll(...entries.map((entry)=>list.doc(entry.key)));
271
+ let created = 0;
272
+ for (const [index, entry] of entries.entries()){
273
+ var _snapshots_index;
274
+ if ((_snapshots_index = snapshots[index]) == null ? void 0 : _snapshots_index.exists) continue;
275
+ const outcome = await recordSiteUnsubscribe({
276
+ hostId: input.hostId,
277
+ email: entry.email,
278
+ via: input.via,
279
+ detail: input.detail,
280
+ firestore: db
281
+ });
282
+ if (outcome.created) created += 1;
283
+ }
284
+ return created;
285
+ }
286
+ /**
287
+ * {@link releaseSiteUnsubscribe} for a page of addresses an outside list
288
+ * reported as subscribed again, reading the rows in one round trip first:
289
+ * only a row that list filed is worth a transaction.
290
+ *
291
+ * @returns how many rows this call lifted.
292
+ */ export async function releaseSiteUnsubscribes(input) {
293
+ var _input_firestore;
294
+ const entries = keyedEmails(input.emails);
295
+ if (!entries.length || !input.hostId || !VIA.test(input.via)) return 0;
296
+ const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
297
+ const list = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION);
298
+ const snapshots = await db.getAll(...entries.map((entry)=>list.doc(entry.key)));
299
+ let released = 0;
300
+ for (const [index, entry] of entries.entries()){
301
+ const snapshot = snapshots[index];
302
+ if (!(snapshot == null ? void 0 : snapshot.exists) || snapshot.get('via') !== input.via) continue;
303
+ if (await releaseSiteUnsubscribe({
304
+ hostId: input.hostId,
305
+ email: entry.email,
306
+ via: input.via,
307
+ firestore: db
308
+ })) {
309
+ released += 1;
310
+ }
311
+ }
312
+ return released;
313
+ }
314
+
315
+ //# sourceMappingURL=site-marketing-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/site-marketing-sync.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A SITE'S MARKETING CONSENT, AS AN OUTSIDE LIST MIRRORS IT (AGL-3639).\n *\n * A connector that keeps a merchant's own email platform in step with the\n * site — their Mailchimp audience, their Klaviyo list — has two questions to\n * ask of the platform and one fact to hand back, and all three are answered\n * from the records every send already reads:\n *\n * - **May this site market to this person?** The consent basis on the\n * person's record, under the org's policy and the site's consent group\n * (`marketing-consent.ts`), then the two suppression lists: the platform's\n * `emailSuppressions` and the group's `hosts/{hostId}/suppressions`. The\n * same decision a campaign makes, so the outside list and the site's own\n * sends never disagree about one person.\n * - **Who left lately?** The site's suppression list, walked in the order\n * it was written, so an unsubscribe on the site reaches the outside list\n * without the person's record having to change.\n * - **This person left over there.** Filed as the site's own unsubscribe —\n * the record the site's unsubscribe link writes, read by every sender —\n * marked with WHERE it came from (`via`), so the walk above does not hand\n * it straight back to the list it came from, and so only that list can\n * lift it again.\n *\n * Only the site's suppression list is written. The person's consent basis is\n * never changed from outside: declining it would stop every stream on every\n * site of the group, and a person who unsubscribed from one newsletter tool\n * said no more than that (the same rule AGL-3305 set for the account side).\n *\n * ## Reads fail LOUD here\n *\n * The send path's filters fail closed — a list that could not be read\n * answers \"suppressed\". A mirror cannot take that posture: \"suppressed\" here\n * becomes an unsubscribe WRITTEN to the merchant's outside list, for every\n * person on a page, over a read that failed for an unrelated reason. So\n * every read in this module throws, and the connector's run fails and is\n * retried rather than acting on an answer nobody measured.\n */\n\nimport {\n consentGroupForHost,\n consentGroupOptOutHosts,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport { emailSearchTokens } from '@aglyn/aglyn/app-utils/email-search'\nimport {\n marketingConsentDecision,\n readMarketingBasis,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n} from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport { mirrorPlatformResubscribe, mirrorPlatformUnsubscribe } from './platform-marketing-consent'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * What an outside list should hold for one person.\n *\n * `withheld` is not a refusal: nobody recorded a basis this site may use\n * (and none is grandfathered), so the person is left OFF the outside list\n * rather than added as unsubscribed — adding them at all would copy an\n * address the site has no standing to market to.\n */\nexport type SiteMarketingStatus = 'subscribed' | 'unsubscribed' | 'withheld'\n\nexport interface SiteMarketingPerson {\n email: string\n /** The person's record as the plugin that keeps people stores it, or `null`. */\n data: Readonly<Record<string, unknown>> | null\n}\n\n/**\n * Each person's status for one site, in the order asked. Throws when any\n * list cannot be read — see the module comment.\n *\n * @param org the owning org's document data: its consent policy and its\n * declared consent groups.\n */\nexport async function readSiteMarketingStatuses(input: {\n hostId: string\n org: Readonly<Record<string, unknown>> | null\n people: readonly SiteMarketingPerson[]\n firestore?: any\n}): Promise<SiteMarketingStatus[]> {\n if (!input.people.length) return []\n const db = input.firestore ?? defaultFirestore()\n const group = consentGroupForHost((input.org as Record<string, unknown> | null) ?? null, input.hostId)\n const policy = resolveMarketingConsentPolicy(input.org?.['marketingConsentPolicy'])\n const keys = input.people.map((person) => personKey(person.email))\n const keyed = keys.filter((key): key is string => Boolean(key))\n const suppressed = new Set<string>()\n if (keyed.length) {\n const platform = await db.getAll(\n ...keyed.map((key) => db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)),\n )\n platform.forEach((snapshot: any, index: number) => {\n if (snapshot?.exists && !snapshot.get('releasedAt')) suppressed.add(keyed[index])\n })\n const sites = await Promise.all(\n consentGroupOptOutHosts(group).map((siteId) => {\n const list = db.collection('hosts').doc(siteId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n return db.getAll(...keyed.map((key) => list.doc(key)))\n }),\n )\n for (const snapshots of sites) {\n snapshots.forEach((snapshot: any, index: number) => {\n if (snapshot?.exists) suppressed.add(keyed[index])\n })\n }\n }\n return input.people.map((person, index): SiteMarketingStatus => {\n const key = keys[index]\n if (!key) return 'withheld'\n if (suppressed.has(key)) return 'unsubscribed'\n const decision = marketingConsentDecision(\n readMarketingBasis((person.data as Record<string, unknown> | null) ?? null, group),\n policy,\n )\n if (decision.reason === 'declined') return 'unsubscribed'\n return decision.verdict === 'withheld' ? 'withheld' : 'subscribed'\n })\n}\n\n/** One row of a site's suppression list, as the walk answers it. */\nexport interface SiteSuppressionChange {\n email: string\n /** `unsubscribe`, `bounce`, `complaint`, `erasure`, … — every row means \"do not market\". */\n reason: string\n /** Where an unsubscribe filed by {@link recordSiteUnsubscribe} came from; `null` for the site's own. */\n via: string | null\n}\n\nexport interface SiteSuppressionChangesPage {\n rows: SiteSuppressionChange[]\n /** The cursor to store; `null` when nothing was written after the one asked with. */\n next: string | null\n}\n\nconst encodeCursor = (stamp: any, id: string): string | null => {\n const seconds = Number(stamp?.seconds)\n const nanoseconds = Number(stamp?.nanoseconds ?? 0)\n if (!Number.isFinite(seconds) || !Number.isFinite(nanoseconds)) return null\n return `${Math.trunc(seconds)}.${Math.trunc(nanoseconds)}.${id}`\n}\n\n/** Reads a suppression-walk cursor, or `null` for anything this module did not hand out. */\nexport function decodeSiteSuppressionCursor(\n cursor: string | null | undefined,\n): { seconds: number; nanoseconds: number; id: string } | null {\n const match = /^(\\d{1,12})\\.(\\d{1,9})\\.([A-Za-z0-9_-]{1,128})$/.exec(String(cursor ?? ''))\n return match ? { seconds: Number(match[1]), nanoseconds: Number(match[2]), id: match[3] } : null\n}\n\n/**\n * The site's suppression list in the order it was last written\n * (`suppressedAt`, then the id), after a cursor. A single-field ordering, so\n * no composite index is involved. A row with no `suppressedAt` (written\n * before the field) is not on the walk; the person's next change reaches the\n * outside list through {@link readSiteMarketingStatuses} instead.\n */\nexport async function listSiteSuppressionChanges(input: {\n hostId: string\n after: string | null\n limit: number\n firestore?: any\n}): Promise<SiteSuppressionChangesPage> {\n const db = input.firestore ?? defaultFirestore()\n const limit = Math.min(500, Math.max(1, Math.floor(Number(input.limit) || 0)))\n let query = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .orderBy('suppressedAt', 'asc')\n .orderBy(FieldPath.documentId(), 'asc')\n const after = decodeSiteSuppressionCursor(input.after)\n if (after) query = query.startAfter(new Timestamp(after.seconds, after.nanoseconds), after.id)\n const snapshot = await query.limit(limit).get()\n const rows: SiteSuppressionChange[] = []\n let next: string | null = null\n for (const doc of snapshot.docs) {\n next = encodeCursor(doc.get('suppressedAt'), doc.id) ?? next\n const email = String(doc.get('email') ?? '').trim().toLowerCase()\n if (!email) continue\n rows.push({\n email,\n reason: String(doc.get('reason') ?? UNSUBSCRIBE_SUPPRESSION_REASON),\n via: typeof doc.get('via') === 'string' ? doc.get('via') : null,\n })\n }\n return { rows, next }\n}\n\n/** A `via` tag: who filed an unsubscribe from outside the site. */\nconst VIA = /^[a-z][a-z0-9-]{1,62}:[a-z][a-z0-9-]{1,62}$/\n\n/**\n * Files an unsubscribe the person made on an outside list as the site's own\n * (`reason: 'unsubscribe'`), marked `via` — `<plugin>:<list>` — so the walk\n * can tell it apart and only the same list can lift it.\n *\n * The same rules as the site's own unsubscribe link: a row of another kind\n * (a bounce, an erasure) stays that kind and is not rewritten; an existing\n * unsubscribe keeps the date and the door it was first filed by; a new one\n * says so on the person's record and, on the platform's own marketing site,\n * on the account's answer (AGL-3305).\n *\n * @returns whether this call created the row.\n */\nexport async function recordSiteUnsubscribe(input: {\n hostId: string\n email: string\n via: string\n /** The words on the person's record, e.g. `Unsubscribed in Mailchimp.` */\n detail: string\n firestore?: any\n}): Promise<{ created: boolean }> {\n const key = personKey(input.email)\n if (!key || !input.hostId || !VIA.test(input.via)) return { created: false }\n const email = String(input.email).trim().toLowerCase()\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key)\n const created: boolean = await db.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n if (existing.exists) return false\n transaction.set(ref, {\n email,\n emailTokens: emailSearchTokens(email),\n reason: UNSUBSCRIBE_SUPPRESSION_REASON,\n via: input.via,\n suppressedAt: FieldValue.serverTimestamp(),\n createdAt: FieldValue.serverTimestamp(),\n })\n return true\n })\n if (created) {\n await stampRecordEmailState({\n hostId: input.hostId,\n email,\n state: { status: 'unsubscribed', atMs: Date.now(), source: 'campaign', detail: input.detail },\n })\n await mirrorPlatformUnsubscribe({ hostId: input.hostId, email, left: 'everything' })\n }\n return { created }\n}\n\n/**\n * Lifts an unsubscribe filed by {@link recordSiteUnsubscribe} from the SAME\n * outside list — the person subscribed again where they had left. Anything\n * else stands: the site's own unsubscribe (the person told the site), a row\n * another list filed, a bounce, a complaint, an erasure.\n *\n * A lifted row is mirrored onto the account's answer the way the site's own\n * resubscribe link is (AGL-3305): only on the platform's marketing site, and\n * only a No an email door recorded — which is the No the unsubscribe above\n * mirrored — so the account's answer and the site's list move together.\n *\n * @returns whether a row was lifted.\n */\nexport async function releaseSiteUnsubscribe(input: {\n hostId: string\n email: string\n via: string\n firestore?: any\n}): Promise<boolean> {\n const key = personKey(input.email)\n if (!key || !input.hostId || !VIA.test(input.via)) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key)\n const released: boolean = await db.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n if (!existing.exists) return false\n if (existing.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON) return false\n if (existing.get('via') !== input.via) return false\n transaction.delete(ref)\n return true\n })\n if (released) {\n await mirrorPlatformResubscribe({\n hostId: input.hostId,\n email: String(input.email).trim().toLowerCase(),\n via: 'email-resubscribe',\n firestore: db,\n })\n }\n return released\n}\n\n/** Distinct, keyable addresses, each with its key. */\nfunction keyedEmails(emails: readonly string[]): Array<{ email: string; key: string }> {\n const out = new Map<string, { email: string; key: string }>()\n for (const raw of emails) {\n const email = String(raw ?? '').trim().toLowerCase()\n const key = personKey(email)\n if (key && !out.has(key)) out.set(key, { email, key })\n }\n return [...out.values()]\n}\n\n/**\n * {@link recordSiteUnsubscribe} for a page of addresses an outside list\n * reported, reading the site's rows in one round trip first: an address\n * that already has a row of any kind is left alone without a transaction,\n * which is most of them on every run after the first — a list reports back\n * the unsubscribes it was just sent.\n *\n * @returns how many rows this call created.\n */\nexport async function recordSiteUnsubscribes(input: {\n hostId: string\n emails: readonly string[]\n via: string\n detail: string\n firestore?: any\n}): Promise<number> {\n const entries = keyedEmails(input.emails)\n if (!entries.length || !input.hostId || !VIA.test(input.via)) return 0\n const db = input.firestore ?? defaultFirestore()\n const list = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n const snapshots = await db.getAll(...entries.map((entry) => list.doc(entry.key)))\n let created = 0\n for (const [index, entry] of entries.entries()) {\n if (snapshots[index]?.exists) continue\n const outcome = await recordSiteUnsubscribe({\n hostId: input.hostId,\n email: entry.email,\n via: input.via,\n detail: input.detail,\n firestore: db,\n })\n if (outcome.created) created += 1\n }\n return created\n}\n\n/**\n * {@link releaseSiteUnsubscribe} for a page of addresses an outside list\n * reported as subscribed again, reading the rows in one round trip first:\n * only a row that list filed is worth a transaction.\n *\n * @returns how many rows this call lifted.\n */\nexport async function releaseSiteUnsubscribes(input: {\n hostId: string\n emails: readonly string[]\n via: string\n firestore?: any\n}): Promise<number> {\n const entries = keyedEmails(input.emails)\n if (!entries.length || !input.hostId || !VIA.test(input.via)) return 0\n const db = input.firestore ?? defaultFirestore()\n const list = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n const snapshots = await db.getAll(...entries.map((entry) => list.doc(entry.key)))\n let released = 0\n for (const [index, entry] of entries.entries()) {\n const snapshot = snapshots[index]\n if (!snapshot?.exists || snapshot.get('via') !== input.via) continue\n if (await releaseSiteUnsubscribe({ hostId: input.hostId, email: entry.email, via: input.via, firestore: db })) {\n released += 1\n }\n }\n return released\n}\n"],"names":["consentGroupForHost","consentGroupOptOutHosts","emailSearchTokens","marketingConsentDecision","readMarketingBasis","resolveMarketingConsentPolicy","personKey","stampRecordEmailState","FieldPath","FieldValue","Timestamp","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","UNSUBSCRIBE_SUPPRESSION_REASON","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","defaultFirestore","app","firestore","readSiteMarketingStatuses","input","people","length","db","group","org","hostId","policy","keys","map","person","email","keyed","filter","key","Boolean","suppressed","Set","platform","getAll","collection","doc","forEach","snapshot","index","exists","get","add","sites","Promise","all","siteId","list","snapshots","has","decision","data","reason","verdict","encodeCursor","stamp","id","seconds","Number","nanoseconds","isFinite","Math","trunc","decodeSiteSuppressionCursor","cursor","match","exec","String","listSiteSuppressionChanges","limit","min","max","floor","query","orderBy","documentId","after","startAfter","rows","next","docs","trim","toLowerCase","push","via","VIA","recordSiteUnsubscribe","test","created","ref","runTransaction","transaction","existing","set","emailTokens","suppressedAt","serverTimestamp","createdAt","state","status","atMs","Date","now","source","detail","left","releaseSiteUnsubscribe","released","delete","keyedEmails","emails","out","Map","raw","values","recordSiteUnsubscribes","entries","entry","outcome","releaseSiteUnsubscribes"],"mappings":"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"}
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { CAPTURED_BY_HOST_FIELD, checkCrmRecordsQuota, composeContactName, CONTACT_EXTRA_PHONE_FIELDS, consentGroupScope, CONTACT_FACETS_FIELD, CONTACT_FORM_IDS_CAP, CONTACT_FORM_IDS_FIELD, marketingConsentFieldsForGroup, mergeContactInteraction, normalizeContactBirthdate, normalizeContainerIds, readContactFacet, normalizeContactEmail, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
17
+ */ import { CAPTURED_BY_HOST_FIELD, checkCrmRecordsQuota, composeContactName, CONTACT_EXTRA_PHONE_FIELDS, consentGroupScope, crmDefaultScopeOf, CONTACT_FACETS_FIELD, CONTACT_FORM_IDS_CAP, CONTACT_FORM_IDS_FIELD, marketingConsentFieldsForGroup, mergeContactInteraction, normalizeContactBirthdate, normalizeContainerIds, readContactFacet, normalizeContactEmail, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
18
18
  import { FieldValue } from "firebase-admin/firestore";
19
19
  import { firebaseAdmin } from "./firebase-admin.js";
20
20
  import { countCrmRecords, restampCrmListFieldsAt } from "./crm-records.js";
@@ -167,7 +167,7 @@ import { consentGroupForSite, getOrgForHost, orgDataCollectionForHost } from "./
167
167
  export async function upsertHostContact(options) {
168
168
  try {
169
169
  var _ref, _ref1, _options_interaction_atMs, _options_campaignIds, _ref2, _ref3, _ref4;
170
- var _options_batch, _options_facet, _orgBilling_org, _contactsRef_parent;
170
+ var _options_batch, _options_facet, _contactsRef_parent;
171
171
  const email = normalizeContactEmail(options.email);
172
172
  if (!email) return {
173
173
  refused: 'invalid-email'
@@ -534,9 +534,10 @@ export async function upsertHostContact(options) {
534
534
  *
535
535
  * A group of one — the default — is this site alone. A declared group
536
536
  * is the sites that already present as one sender. Widening beyond that
537
- * is available and is an ACT: an org may set `defaultResourceScope` to
538
- * `'org'`, or a later capture on a sibling site unions that site in.
539
- */ visibleTo: (orgBilling == null ? void 0 : (_orgBilling_org = orgBilling.org) == null ? void 0 : _orgBilling_org.defaultResourceScope) === 'org' ? [
537
+ * is available and is an ACT: an org may set its CRM default
538
+ * (`crmDefaultScopeOf`) to `'org'`, or a later capture on a sibling
539
+ * site unions that site in.
540
+ */ visibleTo: crmDefaultScopeOf(orgBilling == null ? void 0 : orgBilling.org) === 'org' ? [
540
541
  ORG_SCOPE_TOKEN
541
542
  ] : consentGroupScope(group),
542
543
  email