@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.205
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/package.json +7 -7
- package/src/lib/server/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/notifications.d.ts +7 -0
- package/src/lib/server/notifications.js +27 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +29 -15
- package/src/lib/server/staff-alert-email.js +131 -28
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- package/src/tenancy.js.map +1 -1
|
@@ -16,33 +16,113 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { sendEmail } from "@aglyn/shared-util-email";
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Staff alerts by email, from LIBRARY code (AGL-2925).
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the ordinary answer outside production and reports `unconfigured`, and
|
|
28
|
-
* nothing here throws, because the mail is a courtesy and the refusal it
|
|
29
|
-
* announces is the control.
|
|
21
|
+
* One destination for every subsystem's alarm — the console's
|
|
22
|
+
* `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends
|
|
23
|
+
* through here too — resolved by {@link resolveStaffAlertRecipients}
|
|
24
|
+
* (AGL-3375). Nothing here throws, because the mail is a courtesy and the
|
|
25
|
+
* refusal it announces is the control; with no address anywhere the answer
|
|
26
|
+
* is `unconfigured`.
|
|
30
27
|
*
|
|
31
28
|
* Platform email metering is loaded lazily and only after a send, so that
|
|
32
29
|
* importing this module — which every unit test of the meter does — never
|
|
33
30
|
* pulls the Firestore-backed meter, and with it the admin app, into a test
|
|
34
|
-
* that only wanted to count credits. The system-email renderer
|
|
35
|
-
*
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
* that only wanted to count credits. The system-email renderer and the staff
|
|
32
|
+
* roster, which read through the same admin app, are loaded the same way.
|
|
33
|
+
*/ /** At most this many staff accounts are mailed when no inbox is configured. */ const STAFF_FALLBACK_MAX = 10;
|
|
34
|
+
/**
|
|
35
|
+
* Where a staff alarm goes (AGL-3375), first answer wins:
|
|
36
|
+
*
|
|
37
|
+
* 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.
|
|
38
|
+
* 2. The operator's published support address,
|
|
39
|
+
* `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is
|
|
40
|
+
* asked for and most set.
|
|
41
|
+
* 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.
|
|
42
|
+
*
|
|
43
|
+
* Only the first applies on a preview or development deployment.
|
|
44
|
+
*
|
|
45
|
+
* The chain exists because the alarms that matter most are the ones an
|
|
46
|
+
* install nobody tuned would otherwise send to no one: card testing on its
|
|
47
|
+
* checkout, a stolen card on its first subscription, a phishing campaign
|
|
48
|
+
* held on its sending domain. An unset variable used to mean silence.
|
|
49
|
+
*
|
|
50
|
+
* Empty only when none of the three yields an address.
|
|
51
|
+
*/ export async function resolveStaffAlertRecipients() {
|
|
52
|
+
var _process_env_STAFF_ALERT_EMAIL, _process_env_VERCEL_ENV, _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL;
|
|
53
|
+
const configured = String((_process_env_STAFF_ALERT_EMAIL = process.env.STAFF_ALERT_EMAIL) != null ? _process_env_STAFF_ALERT_EMAIL : '').trim().toLowerCase();
|
|
54
|
+
if (configured.includes('@')) return [
|
|
55
|
+
configured
|
|
56
|
+
];
|
|
57
|
+
// A preview or development deployment says what it is; only a production
|
|
58
|
+
// one, or a self-hosted install that sets no such thing, falls back. A
|
|
59
|
+
// preview's alarms reaching the operator's real inbox would be noise.
|
|
60
|
+
const deployment = String((_process_env_VERCEL_ENV = process.env.VERCEL_ENV) != null ? _process_env_VERCEL_ENV : '').trim();
|
|
61
|
+
if (deployment && deployment !== 'production') return [];
|
|
62
|
+
const operator = String((_process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL = process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL) != null ? _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL : '').trim().toLowerCase();
|
|
63
|
+
if (operator.includes('@')) return [
|
|
64
|
+
operator
|
|
65
|
+
];
|
|
66
|
+
try {
|
|
67
|
+
const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import("./auth-pools.js");
|
|
68
|
+
const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX);
|
|
69
|
+
const addresses = new Set();
|
|
70
|
+
for (const uid of uids){
|
|
71
|
+
var _ref;
|
|
72
|
+
var _pooled_record;
|
|
73
|
+
const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
|
|
74
|
+
const address = String((_ref = pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email) != null ? _ref : '').trim().toLowerCase();
|
|
75
|
+
if (address.includes('@')) addresses.add(address);
|
|
76
|
+
}
|
|
77
|
+
return [
|
|
78
|
+
...addresses
|
|
79
|
+
];
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('[staff-alert-email] staff roster unavailable', error);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sends one rendered alert to each operator recipient, one message each so
|
|
87
|
+
* no staff member is shown another's address. Metered to the platform: our
|
|
88
|
+
* own alarm is our own cost (AGL-1438). Never throws.
|
|
89
|
+
*/ async function sendToOperators(content, context) {
|
|
90
|
+
const recipients = await resolveStaffAlertRecipients();
|
|
91
|
+
if (!recipients.length) return {
|
|
40
92
|
sent: false,
|
|
41
93
|
reason: 'unconfigured'
|
|
42
94
|
};
|
|
95
|
+
let delivered = null;
|
|
96
|
+
let failure = {
|
|
97
|
+
sent: false,
|
|
98
|
+
reason: 'unconfigured'
|
|
99
|
+
};
|
|
100
|
+
let sent = 0;
|
|
101
|
+
for (const to of recipients){
|
|
102
|
+
const result = await sendEmail(_extends({
|
|
103
|
+
to
|
|
104
|
+
}, content, {
|
|
105
|
+
context
|
|
106
|
+
}));
|
|
107
|
+
if (result.sent) {
|
|
108
|
+
sent += 1;
|
|
109
|
+
delivered != null ? delivered : delivered = result;
|
|
110
|
+
} else {
|
|
111
|
+
failure = result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (sent) {
|
|
115
|
+
const { meterPlatformEmail } = await import("./email-metering.js");
|
|
116
|
+
await meterPlatformEmail(sent).catch(()=>undefined);
|
|
117
|
+
}
|
|
118
|
+
// Delivered to anyone is delivered: one dead staff mailbox does not make
|
|
119
|
+
// the alert a failure for the caller.
|
|
120
|
+
return delivered != null ? delivered : failure;
|
|
121
|
+
}
|
|
122
|
+
export async function sendStaffAlertEmail(input) {
|
|
43
123
|
try {
|
|
44
124
|
// In the platform's header and footer, as the `staff-alert` system email
|
|
45
|
-
// (AGL-3367). Loaded lazily for the reason the meter
|
|
125
|
+
// (AGL-3367). Loaded lazily for the reason the meter is.
|
|
46
126
|
const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
|
|
47
127
|
const content = await renderSystemEmailContent('staff-alert', {
|
|
48
128
|
'alert.subject': input.subject,
|
|
@@ -51,17 +131,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
51
131
|
subject: input.subject,
|
|
52
132
|
text: input.text
|
|
53
133
|
});
|
|
54
|
-
|
|
55
|
-
to
|
|
56
|
-
}, content, {
|
|
57
|
-
context: input.context
|
|
58
|
-
}));
|
|
59
|
-
if (result.sent) {
|
|
60
|
-
// Platform-scoped (AGL-1438): our own alert is our own cost.
|
|
61
|
-
const { meterPlatformEmail } = await import("./email-metering.js");
|
|
62
|
-
await meterPlatformEmail().catch(()=>undefined);
|
|
63
|
-
}
|
|
64
|
-
return result;
|
|
134
|
+
return await sendToOperators(content, input.context);
|
|
65
135
|
} catch (error) {
|
|
66
136
|
console.error('[staff-alert-email] send failed', error);
|
|
67
137
|
return {
|
|
@@ -70,5 +140,38 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
70
140
|
};
|
|
71
141
|
}
|
|
72
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`
|
|
145
|
+
* system email: the alert, a button to the item it is about, in the
|
|
146
|
+
* operator's own brand. Sent by `notifyStaff` for every
|
|
147
|
+
* `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.
|
|
148
|
+
* Never throws.
|
|
149
|
+
*/ export async function sendOperatorAlertEmail(input) {
|
|
150
|
+
try {
|
|
151
|
+
var _input_body, _input_url;
|
|
152
|
+
const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
|
|
153
|
+
const body = String((_input_body = input.body) != null ? _input_body : '').trim();
|
|
154
|
+
const url = String((_input_url = input.url) != null ? _input_url : '').trim();
|
|
155
|
+
const content = await renderSystemEmailContent('operator-alert', {
|
|
156
|
+
'alert.title': input.title,
|
|
157
|
+
'alert.body': body,
|
|
158
|
+
'alert.url': url
|
|
159
|
+
}, systemEmailBrand(null), {
|
|
160
|
+
subject: input.title,
|
|
161
|
+
text: [
|
|
162
|
+
input.title,
|
|
163
|
+
body,
|
|
164
|
+
url
|
|
165
|
+
].filter(Boolean).join('\n\n')
|
|
166
|
+
});
|
|
167
|
+
return await sendToOperators(content, input.context);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('[staff-alert-email] operator alert failed', error);
|
|
170
|
+
return {
|
|
171
|
+
sent: false,
|
|
172
|
+
reason: 'network'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
73
176
|
|
|
74
177
|
//# sourceMappingURL=staff-alert-email.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * A staff alert by email, from LIBRARY code (AGL-2925).\n *\n * `STAFF_ALERT_EMAIL` is the one inbox every subsystem's alert lands in —\n * the console's `emailStaffAlert` in `apps/console/app/api/_lib/\n * usage-alert-email.ts` sends the cron's alerts there. A library cannot\n * call that, because a library cannot import from an app. So a library's\n * own alert — the AI meter's platform-wide free-spend ceiling is one —\n * sends through this, to the same address, with the same posture: unset is\n * the ordinary answer outside production and reports `unconfigured`, and\n * nothing here throws, because the mail is a courtesy and the refusal it\n * announces is the control.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer, which reads\n * its template through the same admin app, is loaded the same way.\n */\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n const to = String(process.env.STAFF_ALERT_EMAIL ?? '').trim()\n if (!to.includes('@')) return { sent: false, reason: 'unconfigured' }\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter below is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n const result = await sendEmail({ to, ...content, context: input.context })\n if (result.sent) {\n // Platform-scoped (AGL-1438): our own alert is our own cost.\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail().catch(() => undefined)\n }\n return result\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","sendStaffAlertEmail","input","process","to","String","env","STAFF_ALERT_EMAIL","trim","includes","sent","reason","renderSystemEmailContent","systemEmailBrand","content","subject","text","result","context","meterPlatformEmail","catch","undefined","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,QAA8B,2BAA0B;AAE1E;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeC,oBAAoBC,KAKzC;QACmBC;IAAlB,MAAMC,KAAKC,QAAOF,iCAAAA,QAAQG,GAAG,CAACC,iBAAiB,YAA7BJ,iCAAiC,IAAIK,IAAI;IAC3D,IAAI,CAACJ,GAAGK,QAAQ,CAAC,MAAM,OAAO;QAAEC,MAAM;QAAOC,QAAQ;IAAe;IACpE,IAAI;QACF,yEAAyE;QACzE,+DAA+D;QAC/D,MAAM,EAAEC,wBAAwB,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CACjE;QAEF,MAAMC,UAAU,MAAMF,yBACpB,eACA;YAAE,iBAAiBV,MAAMa,OAAO;YAAE,cAAcb,MAAMc,IAAI;QAAC,GAC3DH,iBAAiB,OACjB;YAAEE,SAASb,MAAMa,OAAO;YAAEC,MAAMd,MAAMc,IAAI;QAAC;QAE7C,MAAMC,SAAS,MAAMjB,UAAU;YAAEI;WAAOU;YAASI,SAAShB,MAAMgB,OAAO;;QACvE,IAAID,OAAOP,IAAI,EAAE;YACf,6DAA6D;YAC7D,MAAM,EAAES,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC;YAC5C,MAAMA,qBAAqBC,KAAK,CAAC,IAAMC;QACzC;QACA,OAAOJ;IACT,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAEZ,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`\n * system email: the alert, a button to the item it is about, in the\n * operator's own brand. Sent by `notifyStaff` for every\n * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.\n * Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join"],"mappings":"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"}
|
|
@@ -101,6 +101,7 @@ export function fakeFirestore(seed = {}) {
|
|
|
101
101
|
}),
|
|
102
102
|
// Ordering and limits are not what these specs are about; the queue
|
|
103
103
|
// read just needs to return everything it stored.
|
|
104
|
+
where: ()=>api,
|
|
104
105
|
orderBy: ()=>api,
|
|
105
106
|
limit: ()=>api,
|
|
106
107
|
get: async ()=>({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/test-firestore.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 Firestore stand-in for specs that span MORE THAN ONE COLLECTION\n * (AGL-1592). Test-only; deliberately not exported from the barrel.\n *\n * The one-collection fakes elsewhere in this directory return the same\n * document whatever collection name they are handed, which is fine when the\n * code under test touches one document and actively misleading when it does\n * not: the phone opt-out path reads `users/{uid}` and `contactSuppressions/{n}`\n * and the whole point is that they are different records. A fake that conflated\n * them would pass while the real thing wrote the marker on top of the\n * suppression.\n *\n * `FieldValue.delete()` is honoured for real, because \"the field is gone\" and\n * \"the field holds a sentinel object\" are different states and only the first\n * one makes `seedUserProfile`'s blank check fire.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n\nconst DELETE = FieldValue.delete()\n\nexport interface FakeFirestore {\n /**\n * Every document in a collection, keyed by id.\n *\n * A SUBcollection is addressed by its full path — `hosts/host-1/suppressions`\n * — which is also how it is seeded.\n */\n docs: (collection: string) => Record<string, any>\n collection: (name: string) => any\n /**\n * Several documents in one round trip, the shape the real `getAll` has:\n * the results come back POSITIONALLY, one per reference, so a caller that\n * zips them back against its own input list gets what it asked for.\n */\n getAll: (...refs: any[]) => Promise<any[]>\n /**\n * A transaction, real enough for a read-then-write body.\n *\n * Reads pass straight through and writes are held until the body resolves,\n * which is the property the code under test depends on: a writer that\n * decides what to store FROM what it read must not observe its own\n * half-finished write. It does not model contention or retries — nothing in\n * this repo's transaction bodies branches on a retry, and a fake that\n * pretended to would be asserting the fake.\n */\n runTransaction: <T>(body: (transaction: any) => Promise<T>) => Promise<T>\n}\n\nexport function fakeFirestore(\n seed: Record<string, Record<string, any>> = {},\n): FakeFirestore {\n const store: Record<string, Record<string, any>> = JSON.parse(\n JSON.stringify(seed),\n )\n\n const snapshotFor = (collection: string, id: string) => {\n const data = store[collection]?.[id]\n return {\n id,\n exists: data !== undefined,\n get: (field: string) => data?.[field],\n data: () => data,\n }\n }\n\n const fake: FakeFirestore = {\n docs: (collection: string) => store[collection] ?? {},\n getAll: async (...refs: any[]) =>\n refs.map((ref) => snapshotFor(ref.collectionPath, ref.id)),\n async runTransaction<T>(body: (transaction: any) => Promise<T>): Promise<T> {\n const pending: Array<() => Promise<void>> = []\n const transaction = {\n get: (ref: any) => ref.get(),\n set: (ref: any, data: Record<string, any>, options?: { merge?: boolean }) => {\n pending.push(() => ref.set(data, options))\n return transaction\n },\n }\n const result = await body(transaction)\n for (const write of pending) await write()\n return result\n },\n collection(name: string) {\n store[name] = store[name] ?? {}\n const api: any = {\n doc: (id: string) => ({\n id,\n /**\n * Which collection this reference came from, so `getAll` can resolve\n * a bare reference the way the real client does.\n */\n collectionPath: name,\n /**\n * A subcollection is a collection at the joined path, so the fake\n * stays one flat map. Nesting matters here because a per-site list\n * lives under the site — a fake that returned the same documents for\n * `hosts/a/suppressions` and `hosts/b/suppressions` would certify a\n * sender that leaks one site's unsubscribes into another's.\n */\n collection: (sub: string) => fake.collection(`${name}/${id}/${sub}`),\n get: async () => snapshotFor(name, id),\n set: async (data: Record<string, any>, options?: { merge?: boolean }) => {\n const base = options?.merge ? (store[name][id] ?? {}) : {}\n const next: Record<string, any> = { ...base }\n for (const [key, value] of Object.entries(data)) {\n // A real delete, not a stored sentinel — see the header.\n if (value && typeof value === 'object' && DELETE.isEqual(value as any)) {\n delete next[key]\n continue\n }\n // Server timestamps arrive as sentinels too. Freeze them into\n // something a spec can assert on and a date can be read from.\n next[key] =\n value && typeof value === 'object' && typeof (value as any).isEqual === 'function'\n ? { seconds: Math.floor(Date.now() / 1000) }\n : value\n }\n store[name][id] = next\n },\n }),\n // Ordering and limits are not what these specs are about; the queue\n // read just needs to return everything it stored.\n orderBy: () => api,\n limit: () => api,\n get: async () => ({\n docs: Object.keys(store[name]).map((id) => snapshotFor(name, id)),\n }),\n add: async (data: Record<string, any>) => {\n const id = `auto-${Object.keys(store[name]).length + 1}`\n store[name][id] = data\n return { id }\n },\n }\n return api\n },\n }\n return fake\n}\n\nexport default fakeFirestore\n"],"names":["FieldValue","DELETE","delete","fakeFirestore","seed","store","JSON","parse","stringify","snapshotFor","collection","id","data","exists","undefined","get","field","fake","docs","getAll","refs","map","ref","collectionPath","runTransaction","body","pending","transaction","set","options","push","result","write","name","api","doc","sub","base","merge","next","key","value","Object","entries","isEqual","seconds","Math","floor","Date","now","orderBy","limit","keys","add","length"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AAErD,MAAMC,SAASD,WAAWE,MAAM;AA8BhC,OAAO,SAASC,cACdC,OAA4C,CAAC,CAAC;IAE9C,MAAMC,QAA6CC,KAAKC,KAAK,CAC3DD,KAAKE,SAAS,CAACJ;IAGjB,MAAMK,cAAc,CAACC,YAAoBC;YAC1BN;QAAb,MAAMO,QAAOP,oBAAAA,KAAK,CAACK,WAAW,qBAAjBL,iBAAmB,CAACM,GAAG;QACpC,OAAO;YACLA;YACAE,QAAQD,SAASE;YACjBC,KAAK,CAACC,QAAkBJ,wBAAAA,IAAM,CAACI,MAAM;YACrCJ,MAAM,IAAMA;QACd;IACF;IAEA,MAAMK,OAAsB;QAC1BC,MAAM,CAACR;gBAAuBL;oBAAAA,oBAAAA,KAAK,CAACK,WAAW,YAAjBL,oBAAqB,CAAC;;QACpDc,QAAQ,OAAO,GAAGC,OAChBA,KAAKC,GAAG,CAAC,CAACC,MAAQb,YAAYa,IAAIC,cAAc,EAAED,IAAIX,EAAE;QAC1D,MAAMa,gBAAkBC,IAAsC;YAC5D,MAAMC,UAAsC,EAAE;YAC9C,MAAMC,cAAc;gBAClBZ,KAAK,CAACO,MAAaA,IAAIP,GAAG;gBAC1Ba,KAAK,CAACN,KAAUV,MAA2BiB;oBACzCH,QAAQI,IAAI,CAAC,IAAMR,IAAIM,GAAG,CAAChB,MAAMiB;oBACjC,OAAOF;gBACT;YACF;YACA,MAAMI,SAAS,MAAMN,KAAKE;YAC1B,KAAK,MAAMK,SAASN,QAAS,MAAMM;YACnC,OAAOD;QACT;QACArB,YAAWuB,IAAY;gBACP5B;YAAdA,KAAK,CAAC4B,KAAK,IAAG5B,cAAAA,KAAK,CAAC4B,KAAK,YAAX5B,cAAe,CAAC;YAC9B,MAAM6B,MAAW;gBACfC,KAAK,CAACxB,KAAgB,CAAA;wBACpBA;wBACA;;;WAGC,GACDY,gBAAgBU;wBAChB;;;;;;WAMC,GACDvB,YAAY,CAAC0B,MAAgBnB,KAAKP,UAAU,CAAC,GAAGuB,KAAK,CAAC,EAAEtB,GAAG,CAAC,EAAEyB,KAAK;wBACnErB,KAAK,UAAYN,YAAYwB,MAAMtB;wBACnCiB,KAAK,OAAOhB,MAA2BiB;gCACNxB;4BAA/B,MAAMgC,OAAOR,CAAAA,2BAAAA,QAASS,KAAK,KAAIjC,iBAAAA,KAAK,CAAC4B,KAAK,CAACtB,GAAG,YAAfN,iBAAmB,CAAC,IAAK,CAAC;4BACzD,MAAMkC,OAA4B,aAAKF;4BACvC,KAAK,MAAM,CAACG,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAAC/B,MAAO;gCAC/C,yDAAyD;gCACzD,IAAI6B,SAAS,OAAOA,UAAU,YAAYxC,OAAO2C,OAAO,CAACH,QAAe;oCACtE,OAAOF,IAAI,CAACC,IAAI;oCAChB;gCACF;gCACA,8DAA8D;gCAC9D,8DAA8D;gCAC9DD,IAAI,CAACC,IAAI,GACPC,SAAS,OAAOA,UAAU,YAAY,OAAO,AAACA,MAAcG,OAAO,KAAK,aACpE;oCAAEC,SAASC,KAAKC,KAAK,CAACC,KAAKC,GAAG,KAAK;gCAAM,IACzCR;4BACR;4BACApC,KAAK,CAAC4B,KAAK,CAACtB,GAAG,GAAG4B;wBACpB;oBACF,CAAA;gBACA,oEAAoE;gBACpE,kDAAkD;gBAClDW,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/test-firestore.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 Firestore stand-in for specs that span MORE THAN ONE COLLECTION\n * (AGL-1592). Test-only; deliberately not exported from the barrel.\n *\n * The one-collection fakes elsewhere in this directory return the same\n * document whatever collection name they are handed, which is fine when the\n * code under test touches one document and actively misleading when it does\n * not: the phone opt-out path reads `users/{uid}` and `contactSuppressions/{n}`\n * and the whole point is that they are different records. A fake that conflated\n * them would pass while the real thing wrote the marker on top of the\n * suppression.\n *\n * `FieldValue.delete()` is honoured for real, because \"the field is gone\" and\n * \"the field holds a sentinel object\" are different states and only the first\n * one makes `seedUserProfile`'s blank check fire.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n\nconst DELETE = FieldValue.delete()\n\nexport interface FakeFirestore {\n /**\n * Every document in a collection, keyed by id.\n *\n * A SUBcollection is addressed by its full path — `hosts/host-1/suppressions`\n * — which is also how it is seeded.\n */\n docs: (collection: string) => Record<string, any>\n collection: (name: string) => any\n /**\n * Several documents in one round trip, the shape the real `getAll` has:\n * the results come back POSITIONALLY, one per reference, so a caller that\n * zips them back against its own input list gets what it asked for.\n */\n getAll: (...refs: any[]) => Promise<any[]>\n /**\n * A transaction, real enough for a read-then-write body.\n *\n * Reads pass straight through and writes are held until the body resolves,\n * which is the property the code under test depends on: a writer that\n * decides what to store FROM what it read must not observe its own\n * half-finished write. It does not model contention or retries — nothing in\n * this repo's transaction bodies branches on a retry, and a fake that\n * pretended to would be asserting the fake.\n */\n runTransaction: <T>(body: (transaction: any) => Promise<T>) => Promise<T>\n}\n\nexport function fakeFirestore(\n seed: Record<string, Record<string, any>> = {},\n): FakeFirestore {\n const store: Record<string, Record<string, any>> = JSON.parse(\n JSON.stringify(seed),\n )\n\n const snapshotFor = (collection: string, id: string) => {\n const data = store[collection]?.[id]\n return {\n id,\n exists: data !== undefined,\n get: (field: string) => data?.[field],\n data: () => data,\n }\n }\n\n const fake: FakeFirestore = {\n docs: (collection: string) => store[collection] ?? {},\n getAll: async (...refs: any[]) =>\n refs.map((ref) => snapshotFor(ref.collectionPath, ref.id)),\n async runTransaction<T>(body: (transaction: any) => Promise<T>): Promise<T> {\n const pending: Array<() => Promise<void>> = []\n const transaction = {\n get: (ref: any) => ref.get(),\n set: (ref: any, data: Record<string, any>, options?: { merge?: boolean }) => {\n pending.push(() => ref.set(data, options))\n return transaction\n },\n }\n const result = await body(transaction)\n for (const write of pending) await write()\n return result\n },\n collection(name: string) {\n store[name] = store[name] ?? {}\n const api: any = {\n doc: (id: string) => ({\n id,\n /**\n * Which collection this reference came from, so `getAll` can resolve\n * a bare reference the way the real client does.\n */\n collectionPath: name,\n /**\n * A subcollection is a collection at the joined path, so the fake\n * stays one flat map. Nesting matters here because a per-site list\n * lives under the site — a fake that returned the same documents for\n * `hosts/a/suppressions` and `hosts/b/suppressions` would certify a\n * sender that leaks one site's unsubscribes into another's.\n */\n collection: (sub: string) => fake.collection(`${name}/${id}/${sub}`),\n get: async () => snapshotFor(name, id),\n set: async (data: Record<string, any>, options?: { merge?: boolean }) => {\n const base = options?.merge ? (store[name][id] ?? {}) : {}\n const next: Record<string, any> = { ...base }\n for (const [key, value] of Object.entries(data)) {\n // A real delete, not a stored sentinel — see the header.\n if (value && typeof value === 'object' && DELETE.isEqual(value as any)) {\n delete next[key]\n continue\n }\n // Server timestamps arrive as sentinels too. Freeze them into\n // something a spec can assert on and a date can be read from.\n next[key] =\n value && typeof value === 'object' && typeof (value as any).isEqual === 'function'\n ? { seconds: Math.floor(Date.now() / 1000) }\n : value\n }\n store[name][id] = next\n },\n }),\n // Ordering and limits are not what these specs are about; the queue\n // read just needs to return everything it stored.\n where: () => api,\n orderBy: () => api,\n limit: () => api,\n get: async () => ({\n docs: Object.keys(store[name]).map((id) => snapshotFor(name, id)),\n }),\n add: async (data: Record<string, any>) => {\n const id = `auto-${Object.keys(store[name]).length + 1}`\n store[name][id] = data\n return { id }\n },\n }\n return api\n },\n }\n return fake\n}\n\nexport default fakeFirestore\n"],"names":["FieldValue","DELETE","delete","fakeFirestore","seed","store","JSON","parse","stringify","snapshotFor","collection","id","data","exists","undefined","get","field","fake","docs","getAll","refs","map","ref","collectionPath","runTransaction","body","pending","transaction","set","options","push","result","write","name","api","doc","sub","base","merge","next","key","value","Object","entries","isEqual","seconds","Math","floor","Date","now","where","orderBy","limit","keys","add","length"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AAErD,MAAMC,SAASD,WAAWE,MAAM;AA8BhC,OAAO,SAASC,cACdC,OAA4C,CAAC,CAAC;IAE9C,MAAMC,QAA6CC,KAAKC,KAAK,CAC3DD,KAAKE,SAAS,CAACJ;IAGjB,MAAMK,cAAc,CAACC,YAAoBC;YAC1BN;QAAb,MAAMO,QAAOP,oBAAAA,KAAK,CAACK,WAAW,qBAAjBL,iBAAmB,CAACM,GAAG;QACpC,OAAO;YACLA;YACAE,QAAQD,SAASE;YACjBC,KAAK,CAACC,QAAkBJ,wBAAAA,IAAM,CAACI,MAAM;YACrCJ,MAAM,IAAMA;QACd;IACF;IAEA,MAAMK,OAAsB;QAC1BC,MAAM,CAACR;gBAAuBL;oBAAAA,oBAAAA,KAAK,CAACK,WAAW,YAAjBL,oBAAqB,CAAC;;QACpDc,QAAQ,OAAO,GAAGC,OAChBA,KAAKC,GAAG,CAAC,CAACC,MAAQb,YAAYa,IAAIC,cAAc,EAAED,IAAIX,EAAE;QAC1D,MAAMa,gBAAkBC,IAAsC;YAC5D,MAAMC,UAAsC,EAAE;YAC9C,MAAMC,cAAc;gBAClBZ,KAAK,CAACO,MAAaA,IAAIP,GAAG;gBAC1Ba,KAAK,CAACN,KAAUV,MAA2BiB;oBACzCH,QAAQI,IAAI,CAAC,IAAMR,IAAIM,GAAG,CAAChB,MAAMiB;oBACjC,OAAOF;gBACT;YACF;YACA,MAAMI,SAAS,MAAMN,KAAKE;YAC1B,KAAK,MAAMK,SAASN,QAAS,MAAMM;YACnC,OAAOD;QACT;QACArB,YAAWuB,IAAY;gBACP5B;YAAdA,KAAK,CAAC4B,KAAK,IAAG5B,cAAAA,KAAK,CAAC4B,KAAK,YAAX5B,cAAe,CAAC;YAC9B,MAAM6B,MAAW;gBACfC,KAAK,CAACxB,KAAgB,CAAA;wBACpBA;wBACA;;;WAGC,GACDY,gBAAgBU;wBAChB;;;;;;WAMC,GACDvB,YAAY,CAAC0B,MAAgBnB,KAAKP,UAAU,CAAC,GAAGuB,KAAK,CAAC,EAAEtB,GAAG,CAAC,EAAEyB,KAAK;wBACnErB,KAAK,UAAYN,YAAYwB,MAAMtB;wBACnCiB,KAAK,OAAOhB,MAA2BiB;gCACNxB;4BAA/B,MAAMgC,OAAOR,CAAAA,2BAAAA,QAASS,KAAK,KAAIjC,iBAAAA,KAAK,CAAC4B,KAAK,CAACtB,GAAG,YAAfN,iBAAmB,CAAC,IAAK,CAAC;4BACzD,MAAMkC,OAA4B,aAAKF;4BACvC,KAAK,MAAM,CAACG,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAAC/B,MAAO;gCAC/C,yDAAyD;gCACzD,IAAI6B,SAAS,OAAOA,UAAU,YAAYxC,OAAO2C,OAAO,CAACH,QAAe;oCACtE,OAAOF,IAAI,CAACC,IAAI;oCAChB;gCACF;gCACA,8DAA8D;gCAC9D,8DAA8D;gCAC9DD,IAAI,CAACC,IAAI,GACPC,SAAS,OAAOA,UAAU,YAAY,OAAO,AAACA,MAAcG,OAAO,KAAK,aACpE;oCAAEC,SAASC,KAAKC,KAAK,CAACC,KAAKC,GAAG,KAAK;gCAAM,IACzCR;4BACR;4BACApC,KAAK,CAAC4B,KAAK,CAACtB,GAAG,GAAG4B;wBACpB;oBACF,CAAA;gBACA,oEAAoE;gBACpE,kDAAkD;gBAClDW,OAAO,IAAMhB;gBACbiB,SAAS,IAAMjB;gBACfkB,OAAO,IAAMlB;gBACbnB,KAAK,UAAa,CAAA;wBAChBG,MAAMwB,OAAOW,IAAI,CAAChD,KAAK,CAAC4B,KAAK,EAAEZ,GAAG,CAAC,CAACV,KAAOF,YAAYwB,MAAMtB;oBAC/D,CAAA;gBACA2C,KAAK,OAAO1C;oBACV,MAAMD,KAAK,CAAC,KAAK,EAAE+B,OAAOW,IAAI,CAAChD,KAAK,CAAC4B,KAAK,EAAEsB,MAAM,GAAG,GAAG;oBACxDlD,KAAK,CAAC4B,KAAK,CAACtB,GAAG,GAAGC;oBAClB,OAAO;wBAAED;oBAAG;gBACd;YACF;YACA,OAAOuB;QACT;IACF;IACA,OAAOjB;AACT;AAEA,eAAed,cAAa"}
|
|
@@ -20,6 +20,7 @@ import { firebaseAdmin } from "./firebase-admin.js";
|
|
|
20
20
|
import { countCrmRecords, restampCrmListFieldsAt } from "./crm-records.js";
|
|
21
21
|
import { attributeOrderToEmail } from "./email-revenue-attribution.js";
|
|
22
22
|
import { hostRefusesCaptureForErasure } from "./email-suppression.js";
|
|
23
|
+
import { scheduleCapturedEmailCheck } from "./capture-email-check.js";
|
|
23
24
|
import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
|
|
24
25
|
import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
|
|
25
26
|
import { consentGroupForGrant } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
@@ -117,8 +118,8 @@ import { consentGroupForSite, getOrgForHost, orgDataCollectionForHost } from "./
|
|
|
117
118
|
}
|
|
118
119
|
export async function upsertHostContact(options) {
|
|
119
120
|
try {
|
|
120
|
-
var _options_interaction_atMs, _options_campaignIds, _ref, _contactsRef_parent, _ref1, _ref2;
|
|
121
|
-
var _options_facet, _orgBilling_org;
|
|
121
|
+
var _options_interaction_atMs, _options_campaignIds, _ref, _contactsRef_parent, _ref1, _ref2, _ref3;
|
|
122
|
+
var _options_facet, _orgBilling_org, _contactsRef_parent1;
|
|
122
123
|
const email = normalizeContactEmail(options.email);
|
|
123
124
|
if (!email) return {
|
|
124
125
|
refused: 'invalid-email'
|
|
@@ -246,7 +247,8 @@ export async function upsertHostContact(options) {
|
|
|
246
247
|
* read once and needs no backfill.
|
|
247
248
|
*=========================================*/ const docSnapshot = await findContactByEmail(contactsRef, email);
|
|
248
249
|
if (docSnapshot) {
|
|
249
|
-
var _facet_name, _profile_lifecycleStage;
|
|
250
|
+
var _facet_name, _profile_lifecycleStage, _ref4;
|
|
251
|
+
var _contactsRef_parent2;
|
|
250
252
|
/*
|
|
251
253
|
* MERGED INTO THIS GROUP'S FACET, not into the top of the document.
|
|
252
254
|
*
|
|
@@ -365,6 +367,13 @@ export async function upsertHostContact(options) {
|
|
|
365
367
|
// The merged facet, name and scope are what the Contacts list
|
|
366
368
|
// searches and filters by (AGL-3321): restamped from the stored row.
|
|
367
369
|
await restampCrmListFieldsAt(docSnapshot.ref, 'contacts');
|
|
370
|
+
// The address's domain, checked after the response (AGL-3328).
|
|
371
|
+
scheduleCapturedEmailCheck({
|
|
372
|
+
orgId: (_ref4 = (_contactsRef_parent2 = contactsRef.parent) == null ? void 0 : _contactsRef_parent2.id) != null ? _ref4 : null,
|
|
373
|
+
hostId: options.hostId,
|
|
374
|
+
email,
|
|
375
|
+
predicted: docSnapshot.get('emailStatus') === 'undeliverable'
|
|
376
|
+
});
|
|
368
377
|
return {
|
|
369
378
|
contactId: docSnapshot.id,
|
|
370
379
|
created: false
|
|
@@ -556,6 +565,13 @@ export async function upsertHostContact(options) {
|
|
|
556
565
|
console.error('upsertHostContact onCreated failed', error);
|
|
557
566
|
});
|
|
558
567
|
}
|
|
568
|
+
// A new person's address, checked after the response (AGL-3328): a
|
|
569
|
+
// domain that takes no mail reads "Would bounce" on the record.
|
|
570
|
+
scheduleCapturedEmailCheck({
|
|
571
|
+
orgId: (_ref3 = (_contactsRef_parent1 = contactsRef.parent) == null ? void 0 : _contactsRef_parent1.id) != null ? _ref3 : null,
|
|
572
|
+
hostId: options.hostId,
|
|
573
|
+
email
|
|
574
|
+
});
|
|
559
575
|
return {
|
|
560
576
|
contactId: created.id,
|
|
561
577
|
created: true
|