@aglyn/tenant-data-admin 1.0.0-beta.261 → 1.0.0-beta.263
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/figure-readers.d.ts +44 -0
- package/src/lib/server/figure-readers.js +45 -0
- package/src/lib/server/figure-readers.js.map +1 -0
- package/src/lib/server/keep-alive.d.ts +18 -0
- package/src/lib/server/keep-alive.js +59 -0
- package/src/lib/server/keep-alive.js.map +1 -0
- package/src/lib/server/mobile-push-switch.js +17 -4
- package/src/lib/server/mobile-push-switch.js.map +1 -1
- package/src/lib/server/native-push.js +6 -3
- package/src/lib/server/native-push.js.map +1 -1
- package/src/lib/server/notifications.d.ts +16 -5
- package/src/lib/server/notifications.js +30 -9
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -0
- package/src/lib/server/operator-alerts.js +6 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/operator-health.d.ts +69 -0
- package/src/lib/server/operator-health.js +87 -0
- package/src/lib/server/operator-health.js.map +1 -1
- package/src/lib/server/push-apns.js +3 -1
- package/src/lib/server/push-apns.js.map +1 -1
- package/src/lib/server/push-delivery.d.ts +9 -0
- package/src/lib/server/push-delivery.js +10 -0
- package/src/lib/server/push-delivery.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +7 -1
- package/src/lib/server/render-system-email.js +1 -1
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/sso-domain-policy.d.ts +43 -7
- package/src/lib/server/sso-domain-policy.js +61 -9
- package/src/lib/server/sso-domain-policy.js.map +1 -1
- package/src/lib/server/weekly-summary.d.ts +160 -0
- package/src/lib/server/weekly-summary.js +568 -0
- package/src/lib/server/weekly-summary.js.map +1 -0
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
* was a write per check per instance that nothing reads.
|
|
37
37
|
* The Admin SDK is loaded on the first write rather than at import, so every
|
|
38
38
|
* health route can import this without initializing anything. Never throws.
|
|
39
|
+
*
|
|
40
|
+
* The edge is told once, and a check that STAYS degraded must not be
|
|
41
|
+
* forgotten after it (AGL-3860). `escalateStillDegradedHealth`, run by the
|
|
42
|
+
* operator alerts tick, raises `system.healthStillDegraded` at 1 h and every
|
|
43
|
+
* 2 h after, as Must know from 2 h on.
|
|
39
44
|
*/ async function firestore() {
|
|
40
45
|
const { default: firebaseAdmin } = await import("./firebase-admin.js");
|
|
41
46
|
return firebaseAdmin.app().firestore();
|
|
@@ -138,6 +143,88 @@ export async function recordHealthState(checkId, status, detail, options = {}) {
|
|
|
138
143
|
return [];
|
|
139
144
|
}
|
|
140
145
|
}
|
|
146
|
+
/** The first still-degraded reminder, after this long degraded. */ export const HEALTH_ESCALATION_FIRST_MS = 60 * 60000;
|
|
147
|
+
/** Each reminder after the first, this long after the one before. */ export const HEALTH_ESCALATION_EVERY_MS = 2 * 60 * 60000;
|
|
148
|
+
/** From this long degraded on, a reminder is Must know. */ export const HEALTH_ESCALATION_MUST_AFTER_MS = 2 * 60 * 60000;
|
|
149
|
+
/**
|
|
150
|
+
* The reminder a check degraded this long is owed: 0 before 1 h, 1 from
|
|
151
|
+
* 1 h, 2 from 3 h, 3 from 5 h. A tick that missed a step owes only the
|
|
152
|
+
* latest, so a sweep that was down for hours tells it once, not N times.
|
|
153
|
+
*/ export function healthEscalationStep(degradedForMs) {
|
|
154
|
+
if (!(degradedForMs >= HEALTH_ESCALATION_FIRST_MS)) return 0;
|
|
155
|
+
return 1 + Math.floor((degradedForMs - HEALTH_ESCALATION_FIRST_MS) / HEALTH_ESCALATION_EVERY_MS);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Raises `system.healthStillDegraded` for every recorded check that has
|
|
159
|
+
* been degraded long enough to be owed its next reminder (AGL-3860). The
|
|
160
|
+
* step is claimed in a transaction on the check's own state document, and
|
|
161
|
+
* only while the document still holds the same incident (`sinceMs`), so any
|
|
162
|
+
* number of ticks on any number of instances tell each step once, and a
|
|
163
|
+
* check that recovered in between is not told at all. Never throws.
|
|
164
|
+
*/ export async function escalateStillDegradedHealth(options = {}) {
|
|
165
|
+
var _options_now, _options_states;
|
|
166
|
+
const now = (_options_now = options.now) != null ? _options_now : Date.now();
|
|
167
|
+
const states = (_options_states = options.states) != null ? _options_states : await listHealthStates();
|
|
168
|
+
const told = [];
|
|
169
|
+
for (const state of states){
|
|
170
|
+
var _state_escalatedStep;
|
|
171
|
+
if ((state == null ? void 0 : state.status) !== 'degraded' || !state.checkId) continue;
|
|
172
|
+
const sinceMs = Number(state.sinceMs);
|
|
173
|
+
if (!Number.isFinite(sinceMs)) continue;
|
|
174
|
+
const degradedForMs = now - sinceMs;
|
|
175
|
+
const step = healthEscalationStep(degradedForMs);
|
|
176
|
+
if (!step || ((_state_escalatedStep = state.escalatedStep) != null ? _state_escalatedStep : 0) >= step) continue;
|
|
177
|
+
const tier = degradedForMs >= HEALTH_ESCALATION_MUST_AFTER_MS ? 'must' : 'should';
|
|
178
|
+
if (options.dryRun) {
|
|
179
|
+
told.push({
|
|
180
|
+
checkId: state.checkId,
|
|
181
|
+
step,
|
|
182
|
+
tier,
|
|
183
|
+
outcome: 'dry-run'
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
var _state_label, _state_detail;
|
|
189
|
+
const db = await firestore();
|
|
190
|
+
const ref = db.collection(OPERATOR_HEALTH_COLLECTION).doc(state.checkId);
|
|
191
|
+
const claimed = await db.runTransaction(async (transaction)=>{
|
|
192
|
+
var _current_escalatedStep;
|
|
193
|
+
const snapshot = await transaction.get(ref);
|
|
194
|
+
const current = snapshot.exists ? snapshot.data() : null;
|
|
195
|
+
if ((current == null ? void 0 : current.status) !== 'degraded' || current.sinceMs !== sinceMs || ((_current_escalatedStep = current.escalatedStep) != null ? _current_escalatedStep : 0) >= step) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
transaction.update(ref, {
|
|
199
|
+
escalatedStep: step,
|
|
200
|
+
lastEscalatedMs: now
|
|
201
|
+
});
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
if (!claimed) continue;
|
|
205
|
+
const { raiseOperatorAlert } = await import("./operator-alerts.js");
|
|
206
|
+
const result = await raiseOperatorAlert('system.healthStillDegraded', {
|
|
207
|
+
dedupeKey: `${state.checkId}:${step}`,
|
|
208
|
+
tier,
|
|
209
|
+
context: {
|
|
210
|
+
check: String((_state_label = state.label) != null ? _state_label : '').trim() || state.checkId,
|
|
211
|
+
duration: describeHealthDuration(degradedForMs),
|
|
212
|
+
detail: String((_state_detail = state.detail) != null ? _state_detail : '').trim()
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
told.push({
|
|
216
|
+
checkId: state.checkId,
|
|
217
|
+
step,
|
|
218
|
+
tier,
|
|
219
|
+
outcome: result.outcome
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// The next tick tries the same step again: it was never claimed.
|
|
223
|
+
console.error(`[operator-health] ${state.checkId} could not be escalated`, error);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return told;
|
|
227
|
+
}
|
|
141
228
|
/**
|
|
142
229
|
* One health endpoint's body as a state: `status` is the endpoint's own
|
|
143
230
|
* verdict, and the detail names each failing check and its code.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/operator-health.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 * HEALTH TRANSITIONS (AGL-3377): the health checks, remembered.\n *\n * Every `/api/health/*` endpoint answers 200 or 503 to whoever asks and\n * forgets the answer. An install with an external uptime monitor hears the\n * 503; an install without one — most self-hosted ones — hears nothing, and\n * a dead cron or a stale backup is found on the day it is needed.\n *\n * `recordHealthState` keeps the last verdict per check in\n * `operatorHealthState/{checkId}` and raises an operator alert on the EDGE:\n * `system.healthDegraded` when a healthy (or never-seen) check goes\n * degraded, `system.healthRecovered` when it comes back. Once each, however\n * many instances and pollers see the same state, because the edge is decided\n * inside a transaction on the check's document.\n *\n * Cheap on the hot path: each instance remembers the last state it wrote, so\n * a poll that finds nothing changed costs no Firestore round trip at all. An\n * instance's FIRST poll of a check reads the document outside a transaction\n * and writes nothing when the stored status already agrees: serverless\n * instances come and go all day, and rewriting `detail` on every cold start\n * was a write per check per instance that nothing reads.\n * The Admin SDK is loaded on the first write rather than at import, so every\n * health route can import this without initializing anything. Never throws.\n */\n\nasync function firestore(): Promise<any> {\n const { default: firebaseAdmin } = await import('./firebase-admin')\n return firebaseAdmin.app().firestore()\n}\n\nexport const OPERATOR_HEALTH_COLLECTION = 'operatorHealthState'\n\nexport type OperatorHealthStatus = 'ok' | 'degraded'\n\nexport interface OperatorHealthStateDoc {\n checkId: string\n label: string\n status: OperatorHealthStatus\n /** When the check entered `status`. */\n sinceMs: number\n detail: string\n updatedAtMs: number\n}\n\nexport type OperatorHealthTransition = 'degraded' | 'recovered' | null\n\n/** The last status this instance wrote per check. */\nconst lastWritten = new Map<string, OperatorHealthStatus>()\n\n/** Test seam. */\nexport function resetOperatorHealthCacheForTests(): void {\n lastWritten.clear()\n}\n\n/** \"3 h 5 min\", for a recovery's duration. */\nexport function describeHealthDuration(ms: number): string {\n const minutes = Math.max(1, Math.round(ms / 60_000))\n if (minutes < 60) return `${minutes} min`\n const hours = Math.floor(minutes / 60)\n const rest = minutes % 60\n if (hours < 48) return rest ? `${hours} h ${rest} min` : `${hours} h`\n return `${Math.round(hours / 24)} days`\n}\n\nexport async function recordHealthState(\n checkId: string,\n status: OperatorHealthStatus,\n detail: string,\n options: { label?: string; now?: number } = {},\n): Promise<OperatorHealthTransition> {\n const id = String(checkId ?? '').trim()\n if (!id) return null\n if (lastWritten.get(id) === status) return null\n const now = options.now ?? Date.now()\n const label = String(options.label ?? '').trim() || id\n const summary = String(detail ?? '').trim().slice(0, 1_000)\n try {\n const db = await firestore()\n const ref = db.collection(OPERATOR_HEALTH_COLLECTION).doc(id)\n const stored = await ref.get()\n if (stored.exists && stored.get('status') === status) {\n lastWritten.set(id, status)\n return null\n }\n const edge = await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const prior = (snapshot.exists ? snapshot.data() : null) as\n | Partial<OperatorHealthStateDoc>\n | null\n if (prior?.status === status) {\n return { transition: null, priorSinceMs: prior.sinceMs ?? now }\n }\n const next: OperatorHealthStateDoc = {\n checkId: id,\n label,\n status,\n sinceMs: now,\n detail: summary,\n updatedAtMs: now,\n }\n transaction.set(ref, next)\n // A check first seen healthy is the ordinary case and says nothing; a\n // check first seen degraded is an install that was broken before\n // anybody watched, which is exactly the one to say out loud.\n const transition: OperatorHealthTransition =\n status === 'degraded' ? 'degraded' : prior ? 'recovered' : null\n return { transition, priorSinceMs: prior?.sinceMs ?? now }\n })\n lastWritten.set(id, status)\n if (edge.transition) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n if (edge.transition === 'degraded') {\n await raiseOperatorAlert('system.healthDegraded', {\n dedupeKey: id,\n context: { check: label, detail: summary },\n })\n } else {\n await raiseOperatorAlert('system.healthRecovered', {\n dedupeKey: id,\n context: {\n check: label,\n duration: describeHealthDuration(now - edge.priorSinceMs),\n },\n })\n }\n }\n return edge.transition\n } catch (error) {\n // Once per verdict per instance: a store that is down must not turn\n // every poll of a public endpoint into another log line. The next\n // change of verdict tries again.\n lastWritten.set(id, status)\n console.error(`[operator-health] ${id} could not be recorded`, error)\n return null\n }\n}\n\n/** Every recorded check, for the staff page and the sweep's report. */\nexport async function listHealthStates(): Promise<OperatorHealthStateDoc[]> {\n try {\n const snapshot = await (await firestore()).collection(OPERATOR_HEALTH_COLLECTION).get()\n return snapshot.docs.map((doc: any) => doc.data() as OperatorHealthStateDoc)\n } catch (error) {\n console.error('[operator-health] states unreadable', error)\n return []\n }\n}\n\n/**\n * One health endpoint's body as a state: `status` is the endpoint's own\n * verdict, and the detail names each failing check and its code.\n */\nexport function healthStateOfBody(\n body: Record<string, unknown> | null,\n httpStatus: number | null,\n): { status: OperatorHealthStatus; detail: string } {\n if (!body) {\n return {\n status: 'degraded',\n detail:\n httpStatus === null\n ? 'The endpoint did not answer.'\n : `The endpoint answered ${httpStatus} with no readable body.`,\n }\n }\n const checks = (body['checks'] ?? {}) as Record<string, Record<string, unknown> | undefined>\n const failing = Object.entries(checks)\n .filter(([, check]) => check && check['ok'] !== true)\n .map(([name, check]) =>\n typeof check?.['code'] === 'string' ? `${name} (${check['code']})` : name,\n )\n const degraded =\n body['status'] === 'degraded' ||\n (body['status'] !== 'ok' && (httpStatus === 503 || failing.length > 0))\n return {\n status: degraded ? 'degraded' : 'ok',\n detail: degraded\n ? failing.length\n ? `Failing: ${failing.join(', ')}.`\n : `The endpoint answered ${httpStatus ?? 'nothing'}.`\n : 'All checks pass.',\n }\n}\n\n/**\n * What each health endpoint is called on the board and in an alert, keyed by\n * the `service` its body names. An unlisted service is called by its name.\n */\nexport const HEALTH_SERVICE_LABELS: Readonly<Record<string, string>> = {\n console: 'Console serving',\n 'console-auth-doors': 'Ways in',\n 'console-backups': 'Backups & exports',\n 'console-billing': 'Billing webhook',\n 'console-crons': 'Scheduled jobs',\n 'console-error-beacon': 'Console error beacon',\n 'console-journeys': 'Console journeys',\n 'console-rate-limits': 'Rate limiters',\n 'console-server-errors': 'Server errors',\n 'console-signup-volume': 'Signup volume',\n 'console-email': 'Email delivery',\n tenant: 'Site serving',\n 'tenant-error-beacon': 'Site error beacon',\n 'tenant-funnel': 'Site capture funnel',\n 'tenant-render-site': 'Site rendering',\n 'tenant-render-marketing': 'Marketing site rendering',\n}\n\n/**\n * Records a health endpoint's own response and hands it back unchanged\n * (AGL-3377): `return recordHealthResponse(Response.json(healthBody(…)))`.\n * The check id is the body's `service`, so the endpoint, the scheduled sweep\n * and any other poller all write the same state. Costs nothing while the\n * verdict is the one this instance last wrote. Never throws.\n */\nexport async function recordHealthResponse(response: Response): Promise<Response> {\n try {\n const body = (await response.clone().json()) as Record<string, unknown> | null\n const service = String(body?.['service'] ?? '').trim()\n if (service) {\n const { status, detail } = healthStateOfBody(body, response.status)\n await recordHealthState(service, status, detail, {\n label: HEALTH_SERVICE_LABELS[service] ?? service,\n })\n }\n } catch (error) {\n console.error('[operator-health] a health response could not be recorded', error)\n }\n return response\n}\n"],"names":["firestore","default","firebaseAdmin","app","OPERATOR_HEALTH_COLLECTION","lastWritten","Map","resetOperatorHealthCacheForTests","clear","describeHealthDuration","ms","minutes","Math","max","round","hours","floor","rest","recordHealthState","checkId","status","detail","options","id","String","trim","get","now","Date","label","summary","slice","db","ref","collection","doc","stored","exists","set","edge","runTransaction","transaction","snapshot","prior","data","transition","priorSinceMs","sinceMs","next","updatedAtMs","raiseOperatorAlert","dedupeKey","context","check","duration","error","console","listHealthStates","docs","map","healthStateOfBody","body","httpStatus","checks","failing","Object","entries","filter","name","degraded","length","join","HEALTH_SERVICE_LABELS","tenant","recordHealthResponse","response","clone","json","service"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/operator-health.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 * HEALTH TRANSITIONS (AGL-3377): the health checks, remembered.\n *\n * Every `/api/health/*` endpoint answers 200 or 503 to whoever asks and\n * forgets the answer. An install with an external uptime monitor hears the\n * 503; an install without one — most self-hosted ones — hears nothing, and\n * a dead cron or a stale backup is found on the day it is needed.\n *\n * `recordHealthState` keeps the last verdict per check in\n * `operatorHealthState/{checkId}` and raises an operator alert on the EDGE:\n * `system.healthDegraded` when a healthy (or never-seen) check goes\n * degraded, `system.healthRecovered` when it comes back. Once each, however\n * many instances and pollers see the same state, because the edge is decided\n * inside a transaction on the check's document.\n *\n * Cheap on the hot path: each instance remembers the last state it wrote, so\n * a poll that finds nothing changed costs no Firestore round trip at all. An\n * instance's FIRST poll of a check reads the document outside a transaction\n * and writes nothing when the stored status already agrees: serverless\n * instances come and go all day, and rewriting `detail` on every cold start\n * was a write per check per instance that nothing reads.\n * The Admin SDK is loaded on the first write rather than at import, so every\n * health route can import this without initializing anything. Never throws.\n *\n * The edge is told once, and a check that STAYS degraded must not be\n * forgotten after it (AGL-3860). `escalateStillDegradedHealth`, run by the\n * operator alerts tick, raises `system.healthStillDegraded` at 1 h and every\n * 2 h after, as Must know from 2 h on.\n */\n\nimport type { OperatorAlertOutcome } from './operator-alerts'\n\nasync function firestore(): Promise<any> {\n const { default: firebaseAdmin } = await import('./firebase-admin')\n return firebaseAdmin.app().firestore()\n}\n\nexport const OPERATOR_HEALTH_COLLECTION = 'operatorHealthState'\n\nexport type OperatorHealthStatus = 'ok' | 'degraded'\n\nexport interface OperatorHealthStateDoc {\n checkId: string\n label: string\n status: OperatorHealthStatus\n /** When the check entered `status`. */\n sinceMs: number\n detail: string\n updatedAtMs: number\n /**\n * The last still-degraded reminder told for THIS incident (AGL-3860), as\n * its step: 1 at 1 h, 2 at 3 h, 3 at 5 h. A transition rewrites the whole\n * document, so a new incident starts again from none.\n */\n escalatedStep?: number\n lastEscalatedMs?: number\n}\n\nexport type OperatorHealthTransition = 'degraded' | 'recovered' | null\n\n/** The last status this instance wrote per check. */\nconst lastWritten = new Map<string, OperatorHealthStatus>()\n\n/** Test seam. */\nexport function resetOperatorHealthCacheForTests(): void {\n lastWritten.clear()\n}\n\n/** \"3 h 5 min\", for a recovery's duration. */\nexport function describeHealthDuration(ms: number): string {\n const minutes = Math.max(1, Math.round(ms / 60_000))\n if (minutes < 60) return `${minutes} min`\n const hours = Math.floor(minutes / 60)\n const rest = minutes % 60\n if (hours < 48) return rest ? `${hours} h ${rest} min` : `${hours} h`\n return `${Math.round(hours / 24)} days`\n}\n\nexport async function recordHealthState(\n checkId: string,\n status: OperatorHealthStatus,\n detail: string,\n options: { label?: string; now?: number } = {},\n): Promise<OperatorHealthTransition> {\n const id = String(checkId ?? '').trim()\n if (!id) return null\n if (lastWritten.get(id) === status) return null\n const now = options.now ?? Date.now()\n const label = String(options.label ?? '').trim() || id\n const summary = String(detail ?? '').trim().slice(0, 1_000)\n try {\n const db = await firestore()\n const ref = db.collection(OPERATOR_HEALTH_COLLECTION).doc(id)\n const stored = await ref.get()\n if (stored.exists && stored.get('status') === status) {\n lastWritten.set(id, status)\n return null\n }\n const edge = await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const prior = (snapshot.exists ? snapshot.data() : null) as\n | Partial<OperatorHealthStateDoc>\n | null\n if (prior?.status === status) {\n return { transition: null, priorSinceMs: prior.sinceMs ?? now }\n }\n const next: OperatorHealthStateDoc = {\n checkId: id,\n label,\n status,\n sinceMs: now,\n detail: summary,\n updatedAtMs: now,\n }\n transaction.set(ref, next)\n // A check first seen healthy is the ordinary case and says nothing; a\n // check first seen degraded is an install that was broken before\n // anybody watched, which is exactly the one to say out loud.\n const transition: OperatorHealthTransition =\n status === 'degraded' ? 'degraded' : prior ? 'recovered' : null\n return { transition, priorSinceMs: prior?.sinceMs ?? now }\n })\n lastWritten.set(id, status)\n if (edge.transition) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n if (edge.transition === 'degraded') {\n await raiseOperatorAlert('system.healthDegraded', {\n dedupeKey: id,\n context: { check: label, detail: summary },\n })\n } else {\n await raiseOperatorAlert('system.healthRecovered', {\n dedupeKey: id,\n context: {\n check: label,\n duration: describeHealthDuration(now - edge.priorSinceMs),\n },\n })\n }\n }\n return edge.transition\n } catch (error) {\n // Once per verdict per instance: a store that is down must not turn\n // every poll of a public endpoint into another log line. The next\n // change of verdict tries again.\n lastWritten.set(id, status)\n console.error(`[operator-health] ${id} could not be recorded`, error)\n return null\n }\n}\n\n/** Every recorded check, for the staff page and the sweep's report. */\nexport async function listHealthStates(): Promise<OperatorHealthStateDoc[]> {\n try {\n const snapshot = await (await firestore()).collection(OPERATOR_HEALTH_COLLECTION).get()\n return snapshot.docs.map((doc: any) => doc.data() as OperatorHealthStateDoc)\n } catch (error) {\n console.error('[operator-health] states unreadable', error)\n return []\n }\n}\n\n/** The first still-degraded reminder, after this long degraded. */\nexport const HEALTH_ESCALATION_FIRST_MS = 60 * 60_000\n/** Each reminder after the first, this long after the one before. */\nexport const HEALTH_ESCALATION_EVERY_MS = 2 * 60 * 60_000\n/** From this long degraded on, a reminder is Must know. */\nexport const HEALTH_ESCALATION_MUST_AFTER_MS = 2 * 60 * 60_000\n\n/**\n * The reminder a check degraded this long is owed: 0 before 1 h, 1 from\n * 1 h, 2 from 3 h, 3 from 5 h. A tick that missed a step owes only the\n * latest, so a sweep that was down for hours tells it once, not N times.\n */\nexport function healthEscalationStep(degradedForMs: number): number {\n if (!(degradedForMs >= HEALTH_ESCALATION_FIRST_MS)) return 0\n return 1 + Math.floor((degradedForMs - HEALTH_ESCALATION_FIRST_MS) / HEALTH_ESCALATION_EVERY_MS)\n}\n\nexport interface HealthEscalation {\n checkId: string\n step: number\n tier: 'should' | 'must'\n /** What raising it did; `dry-run` when nothing was raised on purpose. */\n outcome: OperatorAlertOutcome | 'dry-run'\n}\n\n/**\n * Raises `system.healthStillDegraded` for every recorded check that has\n * been degraded long enough to be owed its next reminder (AGL-3860). The\n * step is claimed in a transaction on the check's own state document, and\n * only while the document still holds the same incident (`sinceMs`), so any\n * number of ticks on any number of instances tell each step once, and a\n * check that recovered in between is not told at all. Never throws.\n */\nexport async function escalateStillDegradedHealth(\n options: { now?: number; dryRun?: boolean; states?: readonly OperatorHealthStateDoc[] } = {},\n): Promise<HealthEscalation[]> {\n const now = options.now ?? Date.now()\n const states = options.states ?? (await listHealthStates())\n const told: HealthEscalation[] = []\n for (const state of states) {\n if (state?.status !== 'degraded' || !state.checkId) continue\n const sinceMs = Number(state.sinceMs)\n if (!Number.isFinite(sinceMs)) continue\n const degradedForMs = now - sinceMs\n const step = healthEscalationStep(degradedForMs)\n if (!step || (state.escalatedStep ?? 0) >= step) continue\n const tier = degradedForMs >= HEALTH_ESCALATION_MUST_AFTER_MS ? 'must' : 'should'\n if (options.dryRun) {\n told.push({ checkId: state.checkId, step, tier, outcome: 'dry-run' })\n continue\n }\n try {\n const db = await firestore()\n const ref = db.collection(OPERATOR_HEALTH_COLLECTION).doc(state.checkId)\n const claimed = await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const current = (snapshot.exists ? snapshot.data() : null) as\n | Partial<OperatorHealthStateDoc>\n | null\n if (\n current?.status !== 'degraded' ||\n current.sinceMs !== sinceMs ||\n (current.escalatedStep ?? 0) >= step\n ) {\n return false\n }\n transaction.update(ref, { escalatedStep: step, lastEscalatedMs: now })\n return true\n })\n if (!claimed) continue\n const { raiseOperatorAlert } = await import('./operator-alerts')\n const result = await raiseOperatorAlert('system.healthStillDegraded', {\n dedupeKey: `${state.checkId}:${step}`,\n tier,\n context: {\n check: String(state.label ?? '').trim() || state.checkId,\n duration: describeHealthDuration(degradedForMs),\n detail: String(state.detail ?? '').trim(),\n },\n })\n told.push({ checkId: state.checkId, step, tier, outcome: result.outcome })\n } catch (error) {\n // The next tick tries the same step again: it was never claimed.\n console.error(`[operator-health] ${state.checkId} could not be escalated`, error)\n }\n }\n return told\n}\n\n/**\n * One health endpoint's body as a state: `status` is the endpoint's own\n * verdict, and the detail names each failing check and its code.\n */\nexport function healthStateOfBody(\n body: Record<string, unknown> | null,\n httpStatus: number | null,\n): { status: OperatorHealthStatus; detail: string } {\n if (!body) {\n return {\n status: 'degraded',\n detail:\n httpStatus === null\n ? 'The endpoint did not answer.'\n : `The endpoint answered ${httpStatus} with no readable body.`,\n }\n }\n const checks = (body['checks'] ?? {}) as Record<string, Record<string, unknown> | undefined>\n const failing = Object.entries(checks)\n .filter(([, check]) => check && check['ok'] !== true)\n .map(([name, check]) =>\n typeof check?.['code'] === 'string' ? `${name} (${check['code']})` : name,\n )\n const degraded =\n body['status'] === 'degraded' ||\n (body['status'] !== 'ok' && (httpStatus === 503 || failing.length > 0))\n return {\n status: degraded ? 'degraded' : 'ok',\n detail: degraded\n ? failing.length\n ? `Failing: ${failing.join(', ')}.`\n : `The endpoint answered ${httpStatus ?? 'nothing'}.`\n : 'All checks pass.',\n }\n}\n\n/**\n * What each health endpoint is called on the board and in an alert, keyed by\n * the `service` its body names. An unlisted service is called by its name.\n */\nexport const HEALTH_SERVICE_LABELS: Readonly<Record<string, string>> = {\n console: 'Console serving',\n 'console-auth-doors': 'Ways in',\n 'console-backups': 'Backups & exports',\n 'console-billing': 'Billing webhook',\n 'console-crons': 'Scheduled jobs',\n 'console-error-beacon': 'Console error beacon',\n 'console-journeys': 'Console journeys',\n 'console-rate-limits': 'Rate limiters',\n 'console-server-errors': 'Server errors',\n 'console-signup-volume': 'Signup volume',\n 'console-email': 'Email delivery',\n tenant: 'Site serving',\n 'tenant-error-beacon': 'Site error beacon',\n 'tenant-funnel': 'Site capture funnel',\n 'tenant-render-site': 'Site rendering',\n 'tenant-render-marketing': 'Marketing site rendering',\n}\n\n/**\n * Records a health endpoint's own response and hands it back unchanged\n * (AGL-3377): `return recordHealthResponse(Response.json(healthBody(…)))`.\n * The check id is the body's `service`, so the endpoint, the scheduled sweep\n * and any other poller all write the same state. Costs nothing while the\n * verdict is the one this instance last wrote. Never throws.\n */\nexport async function recordHealthResponse(response: Response): Promise<Response> {\n try {\n const body = (await response.clone().json()) as Record<string, unknown> | null\n const service = String(body?.['service'] ?? '').trim()\n if (service) {\n const { status, detail } = healthStateOfBody(body, response.status)\n await recordHealthState(service, status, detail, {\n label: HEALTH_SERVICE_LABELS[service] ?? service,\n })\n }\n } catch (error) {\n console.error('[operator-health] a health response could not be recorded', error)\n }\n return response\n}\n"],"names":["firestore","default","firebaseAdmin","app","OPERATOR_HEALTH_COLLECTION","lastWritten","Map","resetOperatorHealthCacheForTests","clear","describeHealthDuration","ms","minutes","Math","max","round","hours","floor","rest","recordHealthState","checkId","status","detail","options","id","String","trim","get","now","Date","label","summary","slice","db","ref","collection","doc","stored","exists","set","edge","runTransaction","transaction","snapshot","prior","data","transition","priorSinceMs","sinceMs","next","updatedAtMs","raiseOperatorAlert","dedupeKey","context","check","duration","error","console","listHealthStates","docs","map","HEALTH_ESCALATION_FIRST_MS","HEALTH_ESCALATION_EVERY_MS","HEALTH_ESCALATION_MUST_AFTER_MS","healthEscalationStep","degradedForMs","escalateStillDegradedHealth","states","told","state","Number","isFinite","step","escalatedStep","tier","dryRun","push","outcome","claimed","current","update","lastEscalatedMs","result","healthStateOfBody","body","httpStatus","checks","failing","Object","entries","filter","name","degraded","length","join","HEALTH_SERVICE_LABELS","tenant","recordHealthResponse","response","clone","json","service"],"mappings":"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"}
|
|
@@ -181,7 +181,9 @@ export function createApnsTransport(options) {
|
|
|
181
181
|
authorization: `bearer ${providerToken()}`,
|
|
182
182
|
'apns-topic': target.topic,
|
|
183
183
|
'apns-push-type': 'alert',
|
|
184
|
-
|
|
184
|
+
// 10 for every alert: 5 lets the device hold, batch or drop it to save power, which is
|
|
185
|
+
// why native pushes arrived late or never while the console showed them at once.
|
|
186
|
+
'apns-priority': '10',
|
|
185
187
|
'content-type': 'application/json'
|
|
186
188
|
}, body);
|
|
187
189
|
// An expired or refused provider token is ours to mint again, not the device's fault.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-apns.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 * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to\n * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development\n * build's token), authenticated with a provider token, an ES256 JWT signed\n * with the team's APNs key.\n *\n * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded\n * APNS_KEY_ID the key's 10-character id\n * APNS_TEAM_ID the Apple Developer team id\n *\n * Without all three the transport is off: a device on APNs is skipped and\n * that is logged once per process. A provider token is reused for up to 50\n * minutes (Apple refuses one older than an hour, and one refreshed more often\n * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the\n * token is gone, and the row is pruned.\n *\n * Server-only, imported by `push-delivery.ts` only when a device is on APNs.\n */\n\nimport { createPrivateKey, sign, type KeyObject } from 'node:crypto'\nimport { connect, type ClientHttp2Session } from 'node:http2'\nimport type { PushMessage, PushOutcome, PushTarget, PushTransport } from './push-delivery'\n\nexport const APNS_HOSTS = {\n production: 'https://api.push.apple.com',\n sandbox: 'https://api.sandbox.push.apple.com',\n} as const\n\n/** A provider token is reused for at most this long. */\nexport const APNS_TOKEN_TTL_MS = 50 * 60 * 1000\n\n/** Requests in flight at once on one connection. */\nconst CONCURRENCY = 20\n\nexport interface ApnsCredentials {\n key: KeyObject\n keyId: string\n teamId: string\n}\n\nexport interface ApnsResponse {\n status: number\n body: string\n}\n\n/** One HTTP/2 POST to an APNs origin; injectable for tests. */\nexport type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>\n\nconst base64url = (input: Buffer | string) => Buffer.from(input).toString('base64url')\n\n/** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */\nexport function parseApnsKey(raw: string): KeyObject {\n const text = raw.trim().replace(/\\\\n/g, '\\n')\n if (text.includes('-----BEGIN')) return createPrivateKey(text)\n const decoded = Buffer.from(text, 'base64')\n const asText = decoded.toString('utf8')\n if (asText.includes('-----BEGIN')) return createPrivateKey(asText)\n return createPrivateKey({ key: decoded, format: 'der', type: 'pkcs8' })\n}\n\nexport function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null {\n const raw = env['APNS_KEY_P8']?.trim()\n const keyId = env['APNS_KEY_ID']?.trim()\n const teamId = env['APNS_TEAM_ID']?.trim()\n if (!raw || !keyId || !teamId) return null\n return { key: parseApnsKey(raw), keyId, teamId }\n}\n\n/** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */\nexport function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: credentials.keyId }))\n const claims = base64url(JSON.stringify({ iss: credentials.teamId, iat: Math.floor(nowMs / 1000) }))\n const signature = sign('sha256', Buffer.from(`${header}.${claims}`), {\n key: credentials.key,\n dsaEncoding: 'ieee-p1363',\n })\n return `${header}.${claims}.${base64url(signature)}`\n}\n\n/** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */\nexport function apnsPayload(message: PushMessage): string {\n return JSON.stringify({\n aps: {\n alert: { title: message.title, ...(message.body ? { body: message.body } : {}) },\n sound: 'default',\n },\n ...message.data,\n })\n}\n\nconst GONE = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])\n\nexport function apnsOutcome(response: ApnsResponse): PushOutcome {\n if (response.status === 200) return 'sent'\n if (response.status === 410) return 'prune'\n let reason = ''\n try {\n reason = String(JSON.parse(response.body)?.reason ?? '')\n } catch {\n // An unreadable body is a failure, not a verdict on the token.\n }\n return GONE.has(reason) ? 'prune' : 'failed'\n}\n\n/** The default request: one HTTP/2 session per origin for the life of a send. */\nfunction http2Requester(): { request: ApnsRequest; close: () => void } {\n const sessions = new Map<string, ClientHttp2Session>()\n const session = (origin: string) => {\n let open = sessions.get(origin)\n if (!open || open.closed || open.destroyed) {\n open = connect(origin)\n open.on('error', () => undefined)\n sessions.set(origin, open)\n }\n return open\n }\n const request: ApnsRequest = (origin, headers, body) =>\n new Promise((resolve, reject) => {\n const stream = session(origin).request({ ':method': 'POST', ...headers })\n let status = 0\n let text = ''\n stream.setEncoding('utf8')\n stream.setTimeout(15_000, () => stream.close())\n stream.on('response', (head) => {\n status = Number(head[':status'] ?? 0)\n })\n stream.on('data', (chunk: string) => {\n text += chunk\n })\n stream.on('end', () => resolve({ status, body: text }))\n stream.on('error', reject)\n stream.end(body)\n })\n return {\n request,\n close: () => {\n for (const open of sessions.values()) open.close()\n sessions.clear()\n },\n }\n}\n\nexport interface ApnsTransportOptions {\n credentials: ApnsCredentials\n /** Injected in tests; the default opens HTTP/2 sessions per send. */\n request?: ApnsRequest\n now?: () => number\n}\n\nexport function createApnsTransport(options: ApnsTransportOptions): PushTransport {\n const now = options.now ?? Date.now\n let cached: { token: string; at: number } | null = null\n const providerToken = () => {\n const at = now()\n if (!cached || at - cached.at >= APNS_TOKEN_TTL_MS) cached = { token: apnsProviderToken(options.credentials, at), at }\n return cached.token\n }\n return {\n async send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]> {\n const { request, close } = options.request\n ? { request: options.request, close: () => undefined }\n : http2Requester()\n const body = apnsPayload(message)\n const outcomes: PushOutcome[] = new Array(targets.length).fill('failed')\n try {\n for (let start = 0; start < targets.length; start += CONCURRENCY) {\n const batch = targets.slice(start, start + CONCURRENCY)\n await Promise.all(\n batch.map(async (target, offset) => {\n try {\n const response = await request(\n APNS_HOSTS[target.apnsEnvironment ?? 'production'],\n {\n ':path': `/3/device/${target.token}`,\n authorization: `bearer ${providerToken()}`,\n 'apns-topic': target.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': message.urgent ? '10' : '5',\n 'content-type': 'application/json',\n },\n body,\n )\n // An expired or refused provider token is ours to mint again, not the device's fault.\n if (response.status === 403) cached = null\n outcomes[start + offset] = apnsOutcome(response)\n } catch {\n outcomes[start + offset] = 'failed'\n }\n }),\n )\n }\n } finally {\n close()\n }\n return outcomes\n },\n }\n}\n\nlet warnedMissingKey = false\nlet configured: { transport: PushTransport } | null = null\n\n/**\n * The deployment's APNs transport from `APNS_*`, or null — logged once —\n * when they are not set.\n */\nexport function apnsTransport(env: Record<string, string | undefined> = process.env): PushTransport | null {\n if (configured) return configured.transport\n const credentials = apnsCredentials(env)\n if (!credentials) {\n if (!warnedMissingKey) {\n warnedMissingKey = true\n console.warn('push: APNS_KEY_P8, APNS_KEY_ID and APNS_TEAM_ID are not all set; iOS and macOS devices are skipped')\n }\n return null\n }\n configured = { transport: createApnsTransport({ credentials }) }\n return configured.transport\n}\n"],"names":["createPrivateKey","sign","connect","APNS_HOSTS","production","sandbox","APNS_TOKEN_TTL_MS","CONCURRENCY","base64url","input","Buffer","from","toString","parseApnsKey","raw","text","trim","replace","includes","decoded","asText","key","format","type","apnsCredentials","env","keyId","teamId","apnsProviderToken","credentials","nowMs","header","JSON","stringify","alg","kid","claims","iss","iat","Math","floor","signature","dsaEncoding","apnsPayload","message","aps","alert","title","body","sound","data","GONE","Set","apnsOutcome","response","status","reason","String","parse","has","http2Requester","sessions","Map","session","origin","open","get","closed","destroyed","on","undefined","set","request","headers","Promise","resolve","reject","stream","setEncoding","setTimeout","close","head","Number","chunk","end","values","clear","createApnsTransport","options","now","Date","cached","providerToken","at","token","send","targets","outcomes","Array","length","fill","start","batch","slice","all","map","target","offset","apnsEnvironment","authorization","topic","urgent","warnedMissingKey","configured","apnsTransport","process","transport","console","warn"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-apns.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 * Apple Push Notification service, sent directly (AGL-3651): HTTP/2 to\n * `api.push.apple.com` (or `api.sandbox.push.apple.com` for a development\n * build's token), authenticated with a provider token, an ES256 JWT signed\n * with the team's APNs key.\n *\n * APNS_KEY_P8 the `.p8` key: PEM text, or that PEM (or its DER) base64-encoded\n * APNS_KEY_ID the key's 10-character id\n * APNS_TEAM_ID the Apple Developer team id\n *\n * Without all three the transport is off: a device on APNs is skipped and\n * that is logged once per process. A provider token is reused for up to 50\n * minutes (Apple refuses one older than an hour, and one refreshed more often\n * than every 20). A `410`, `BadDeviceToken` or `Unregistered` answer means the\n * token is gone, and the row is pruned.\n *\n * Server-only, imported by `push-delivery.ts` only when a device is on APNs.\n */\n\nimport { createPrivateKey, sign, type KeyObject } from 'node:crypto'\nimport { connect, type ClientHttp2Session } from 'node:http2'\nimport type { PushMessage, PushOutcome, PushTarget, PushTransport } from './push-delivery'\n\nexport const APNS_HOSTS = {\n production: 'https://api.push.apple.com',\n sandbox: 'https://api.sandbox.push.apple.com',\n} as const\n\n/** A provider token is reused for at most this long. */\nexport const APNS_TOKEN_TTL_MS = 50 * 60 * 1000\n\n/** Requests in flight at once on one connection. */\nconst CONCURRENCY = 20\n\nexport interface ApnsCredentials {\n key: KeyObject\n keyId: string\n teamId: string\n}\n\nexport interface ApnsResponse {\n status: number\n body: string\n}\n\n/** One HTTP/2 POST to an APNs origin; injectable for tests. */\nexport type ApnsRequest = (origin: string, headers: Record<string, string>, body: string) => Promise<ApnsResponse>\n\nconst base64url = (input: Buffer | string) => Buffer.from(input).toString('base64url')\n\n/** The `.p8` key from `APNS_KEY_P8`: PEM, base64 of the PEM, or base64 of the DER. */\nexport function parseApnsKey(raw: string): KeyObject {\n const text = raw.trim().replace(/\\\\n/g, '\\n')\n if (text.includes('-----BEGIN')) return createPrivateKey(text)\n const decoded = Buffer.from(text, 'base64')\n const asText = decoded.toString('utf8')\n if (asText.includes('-----BEGIN')) return createPrivateKey(asText)\n return createPrivateKey({ key: decoded, format: 'der', type: 'pkcs8' })\n}\n\nexport function apnsCredentials(env: Record<string, string | undefined>): ApnsCredentials | null {\n const raw = env['APNS_KEY_P8']?.trim()\n const keyId = env['APNS_KEY_ID']?.trim()\n const teamId = env['APNS_TEAM_ID']?.trim()\n if (!raw || !keyId || !teamId) return null\n return { key: parseApnsKey(raw), keyId, teamId }\n}\n\n/** An ES256 provider token: `{alg, kid}.{iss, iat}`, signed raw (r‖s), as APNs requires. */\nexport function apnsProviderToken(credentials: ApnsCredentials, nowMs: number): string {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: credentials.keyId }))\n const claims = base64url(JSON.stringify({ iss: credentials.teamId, iat: Math.floor(nowMs / 1000) }))\n const signature = sign('sha256', Buffer.from(`${header}.${claims}`), {\n key: credentials.key,\n dsaEncoding: 'ieee-p1363',\n })\n return `${header}.${claims}.${base64url(signature)}`\n}\n\n/** The APNs payload: the alert, the default sound, and the tap data beside `aps`. */\nexport function apnsPayload(message: PushMessage): string {\n return JSON.stringify({\n aps: {\n alert: { title: message.title, ...(message.body ? { body: message.body } : {}) },\n sound: 'default',\n },\n ...message.data,\n })\n}\n\nconst GONE = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic'])\n\nexport function apnsOutcome(response: ApnsResponse): PushOutcome {\n if (response.status === 200) return 'sent'\n if (response.status === 410) return 'prune'\n let reason = ''\n try {\n reason = String(JSON.parse(response.body)?.reason ?? '')\n } catch {\n // An unreadable body is a failure, not a verdict on the token.\n }\n return GONE.has(reason) ? 'prune' : 'failed'\n}\n\n/** The default request: one HTTP/2 session per origin for the life of a send. */\nfunction http2Requester(): { request: ApnsRequest; close: () => void } {\n const sessions = new Map<string, ClientHttp2Session>()\n const session = (origin: string) => {\n let open = sessions.get(origin)\n if (!open || open.closed || open.destroyed) {\n open = connect(origin)\n open.on('error', () => undefined)\n sessions.set(origin, open)\n }\n return open\n }\n const request: ApnsRequest = (origin, headers, body) =>\n new Promise((resolve, reject) => {\n const stream = session(origin).request({ ':method': 'POST', ...headers })\n let status = 0\n let text = ''\n stream.setEncoding('utf8')\n stream.setTimeout(15_000, () => stream.close())\n stream.on('response', (head) => {\n status = Number(head[':status'] ?? 0)\n })\n stream.on('data', (chunk: string) => {\n text += chunk\n })\n stream.on('end', () => resolve({ status, body: text }))\n stream.on('error', reject)\n stream.end(body)\n })\n return {\n request,\n close: () => {\n for (const open of sessions.values()) open.close()\n sessions.clear()\n },\n }\n}\n\nexport interface ApnsTransportOptions {\n credentials: ApnsCredentials\n /** Injected in tests; the default opens HTTP/2 sessions per send. */\n request?: ApnsRequest\n now?: () => number\n}\n\nexport function createApnsTransport(options: ApnsTransportOptions): PushTransport {\n const now = options.now ?? Date.now\n let cached: { token: string; at: number } | null = null\n const providerToken = () => {\n const at = now()\n if (!cached || at - cached.at >= APNS_TOKEN_TTL_MS) cached = { token: apnsProviderToken(options.credentials, at), at }\n return cached.token\n }\n return {\n async send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]> {\n const { request, close } = options.request\n ? { request: options.request, close: () => undefined }\n : http2Requester()\n const body = apnsPayload(message)\n const outcomes: PushOutcome[] = new Array(targets.length).fill('failed')\n try {\n for (let start = 0; start < targets.length; start += CONCURRENCY) {\n const batch = targets.slice(start, start + CONCURRENCY)\n await Promise.all(\n batch.map(async (target, offset) => {\n try {\n const response = await request(\n APNS_HOSTS[target.apnsEnvironment ?? 'production'],\n {\n ':path': `/3/device/${target.token}`,\n authorization: `bearer ${providerToken()}`,\n 'apns-topic': target.topic,\n 'apns-push-type': 'alert',\n // 10 for every alert: 5 lets the device hold, batch or drop it to save power, which is\n // why native pushes arrived late or never while the console showed them at once.\n 'apns-priority': '10',\n 'content-type': 'application/json',\n },\n body,\n )\n // An expired or refused provider token is ours to mint again, not the device's fault.\n if (response.status === 403) cached = null\n outcomes[start + offset] = apnsOutcome(response)\n } catch {\n outcomes[start + offset] = 'failed'\n }\n }),\n )\n }\n } finally {\n close()\n }\n return outcomes\n },\n }\n}\n\nlet warnedMissingKey = false\nlet configured: { transport: PushTransport } | null = null\n\n/**\n * The deployment's APNs transport from `APNS_*`, or null — logged once —\n * when they are not set.\n */\nexport function apnsTransport(env: Record<string, string | undefined> = process.env): PushTransport | null {\n if (configured) return configured.transport\n const credentials = apnsCredentials(env)\n if (!credentials) {\n if (!warnedMissingKey) {\n warnedMissingKey = true\n console.warn('push: APNS_KEY_P8, APNS_KEY_ID and APNS_TEAM_ID are not all set; iOS and macOS devices are skipped')\n }\n return null\n }\n configured = { transport: createApnsTransport({ credentials }) }\n return configured.transport\n}\n"],"names":["createPrivateKey","sign","connect","APNS_HOSTS","production","sandbox","APNS_TOKEN_TTL_MS","CONCURRENCY","base64url","input","Buffer","from","toString","parseApnsKey","raw","text","trim","replace","includes","decoded","asText","key","format","type","apnsCredentials","env","keyId","teamId","apnsProviderToken","credentials","nowMs","header","JSON","stringify","alg","kid","claims","iss","iat","Math","floor","signature","dsaEncoding","apnsPayload","message","aps","alert","title","body","sound","data","GONE","Set","apnsOutcome","response","status","reason","String","parse","has","http2Requester","sessions","Map","session","origin","open","get","closed","destroyed","on","undefined","set","request","headers","Promise","resolve","reject","stream","setEncoding","setTimeout","close","head","Number","chunk","end","values","clear","createApnsTransport","options","now","Date","cached","providerToken","at","token","send","targets","outcomes","Array","length","fill","start","batch","slice","all","map","target","offset","apnsEnvironment","authorization","topic","warnedMissingKey","configured","apnsTransport","process","transport","console","warn"],"mappings":"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"}
|
|
@@ -68,6 +68,15 @@ export interface PushDeliveryResult {
|
|
|
68
68
|
skipped: number;
|
|
69
69
|
}
|
|
70
70
|
export declare function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage;
|
|
71
|
+
/**
|
|
72
|
+
* The notification types the Aglyn POS app is pushed. The register is not a
|
|
73
|
+
* second inbox: a person who has both apps would otherwise get every
|
|
74
|
+
* workspace notification twice on the same Mac or iPad, so the Aglyn app
|
|
75
|
+
* carries them all and the POS app only what a register needs. None yet.
|
|
76
|
+
*/
|
|
77
|
+
export declare const POS_PUSH_TYPES: ReadonlySet<string>;
|
|
78
|
+
/** Whether `app` is sent a notification of `type`. */
|
|
79
|
+
export declare function appReceivesPush(app: MobileDeviceApp, type: string | undefined): boolean;
|
|
71
80
|
/**
|
|
72
81
|
* Sends one notification to every live device of `uids`, each through its
|
|
73
82
|
* own transport. Never throws: the console notification is the record, and a
|
|
@@ -70,6 +70,15 @@ export function pushMessage(payload, link) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
const isApp = (value)=>value === 'aglyn' || value === 'aglyn-pos';
|
|
73
|
+
/**
|
|
74
|
+
* The notification types the Aglyn POS app is pushed. The register is not a
|
|
75
|
+
* second inbox: a person who has both apps would otherwise get every
|
|
76
|
+
* workspace notification twice on the same Mac or iPad, so the Aglyn app
|
|
77
|
+
* carries them all and the POS app only what a register needs. None yet.
|
|
78
|
+
*/ export const POS_PUSH_TYPES = new Set();
|
|
79
|
+
/** Whether `app` is sent a notification of `type`. */ export function appReceivesPush(app, type) {
|
|
80
|
+
return app === 'aglyn' || type !== undefined && POS_PUSH_TYPES.has(type);
|
|
81
|
+
}
|
|
73
82
|
/**
|
|
74
83
|
* Sends one notification to every live device of `uids`, each through its
|
|
75
84
|
* own transport. Never throws: the console notification is the record, and a
|
|
@@ -109,6 +118,7 @@ const isApp = (value)=>value === 'aglyn' || value === 'aglyn-pos';
|
|
|
109
118
|
await prune(device.ref);
|
|
110
119
|
continue;
|
|
111
120
|
}
|
|
121
|
+
if (!appReceivesPush(app, payload.type)) continue;
|
|
112
122
|
if (seen.has(token) || total >= MAX_DEVICES) continue;
|
|
113
123
|
seen.add(token);
|
|
114
124
|
total += 1;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-delivery.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 * The native apps' push delivery (AGL-3651): a notification the fan-out\n * wrote goes to each recipient's registered devices, grouped by transport —\n * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).\n *\n * Server-only. `registerNativePushSenders` (`native-push.ts`) registers\n * `nativePushSender` with the fan-out and imports this file only when a\n * notification has push recipients, and this file imports each transport\n * only when a device needs it.\n *\n * A row the registry no longer accepts is pruned here: an Expo row from the\n * React Native app, a row with no transport, a token that does not fit its\n * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a\n * transport reports as gone is pruned after the send.\n */\n\nimport {\n isMobilePushToken,\n MOBILE_APP_BUNDLES,\n MOBILE_DEVICE_STALE_MS,\n MOBILE_DEVICES_COLLECTION,\n MOBILE_DEVICES_PER_USER,\n mobilePushData,\n type ApnsEnvironment,\n type MobileDeviceApp,\n type MobilePushData,\n type MobilePushTransport,\n} from '@aglyn/aglyn/app-utils/notification-push'\nimport { normalizeNotificationLink } from '@aglyn/aglyn/server'\nimport type { DocumentReference, Firestore } from 'firebase-admin/firestore'\nimport type { MobilePushPayload, MobilePushSender } from './mobile-push-switch'\n\n/** The most devices one notification is sent to, whatever the audience. */\nconst MAX_DEVICES = 1000\n\n/** One device a transport sends to. */\nexport interface PushTarget {\n token: string\n app: MobileDeviceApp\n /** APNs: the app's bundle id, which is the push topic. */\n topic: string\n apnsEnvironment?: ApnsEnvironment\n}\n\n/** What every device of one notification is sent. */\nexport interface PushMessage {\n title: string\n body?: string\n data: MobilePushData\n /** Critical and warning notifications ask the platform to deliver now. */\n urgent: boolean\n}\n\n/** A transport's verdict for each target, in order. `prune`: the token is gone; forget the row. */\nexport type PushOutcome = 'sent' | 'prune' | 'failed'\n\nexport interface PushTransport {\n send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]>\n}\n\nexport interface PushDeliveryDeps {\n db: Firestore\n /** Each transport, or null when the deployment has none configured. Resolved only when a device needs it. */\n transport: (kind: MobilePushTransport) => Promise<PushTransport | null>\n now?: () => number\n}\n\nexport interface PushDeliveryResult {\n sent: number\n /** Rows removed: stale, Expo or malformed rows, and tokens a transport reported gone. */\n pruned: number\n failed: number\n /** Devices not sent to because their transport is not configured. */\n skipped: number\n}\n\n/** A stored timestamp as epoch millis, or null when there is none. */\nfunction millis(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return value\n const withMillis = value as { toMillis?: () => number }\n return typeof withMillis.toMillis === 'function' ? withMillis.toMillis() : null\n}\n\nexport function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage {\n return {\n title: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n data: mobilePushData({ ...payload, link }),\n urgent: payload.level === 'critical' || payload.level === 'warning',\n }\n}\n\n/** The link the app opens: the stored one, rewritten onto today's console routes. */\nasync function pushLink(db: Firestore, payload: MobilePushPayload): Promise<string | undefined> {\n if (!payload.link) return undefined\n if (!payload.orgId) return normalizeNotificationLink(payload.link, {})\n try {\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId ? db.collection('hosts').doc(payload.hostId).get() : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return normalizeNotificationLink(payload.link, {\n orgSlug: typeof slug === 'string' ? slug : null,\n hostId: payload.hostId ?? null,\n hostSubdomain: typeof subdomain === 'string' ? subdomain : null,\n })\n } catch {\n return normalizeNotificationLink(payload.link, {})\n }\n}\n\nconst isApp = (value: unknown): value is MobileDeviceApp => value === 'aglyn' || value === 'aglyn-pos'\n\n/**\n * Sends one notification to every live device of `uids`, each through its\n * own transport. Never throws: the console notification is the record, and a\n * push is a courtesy beside it.\n */\nexport async function deliverNativePush(\n uids: readonly string[],\n payload: MobilePushPayload,\n deps: PushDeliveryDeps,\n): Promise<PushDeliveryResult> {\n const result: PushDeliveryResult = { sent: 0, pruned: 0, failed: 0, skipped: 0 }\n if (!uids.length) return result\n const now = (deps.now ?? Date.now)()\n const prune = async (ref: DocumentReference) => {\n await ref.delete().catch(() => undefined)\n result.pruned += 1\n }\n try {\n const { db } = deps\n const groups = new Map<MobilePushTransport, Array<{ target: PushTarget; ref: DocumentReference }>>()\n const seen = new Set<string>()\n let total = 0\n for (const uid of uids) {\n const devices = await db\n .collection('users')\n .doc(uid)\n .collection(MOBILE_DEVICES_COLLECTION)\n .limit(MOBILE_DEVICES_PER_USER)\n .get()\n for (const device of devices.docs) {\n const transport = device.get('transport')\n const token = device.get('token')\n const app = device.get('app')\n if (!isMobilePushToken(transport, token) || !isApp(app)) {\n await prune(device.ref)\n continue\n }\n const lastSeen = millis(device.get('lastSeen'))\n if (lastSeen !== null && now - lastSeen > MOBILE_DEVICE_STALE_MS) {\n await prune(device.ref)\n continue\n }\n if (seen.has(token) || total >= MAX_DEVICES) continue\n seen.add(token)\n total += 1\n const environment = device.get('apnsEnvironment')\n const target: PushTarget = {\n token,\n app,\n topic: MOBILE_APP_BUNDLES[app],\n ...(transport === 'apns' && (environment === 'sandbox' || environment === 'production')\n ? { apnsEnvironment: environment }\n : {}),\n }\n const group = groups.get(transport) ?? []\n group.push({ target, ref: device.ref })\n groups.set(transport, group)\n }\n }\n if (!groups.size) return result\n const message = pushMessage(payload, await pushLink(db, payload))\n for (const [kind, entries] of groups) {\n let transport: PushTransport | null = null\n try {\n transport = await deps.transport(kind)\n } catch (error) {\n console.error(`push transport ${kind} failed to load`, error)\n }\n if (!transport) {\n result.skipped += entries.length\n continue\n }\n let outcomes: PushOutcome[]\n try {\n outcomes = await transport.send(\n entries.map((entry) => entry.target),\n message,\n )\n } catch (error) {\n console.error(`push transport ${kind} failed`, error)\n result.failed += entries.length\n continue\n }\n for (const [index, entry] of entries.entries()) {\n const outcome = outcomes[index] ?? 'failed'\n if (outcome === 'sent') result.sent += 1\n else if (outcome === 'prune') await prune(entry.ref)\n else result.failed += 1\n }\n }\n } catch (error) {\n console.error('push delivery failed', error)\n }\n return result\n}\n\n/** The deployment's transports, each imported the first time a device needs it. */\nasync function deploymentTransport(kind: MobilePushTransport): Promise<PushTransport | null> {\n if (kind === 'apns') return (await import('./push-apns')).apnsTransport()\n return (await import('./push-fcm')).fcmTransport()\n}\n\n/** The sender `registerNativePushSenders` hands the fan-out. */\nexport const nativePushSender: MobilePushSender = async (uids, payload, { db }) => {\n await deliverNativePush(uids, payload, { db, transport: deploymentTransport })\n}\n"],"names":["isMobilePushToken","MOBILE_APP_BUNDLES","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_COLLECTION","MOBILE_DEVICES_PER_USER","mobilePushData","normalizeNotificationLink","MAX_DEVICES","millis","value","withMillis","toMillis","pushMessage","payload","link","title","body","data","urgent","level","pushLink","db","undefined","orgId","org","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","orgSlug","hostSubdomain","isApp","deliverNativePush","uids","deps","result","sent","pruned","failed","skipped","length","now","Date","prune","ref","delete","catch","groups","Map","seen","Set","total","uid","devices","limit","device","docs","transport","token","app","lastSeen","has","add","environment","target","topic","apnsEnvironment","group","push","set","size","message","kind","entries","error","console","outcomes","send","map","entry","index","outcome","deploymentTransport","apnsTransport","fcmTransport","nativePushSender"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-delivery.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 * The native apps' push delivery (AGL-3651): a notification the fan-out\n * wrote goes to each recipient's registered devices, grouped by transport —\n * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).\n *\n * Server-only. `registerNativePushSenders` (`native-push.ts`) registers\n * `nativePushSender` with the fan-out and imports this file only when a\n * notification has push recipients, and this file imports each transport\n * only when a device needs it.\n *\n * A row the registry no longer accepts is pruned here: an Expo row from the\n * React Native app, a row with no transport, a token that does not fit its\n * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a\n * transport reports as gone is pruned after the send.\n */\n\nimport {\n isMobilePushToken,\n MOBILE_APP_BUNDLES,\n MOBILE_DEVICE_STALE_MS,\n MOBILE_DEVICES_COLLECTION,\n MOBILE_DEVICES_PER_USER,\n mobilePushData,\n type ApnsEnvironment,\n type MobileDeviceApp,\n type MobilePushData,\n type MobilePushTransport,\n} from '@aglyn/aglyn/app-utils/notification-push'\nimport { normalizeNotificationLink } from '@aglyn/aglyn/server'\nimport type { DocumentReference, Firestore } from 'firebase-admin/firestore'\nimport type { MobilePushPayload, MobilePushSender } from './mobile-push-switch'\n\n/** The most devices one notification is sent to, whatever the audience. */\nconst MAX_DEVICES = 1000\n\n/** One device a transport sends to. */\nexport interface PushTarget {\n token: string\n app: MobileDeviceApp\n /** APNs: the app's bundle id, which is the push topic. */\n topic: string\n apnsEnvironment?: ApnsEnvironment\n}\n\n/** What every device of one notification is sent. */\nexport interface PushMessage {\n title: string\n body?: string\n data: MobilePushData\n /** Critical and warning notifications ask the platform to deliver now. */\n urgent: boolean\n}\n\n/** A transport's verdict for each target, in order. `prune`: the token is gone; forget the row. */\nexport type PushOutcome = 'sent' | 'prune' | 'failed'\n\nexport interface PushTransport {\n send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]>\n}\n\nexport interface PushDeliveryDeps {\n db: Firestore\n /** Each transport, or null when the deployment has none configured. Resolved only when a device needs it. */\n transport: (kind: MobilePushTransport) => Promise<PushTransport | null>\n now?: () => number\n}\n\nexport interface PushDeliveryResult {\n sent: number\n /** Rows removed: stale, Expo or malformed rows, and tokens a transport reported gone. */\n pruned: number\n failed: number\n /** Devices not sent to because their transport is not configured. */\n skipped: number\n}\n\n/** A stored timestamp as epoch millis, or null when there is none. */\nfunction millis(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return value\n const withMillis = value as { toMillis?: () => number }\n return typeof withMillis.toMillis === 'function' ? withMillis.toMillis() : null\n}\n\nexport function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage {\n return {\n title: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n data: mobilePushData({ ...payload, link }),\n urgent: payload.level === 'critical' || payload.level === 'warning',\n }\n}\n\n/** The link the app opens: the stored one, rewritten onto today's console routes. */\nasync function pushLink(db: Firestore, payload: MobilePushPayload): Promise<string | undefined> {\n if (!payload.link) return undefined\n if (!payload.orgId) return normalizeNotificationLink(payload.link, {})\n try {\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId ? db.collection('hosts').doc(payload.hostId).get() : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return normalizeNotificationLink(payload.link, {\n orgSlug: typeof slug === 'string' ? slug : null,\n hostId: payload.hostId ?? null,\n hostSubdomain: typeof subdomain === 'string' ? subdomain : null,\n })\n } catch {\n return normalizeNotificationLink(payload.link, {})\n }\n}\n\nconst isApp = (value: unknown): value is MobileDeviceApp => value === 'aglyn' || value === 'aglyn-pos'\n\n/**\n * The notification types the Aglyn POS app is pushed. The register is not a\n * second inbox: a person who has both apps would otherwise get every\n * workspace notification twice on the same Mac or iPad, so the Aglyn app\n * carries them all and the POS app only what a register needs. None yet.\n */\nexport const POS_PUSH_TYPES: ReadonlySet<string> = new Set<string>()\n\n/** Whether `app` is sent a notification of `type`. */\nexport function appReceivesPush(app: MobileDeviceApp, type: string | undefined): boolean {\n return app === 'aglyn' || (type !== undefined && POS_PUSH_TYPES.has(type))\n}\n\n/**\n * Sends one notification to every live device of `uids`, each through its\n * own transport. Never throws: the console notification is the record, and a\n * push is a courtesy beside it.\n */\nexport async function deliverNativePush(\n uids: readonly string[],\n payload: MobilePushPayload,\n deps: PushDeliveryDeps,\n): Promise<PushDeliveryResult> {\n const result: PushDeliveryResult = { sent: 0, pruned: 0, failed: 0, skipped: 0 }\n if (!uids.length) return result\n const now = (deps.now ?? Date.now)()\n const prune = async (ref: DocumentReference) => {\n await ref.delete().catch(() => undefined)\n result.pruned += 1\n }\n try {\n const { db } = deps\n const groups = new Map<MobilePushTransport, Array<{ target: PushTarget; ref: DocumentReference }>>()\n const seen = new Set<string>()\n let total = 0\n for (const uid of uids) {\n const devices = await db\n .collection('users')\n .doc(uid)\n .collection(MOBILE_DEVICES_COLLECTION)\n .limit(MOBILE_DEVICES_PER_USER)\n .get()\n for (const device of devices.docs) {\n const transport = device.get('transport')\n const token = device.get('token')\n const app = device.get('app')\n if (!isMobilePushToken(transport, token) || !isApp(app)) {\n await prune(device.ref)\n continue\n }\n const lastSeen = millis(device.get('lastSeen'))\n if (lastSeen !== null && now - lastSeen > MOBILE_DEVICE_STALE_MS) {\n await prune(device.ref)\n continue\n }\n if (!appReceivesPush(app, payload.type)) continue\n if (seen.has(token) || total >= MAX_DEVICES) continue\n seen.add(token)\n total += 1\n const environment = device.get('apnsEnvironment')\n const target: PushTarget = {\n token,\n app,\n topic: MOBILE_APP_BUNDLES[app],\n ...(transport === 'apns' && (environment === 'sandbox' || environment === 'production')\n ? { apnsEnvironment: environment }\n : {}),\n }\n const group = groups.get(transport) ?? []\n group.push({ target, ref: device.ref })\n groups.set(transport, group)\n }\n }\n if (!groups.size) return result\n const message = pushMessage(payload, await pushLink(db, payload))\n for (const [kind, entries] of groups) {\n let transport: PushTransport | null = null\n try {\n transport = await deps.transport(kind)\n } catch (error) {\n console.error(`push transport ${kind} failed to load`, error)\n }\n if (!transport) {\n result.skipped += entries.length\n continue\n }\n let outcomes: PushOutcome[]\n try {\n outcomes = await transport.send(\n entries.map((entry) => entry.target),\n message,\n )\n } catch (error) {\n console.error(`push transport ${kind} failed`, error)\n result.failed += entries.length\n continue\n }\n for (const [index, entry] of entries.entries()) {\n const outcome = outcomes[index] ?? 'failed'\n if (outcome === 'sent') result.sent += 1\n else if (outcome === 'prune') await prune(entry.ref)\n else result.failed += 1\n }\n }\n } catch (error) {\n console.error('push delivery failed', error)\n }\n return result\n}\n\n/** The deployment's transports, each imported the first time a device needs it. */\nasync function deploymentTransport(kind: MobilePushTransport): Promise<PushTransport | null> {\n if (kind === 'apns') return (await import('./push-apns')).apnsTransport()\n return (await import('./push-fcm')).fcmTransport()\n}\n\n/** The sender `registerNativePushSenders` hands the fan-out. */\nexport const nativePushSender: MobilePushSender = async (uids, payload, { db }) => {\n await deliverNativePush(uids, payload, { db, transport: deploymentTransport })\n}\n"],"names":["isMobilePushToken","MOBILE_APP_BUNDLES","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_COLLECTION","MOBILE_DEVICES_PER_USER","mobilePushData","normalizeNotificationLink","MAX_DEVICES","millis","value","withMillis","toMillis","pushMessage","payload","link","title","body","data","urgent","level","pushLink","db","undefined","orgId","org","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","orgSlug","hostSubdomain","isApp","POS_PUSH_TYPES","Set","appReceivesPush","app","type","has","deliverNativePush","uids","deps","result","sent","pruned","failed","skipped","length","now","Date","prune","ref","delete","catch","groups","Map","seen","total","uid","devices","limit","device","docs","transport","token","lastSeen","add","environment","target","topic","apnsEnvironment","group","push","set","size","message","kind","entries","error","console","outcomes","send","map","entry","index","outcome","deploymentTransport","apnsTransport","fcmTransport","nativePushSender"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type SystemEmailTemplateDefinition } from '@aglyn/shared-util-email';
|
|
17
|
+
import { type EmailTheme, type SystemEmailTemplateDefinition } from '@aglyn/shared-util-email';
|
|
18
18
|
import { type ReusableComponentTree } from '@aglyn/aglyn/server';
|
|
19
19
|
import { isPlatformBrandedSend, type SystemEmailBrandOptions } from '@aglyn/aglyn/app-utils/system-email-chrome';
|
|
20
20
|
export { isPlatformBrandedSend };
|
|
@@ -74,6 +74,12 @@ export interface LoadedSystemEmail {
|
|
|
74
74
|
* send, by {@link renderLoadedSystemEmail}.
|
|
75
75
|
*/
|
|
76
76
|
export declare function loadSystemEmail(templateKey: string): Promise<LoadedSystemEmail | null>;
|
|
77
|
+
/**
|
|
78
|
+
* The theme a system email is drawn in (AGL-3370): the console's palette,
|
|
79
|
+
* or, for a white-label org that set a brand color, the same palette with
|
|
80
|
+
* that color as its primary. Buttons use the console's corner radius.
|
|
81
|
+
*/
|
|
82
|
+
export declare function systemEmailTheme(options: SystemEmailBrandOptions): EmailTheme;
|
|
77
83
|
/**
|
|
78
84
|
* Renders a loaded email for one recipient's merge values (AGL-768). No
|
|
79
85
|
* Firestore access — call {@link loadSystemEmail} once, then this per
|
|
@@ -172,7 +172,7 @@ export { isPlatformBrandedSend };
|
|
|
172
172
|
* The theme a system email is drawn in (AGL-3370): the console's palette,
|
|
173
173
|
* or, for a white-label org that set a brand color, the same palette with
|
|
174
174
|
* that color as its primary. Buttons use the console's corner radius.
|
|
175
|
-
*/ function systemEmailTheme(options) {
|
|
175
|
+
*/ export function systemEmailTheme(options) {
|
|
176
176
|
var _options_brandPrimaryColor;
|
|
177
177
|
const primaryColor = String((_options_brandPrimaryColor = options.brandPrimaryColor) != null ? _options_brandPrimaryColor : '').trim();
|
|
178
178
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/render-system-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 {\n EMAIL_NODE_ROOT_ID,\n PLATFORM_EMAIL_PALETTE,\n chromeForDesign,\n SYSTEM_EMAIL_COLLECTION,\n buildEmailPalette,\n buildDefaultEmailNodeMap,\n getSystemEmailTemplate,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailTheme,\n type SystemEmailTemplateDefinition,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { platformMarketingHostId } from './platform-marketing-consent'\nimport {\n brandMergeTokens,\n collectReferencedComponentIds,\n composeReusableComponentNodes,\n decodeStoredNodes,\n resolveBrandingProfile,\n sanitizeAuthorHtml,\n type ReusableComponentTree,\n} from '@aglyn/aglyn/server'\nimport {\n hostComponentReader,\n loadReferencedComponents,\n type ComponentStoreLike,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n PLATFORM_BRAND_MERGE_TOKENS,\n buildSystemEmailChrome,\n isPlatformBrandedSend,\n type SystemEmailBrandOptions,\n} from '@aglyn/aglyn/app-utils/system-email-chrome'\n\n// Defined beside the chrome they decide, and re-exported for the callers and\n// specs that reach them through this module.\nexport { isPlatformBrandedSend }\nexport type { SystemEmailBrandOptions }\n\n/**\n * Blanks any `{{token}}` the caller did not supply.\n *\n * `substituteMergeTokens` leaves unknown tokens untouched, which is right for\n * campaigns (an author wants to see the tag they mistyped) but wrong here —\n * a system email goes to a customer, and \"You've been invited to\n * {{org.name}}\" is worse than a gap. Applied only in this resolver so\n * campaign behaviour is unchanged.\n */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Origin a picked image in a system email is fetched from (AGL-1224).\n *\n * A system email is staff-designed and its media lives in the platform\n * library, which the CONSOLE's `/api/media/cdn` route serves — the tenant app\n * mounts the same route, but a recipient of an org invite has no site to\n * resolve against. Same expression `app/layout.tsx` uses for the console's\n * own canonical URL, so the two cannot drift per deploy.\n */\nconst CONSOLE_ORIGIN =\n process.env.NEXT_PUBLIC_CONSOLE_URL ?? 'https://app.aglyn.com'\n\n/**\n * Which copy a system email carries: a design staff published, or the\n * catalog's built-in copy, which is what every key without a usable design\n * sends (AGL-3322).\n */\nexport type SystemEmailSource = 'designed' | 'default'\n\nexport interface RenderedSystemEmail {\n subject: string\n html: string\n text: string\n source: SystemEmailSource\n}\n\n/**\n * A system email loaded once, ready to render for any number of recipients\n * without touching Firestore again (AGL-768): the published staff design, or\n * the catalog's built-in copy when there is none.\n */\nexport interface LoadedSystemEmail {\n definition: SystemEmailTemplateDefinition\n /** The design as stored, each email block still a placement; or the built-in copy's node map. */\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n source: SystemEmailSource\n /**\n * The published definitions of the email blocks `nodes` places, keyed by\n * id, read from the platform marketing site ONCE with the template\n * (AGL-3318), so a batch composes every recipient's copy without another\n * read. Absent when the design places none, when the deployment names no\n * marketing site, and when the read failed; each placement then renders as\n * nothing.\n */\n components?: Record<string, ReusableComponentTree>\n}\n\n/**\n * The email blocks a platform email places, read from the site that owns\n * them (AGL-3318), or `undefined` when there is nothing to read or the read\n * failed.\n *\n * WHICH SITE. A block is a reusable component, and a component belongs to a\n * site; a platform email belongs to none. The header and footer Aglyn's own\n * mail wears are the ones on its marketing site, which the deployment names\n * in `PLATFORM_MARKETING_HOST_ID`, the setting product-email consent already\n * reads. An install that names none reads nothing, and a block placed in its\n * system emails renders as nothing.\n *\n * NEVER A REASON NOT TO SEND. A site's own email fails its whole load when a\n * component read fails, and falls back to its built-in copy. Here the copy is\n * staff's design and the blocks are another site's chrome, so a failed read\n * costs the blocks and nothing else: it is logged, and the design renders\n * inside the coded header and footer instead (see\n * {@link renderLoadedSystemEmail}) rather than being traded for the built-in\n * copy. A password reset whose header could not be read still carries its\n * link.\n */\nasync function loadPlatformEmailBlocks(\n firestore: ComponentStoreLike,\n templateKey: string,\n nodes: Record<string, unknown>,\n): Promise<Record<string, ReusableComponentTree> | undefined> {\n // A design that places nothing costs nothing: no setting read, no read.\n if (!collectReferencedComponentIds(nodes as never).size) return undefined\n const hostId = platformMarketingHostId()\n if (!hostId) return undefined\n try {\n return await loadReferencedComponents(\n nodes,\n hostComponentReader(firestore, hostId),\n {\n onSkipped: (skipped) =>\n console.warn(\n JSON.stringify({\n tag: 'AGL-3318:platform-email-blocks-skipped',\n templateKey,\n hostId,\n skipped,\n }),\n ),\n },\n )\n } catch (error) {\n console.error(\n `system email template ${templateKey}: its email blocks failed to load from ${hostId}`,\n error,\n )\n return undefined\n }\n}\n\n/**\n * Whether one send draws the design's email blocks: a send under the\n * platform's own brand (see {@link isPlatformBrandedSend}) of a design whose\n * blocks the load could read. Every other send renders the stored nodes, each\n * block a placement that draws nothing, inside the coded chrome.\n */\nfunction drawsPlatformBlocks(\n loaded: LoadedSystemEmail,\n merged: Readonly<Record<string, string>>,\n options: SystemEmailBrandOptions,\n): loaded is LoadedSystemEmail & {\n components: Record<string, ReusableComponentTree>\n} {\n return (\n Boolean(loaded.components && Object.keys(loaded.components).length) &&\n isPlatformBrandedSend(merged, options)\n )\n}\n\n/**\n * A key's built-in copy as a loaded email (AGL-3322): the node map the editor\n * seeds a first design with, and the catalog subject. What a key sends until\n * staff publish a design, and whenever the published one cannot be read.\n */\nfunction catalogDefault(\n definition: SystemEmailTemplateDefinition,\n): LoadedSystemEmail {\n return {\n definition,\n nodes: buildDefaultEmailNodeMap(definition),\n subjectTemplate: definition.defaultSubject,\n preheaderTemplate: '',\n source: 'default',\n }\n}\n\n/**\n * Loads what a key sends — ONE Firestore read — ready to render per\n * recipient: the published staff design, or the catalog's built-in copy\n * (AGL-3322) when there is none to use (no document, no published version,\n * an empty node map, or a read failure).\n *\n * `null` only for a key that is unknown or not Resend-delivered, which has\n * nothing here to render. Split out from {@link renderSystemEmail} so a batch\n * send resolves the template once and renders it per recipient, rather than\n * one read each (AGL-768): a Firestore read per invite is fine, one per\n * recipient in a usage-email batch is not.\n *\n * Reads through the Admin SDK, which bypasses the staff-only rules on the\n * collection — the send path runs as the server, not as a signed-in user.\n *\n * A design that places email blocks costs one more read per block, from the\n * platform marketing site, made here with the template so a batch pays it\n * once too (AGL-3318). Whether a recipient's copy expands them is decided per\n * send, by {@link renderLoadedSystemEmail}.\n */\nexport async function loadSystemEmail(\n templateKey: string,\n): Promise<LoadedSystemEmail | null> {\n const definition = getSystemEmailTemplate(templateKey)\n if (!definition) return null\n // A Firebase- or Stripe-delivered email is sent by that service from its\n // own templates, so rendering one here would produce output nothing ever\n // sends (AGL-767).\n if (definition.deliveredBy !== 'resend') return null\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const templateRef = firestore\n .collection(SYSTEM_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return catalogDefault(definition)\n\n const versionId = templateSnapshot.get('versionId')\n // Cleared by \"reset to default\", which nulls the pointer rather than\n // deleting the document so the audit trail survives.\n if (!versionId) return catalogDefault(definition)\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms (AGL-1223). The email besigner compresses like every\n // other besigner document, and versions written before it did are still\n // plain maps — `decodeStoredNodes` returns those unchanged.\n //\n // Reading raw is the failure the guard below cannot catch: over a\n // `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and a staff-authored email renders empty rather than falling\n // back to its built-in copy.\n const nodes = decodeStoredNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return catalogDefault(definition)\n\n const components = await loadPlatformEmailBlocks(\n firestore,\n templateKey,\n nodes,\n )\n return {\n definition,\n nodes,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n definition.defaultSubject,\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n source: 'designed',\n ...(components && { components }),\n }\n } catch (error) {\n // Never let a template problem block the send, or strip it bare: the\n // recipient gets the built-in copy, in the same header and footer.\n console.error(\n `system email template ${templateKey} failed to load; sending its built-in copy`,\n error,\n )\n return catalogDefault(definition)\n }\n}\n\n/**\n * The theme a system email is drawn in (AGL-3370): the console's palette,\n * or, for a white-label org that set a brand color, the same palette with\n * that color as its primary. Buttons use the console's corner radius.\n */\nfunction systemEmailTheme(options: SystemEmailBrandOptions): EmailTheme {\n const primaryColor = String(options.brandPrimaryColor ?? '').trim()\n return {\n palette: primaryColor\n ? buildEmailPalette({ base: 'platform', primaryColor })\n : PLATFORM_EMAIL_PALETTE,\n buttonRadius: 4,\n }\n}\n\n/**\n * Renders a loaded email for one recipient's merge values (AGL-768). No\n * Firestore access — call {@link loadSystemEmail} once, then this per\n * recipient. `null` only if the renderer produced no document at all.\n *\n * Which header and footer it wears is decided here, per send, because one\n * batch-loaded design goes to recipients of different brands:\n *\n * - A send under the platform's own brand of a design whose email blocks the\n * load read draws those blocks (AGL-3318), and nothing else: they ARE its\n * header and footer, and a second one around them would draw both.\n * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome\n * (AGL-3322): the built-in copy, which places no blocks; a design sent to a\n * white-label org's people, whose blocks stay unexpanded and draw nothing,\n * since they are the platform's; and a design on a deployment that names no\n * marketing site, or whose blocks could not be read. The chrome is in the\n * brand of the send — the org's email logo rides in its header, so a\n * white-label mail carries exactly one logo.\n *\n * Either way, a band the design draws itself is left out of the chrome\n * (AGL-3372): a design that opens with its own Header gets the footer only.\n */\nexport function renderLoadedSystemEmail(\n loaded: LoadedSystemEmail,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): RenderedSystemEmail | null {\n const merged = { ...PLATFORM_BRAND_MERGE_TOKENS, ...merge }\n const blocks = drawsPlatformBlocks(loaded, merged, options)\n const nodes = (\n blocks\n ? composeReusableComponentNodes(loaded.nodes as never, loaded.components)\n : loaded.nodes\n ) as Record<string, unknown>\n // The brand's chrome around every copy, less any band the design draws\n // itself (AGL-3372). Unexpanded placements draw nothing in this send, so\n // they only count when the blocks are drawn.\n const chrome = chromeForDesign({\n stored: blocks ? loaded.nodes : null,\n composed: nodes,\n chrome: buildSystemEmailChrome({\n definition: loaded.definition,\n merged,\n brandLogoUrl: options.brandLogoUrl,\n brandHomeUrl: options.brandHomeUrl,\n }),\n })\n const rendered = renderEmailHtml({\n nodes: nodes as never,\n // Besigner maps are rooted at '_@_', not renderEmailHtml's default\n // 'root' — without this a designed template rendered empty and the send\n // fell back to built-in copy, so designing did nothing (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merged),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merged),\n merge: merged,\n // The same policy the console's own preview of these nodes applies —\n // `sanitizeCustomHtml` in the email plugin delegates to this exact\n // function, so what staff review and what the recipient receives are one\n // function of one string rather than two that have to be kept in step.\n sanitize: sanitizeAuthorHtml,\n // Without this a staff-picked image resolves to a site-relative CDN path,\n // which is a broken-image box in the recipient's inbox (AGL-1224). The\n // chrome's logo resolves against it too.\n mediaOrigin: CONSOLE_ORIGIN,\n // In the console's theme (AGL-3370), under a white-label org's own color\n // when it set one: a color staff picked in the Besigner is a palette\n // token, and a button nobody colored wears the brand's accent.\n ...systemEmailTheme(options),\n // No `brandLogoUrl`: the org's email logo (AGL-2139) is the chrome\n // header's logo whenever there is one to draw, and a send that draws the\n // platform's blocks is by definition one that carries none.\n ...(chrome ? { chrome } : {}),\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merged),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n source: loaded.source,\n }\n}\n\n/**\n * Renders the system email a key sends to one recipient (AGL-750): the\n * published staff design, or the catalog's built-in copy (AGL-3322), in the\n * header and footer of the brand it is sent under.\n *\n * **`null` only for a key that is unknown or not Resend-delivered**, which\n * has nothing here to render. No template document, no published version, an\n * empty node map and a Firestore failure all render the built-in copy — the\n * same copy the editor seeds a first design with — so a missing, half-saved\n * or broken template can never mean a customer receives no email, or a bare\n * one. Every caller still keeps its own hard-coded copy behind this, as the\n * last resort for a render that produced nothing.\n *\n * A single-recipient convenience over {@link loadSystemEmail} +\n * {@link renderLoadedSystemEmail}; a batch should call those two directly so\n * it reads the template once.\n */\nexport async function renderSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n const loaded = await loadSystemEmail(templateKey)\n return loaded ? renderLoadedSystemEmail(loaded, merge, options) : null\n}\n\n/**\n * The email a template sends right now, for the staff test-send endpoint\n * (AGL-766): exactly what a real send of it renders — the published design,\n * or the built-in copy, with the same header and footer — so a test shows\n * what recipients receive. `null` for an unknown or non-Resend key.\n */\nexport async function renderEffectiveSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n return renderSystemEmail(templateKey, merge, options)\n}\n\n/**\n * The brand one system email is sent under (AGL-3367): the merge tokens, the\n * chrome options and the sender name, from one place.\n *\n * `null` is the platform's own brand, and it is the answer for everything\n * the platform says on its own behalf: a staff alert, a legal receipt, an\n * enforcement notice. A workspace-context email passes the workspace's org\n * document, and `resolveBrandingProfile` decides from there: a white-label\n * org gets its own name, logo, home and support in the header and footer,\n * and every other org resolves to the platform profile, so the same call is\n * right for both.\n */\nexport interface SystemEmailBrand {\n merge: Record<string, string>\n options: SystemEmailBrandOptions\n /** The display name to send under; absent sends under the platform's. */\n fromName?: string\n /** The org's own name, for the `{{org.name}}` its copy may use. */\n orgName?: string\n}\n\n/** See {@link SystemEmailBrand}. */\nexport function systemEmailBrand(\n org: Readonly<Record<string, unknown>> | null | undefined,\n): SystemEmailBrand {\n if (!org) return { merge: {}, options: {} }\n const branding = resolveBrandingProfile(org as never)\n const orgName = typeof org['name'] === 'string' ? org['name'].trim() : ''\n return {\n ...(orgName ? { orgName } : {}),\n merge: brandMergeTokens(branding),\n options: {\n brandLogoUrl: branding.emailLogoUrl,\n brandHomeUrl: branding.homeUrl,\n brandPrimaryColor: branding.primaryColor,\n },\n fromName: branding.fromName,\n }\n}\n\n/**\n * {@link systemEmailBrand} for an org read by id, for a sender that holds\n * only the id. A failed or missing read is the platform's brand: the email\n * still goes, and a white-label org's people see the platform's chrome on\n * one message rather than no message.\n */\nexport async function orgSystemEmailBrand(\n orgId: string | null | undefined,\n): Promise<SystemEmailBrand> {\n if (!orgId) return systemEmailBrand(null)\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .get()\n return systemEmailBrand(snapshot.exists ? (snapshot.data() ?? null) : null)\n } catch (error) {\n console.error(`system email brand for org ${orgId} failed to load`, error)\n return systemEmailBrand(null)\n }\n}\n\n/** What a send takes from a rendered system email. */\nexport interface SystemEmailContent {\n subject: string\n text: string\n html?: string\n}\n\n/**\n * The subject, text and HTML to send, with the caller's own copy as the\n * last resort for a render that produced nothing: the shape every sender of\n * a system email repeats, written once.\n */\nexport function systemEmailContent(\n rendered: RenderedSystemEmail | null,\n fallback: { subject: string; text: string },\n): SystemEmailContent {\n return {\n subject: rendered?.subject || fallback.subject,\n text: rendered?.text || fallback.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n }\n}\n\n/**\n * Renders a key for one recipient in a brand and returns what to send\n * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in\n * one call, for the senders with one message to build. Never throws; a\n * render that fails sends the fallback copy, which is what it sent before.\n */\nexport async function renderSystemEmailContent(\n templateKey: string,\n merge: Record<string, string>,\n brand: SystemEmailBrand,\n fallback: { subject: string; text: string },\n): Promise<SystemEmailContent> {\n const rendered = await renderSystemEmail(\n templateKey,\n { ...brand.merge, ...merge },\n brand.options,\n ).catch((error) => {\n console.error(`system email ${templateKey} failed to render`, error)\n return null\n })\n return systemEmailContent(rendered, fallback)\n}\n\nexport default renderSystemEmail\n"],"names":["process","EMAIL_NODE_ROOT_ID","PLATFORM_EMAIL_PALETTE","chromeForDesign","SYSTEM_EMAIL_COLLECTION","buildEmailPalette","buildDefaultEmailNodeMap","getSystemEmailTemplate","renderEmailHtml","substituteMergeTokens","firebaseAdmin","platformMarketingHostId","brandMergeTokens","collectReferencedComponentIds","composeReusableComponentNodes","decodeStoredNodes","resolveBrandingProfile","sanitizeAuthorHtml","hostComponentReader","loadReferencedComponents","PLATFORM_BRAND_MERGE_TOKENS","buildSystemEmailChrome","isPlatformBrandedSend","blankUnresolvedTokens","value","replace","CONSOLE_ORIGIN","env","NEXT_PUBLIC_CONSOLE_URL","loadPlatformEmailBlocks","firestore","templateKey","nodes","size","undefined","hostId","onSkipped","skipped","console","warn","JSON","stringify","tag","error","drawsPlatformBlocks","loaded","merged","options","Boolean","components","Object","keys","length","catalogDefault","definition","subjectTemplate","defaultSubject","preheaderTemplate","source","loadSystemEmail","deliveredBy","templateSnapshot","app","templateRef","collection","doc","get","exists","versionId","versionSnapshot","String","systemEmailTheme","primaryColor","brandPrimaryColor","trim","palette","base","buttonRadius","renderLoadedSystemEmail","merge","rendered","blocks","chrome","stored","composed","brandLogoUrl","brandHomeUrl","rootId","subject","preheader","sanitize","mediaOrigin","html","text","renderSystemEmail","renderEffectiveSystemEmail","systemEmailBrand","org","branding","orgName","emailLogoUrl","homeUrl","fromName","orgSystemEmailBrand","orgId","snapshot","data","systemEmailContent","fallback","renderSystemEmailContent","brand","catch"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/render-system-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 {\n EMAIL_NODE_ROOT_ID,\n PLATFORM_EMAIL_PALETTE,\n chromeForDesign,\n SYSTEM_EMAIL_COLLECTION,\n buildEmailPalette,\n buildDefaultEmailNodeMap,\n getSystemEmailTemplate,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailTheme,\n type SystemEmailTemplateDefinition,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { platformMarketingHostId } from './platform-marketing-consent'\nimport {\n brandMergeTokens,\n collectReferencedComponentIds,\n composeReusableComponentNodes,\n decodeStoredNodes,\n resolveBrandingProfile,\n sanitizeAuthorHtml,\n type ReusableComponentTree,\n} from '@aglyn/aglyn/server'\nimport {\n hostComponentReader,\n loadReferencedComponents,\n type ComponentStoreLike,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n PLATFORM_BRAND_MERGE_TOKENS,\n buildSystemEmailChrome,\n isPlatformBrandedSend,\n type SystemEmailBrandOptions,\n} from '@aglyn/aglyn/app-utils/system-email-chrome'\n\n// Defined beside the chrome they decide, and re-exported for the callers and\n// specs that reach them through this module.\nexport { isPlatformBrandedSend }\nexport type { SystemEmailBrandOptions }\n\n/**\n * Blanks any `{{token}}` the caller did not supply.\n *\n * `substituteMergeTokens` leaves unknown tokens untouched, which is right for\n * campaigns (an author wants to see the tag they mistyped) but wrong here —\n * a system email goes to a customer, and \"You've been invited to\n * {{org.name}}\" is worse than a gap. Applied only in this resolver so\n * campaign behaviour is unchanged.\n */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Origin a picked image in a system email is fetched from (AGL-1224).\n *\n * A system email is staff-designed and its media lives in the platform\n * library, which the CONSOLE's `/api/media/cdn` route serves — the tenant app\n * mounts the same route, but a recipient of an org invite has no site to\n * resolve against. Same expression `app/layout.tsx` uses for the console's\n * own canonical URL, so the two cannot drift per deploy.\n */\nconst CONSOLE_ORIGIN =\n process.env.NEXT_PUBLIC_CONSOLE_URL ?? 'https://app.aglyn.com'\n\n/**\n * Which copy a system email carries: a design staff published, or the\n * catalog's built-in copy, which is what every key without a usable design\n * sends (AGL-3322).\n */\nexport type SystemEmailSource = 'designed' | 'default'\n\nexport interface RenderedSystemEmail {\n subject: string\n html: string\n text: string\n source: SystemEmailSource\n}\n\n/**\n * A system email loaded once, ready to render for any number of recipients\n * without touching Firestore again (AGL-768): the published staff design, or\n * the catalog's built-in copy when there is none.\n */\nexport interface LoadedSystemEmail {\n definition: SystemEmailTemplateDefinition\n /** The design as stored, each email block still a placement; or the built-in copy's node map. */\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n source: SystemEmailSource\n /**\n * The published definitions of the email blocks `nodes` places, keyed by\n * id, read from the platform marketing site ONCE with the template\n * (AGL-3318), so a batch composes every recipient's copy without another\n * read. Absent when the design places none, when the deployment names no\n * marketing site, and when the read failed; each placement then renders as\n * nothing.\n */\n components?: Record<string, ReusableComponentTree>\n}\n\n/**\n * The email blocks a platform email places, read from the site that owns\n * them (AGL-3318), or `undefined` when there is nothing to read or the read\n * failed.\n *\n * WHICH SITE. A block is a reusable component, and a component belongs to a\n * site; a platform email belongs to none. The header and footer Aglyn's own\n * mail wears are the ones on its marketing site, which the deployment names\n * in `PLATFORM_MARKETING_HOST_ID`, the setting product-email consent already\n * reads. An install that names none reads nothing, and a block placed in its\n * system emails renders as nothing.\n *\n * NEVER A REASON NOT TO SEND. A site's own email fails its whole load when a\n * component read fails, and falls back to its built-in copy. Here the copy is\n * staff's design and the blocks are another site's chrome, so a failed read\n * costs the blocks and nothing else: it is logged, and the design renders\n * inside the coded header and footer instead (see\n * {@link renderLoadedSystemEmail}) rather than being traded for the built-in\n * copy. A password reset whose header could not be read still carries its\n * link.\n */\nasync function loadPlatformEmailBlocks(\n firestore: ComponentStoreLike,\n templateKey: string,\n nodes: Record<string, unknown>,\n): Promise<Record<string, ReusableComponentTree> | undefined> {\n // A design that places nothing costs nothing: no setting read, no read.\n if (!collectReferencedComponentIds(nodes as never).size) return undefined\n const hostId = platformMarketingHostId()\n if (!hostId) return undefined\n try {\n return await loadReferencedComponents(\n nodes,\n hostComponentReader(firestore, hostId),\n {\n onSkipped: (skipped) =>\n console.warn(\n JSON.stringify({\n tag: 'AGL-3318:platform-email-blocks-skipped',\n templateKey,\n hostId,\n skipped,\n }),\n ),\n },\n )\n } catch (error) {\n console.error(\n `system email template ${templateKey}: its email blocks failed to load from ${hostId}`,\n error,\n )\n return undefined\n }\n}\n\n/**\n * Whether one send draws the design's email blocks: a send under the\n * platform's own brand (see {@link isPlatformBrandedSend}) of a design whose\n * blocks the load could read. Every other send renders the stored nodes, each\n * block a placement that draws nothing, inside the coded chrome.\n */\nfunction drawsPlatformBlocks(\n loaded: LoadedSystemEmail,\n merged: Readonly<Record<string, string>>,\n options: SystemEmailBrandOptions,\n): loaded is LoadedSystemEmail & {\n components: Record<string, ReusableComponentTree>\n} {\n return (\n Boolean(loaded.components && Object.keys(loaded.components).length) &&\n isPlatformBrandedSend(merged, options)\n )\n}\n\n/**\n * A key's built-in copy as a loaded email (AGL-3322): the node map the editor\n * seeds a first design with, and the catalog subject. What a key sends until\n * staff publish a design, and whenever the published one cannot be read.\n */\nfunction catalogDefault(\n definition: SystemEmailTemplateDefinition,\n): LoadedSystemEmail {\n return {\n definition,\n nodes: buildDefaultEmailNodeMap(definition),\n subjectTemplate: definition.defaultSubject,\n preheaderTemplate: '',\n source: 'default',\n }\n}\n\n/**\n * Loads what a key sends — ONE Firestore read — ready to render per\n * recipient: the published staff design, or the catalog's built-in copy\n * (AGL-3322) when there is none to use (no document, no published version,\n * an empty node map, or a read failure).\n *\n * `null` only for a key that is unknown or not Resend-delivered, which has\n * nothing here to render. Split out from {@link renderSystemEmail} so a batch\n * send resolves the template once and renders it per recipient, rather than\n * one read each (AGL-768): a Firestore read per invite is fine, one per\n * recipient in a usage-email batch is not.\n *\n * Reads through the Admin SDK, which bypasses the staff-only rules on the\n * collection — the send path runs as the server, not as a signed-in user.\n *\n * A design that places email blocks costs one more read per block, from the\n * platform marketing site, made here with the template so a batch pays it\n * once too (AGL-3318). Whether a recipient's copy expands them is decided per\n * send, by {@link renderLoadedSystemEmail}.\n */\nexport async function loadSystemEmail(\n templateKey: string,\n): Promise<LoadedSystemEmail | null> {\n const definition = getSystemEmailTemplate(templateKey)\n if (!definition) return null\n // A Firebase- or Stripe-delivered email is sent by that service from its\n // own templates, so rendering one here would produce output nothing ever\n // sends (AGL-767).\n if (definition.deliveredBy !== 'resend') return null\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const templateRef = firestore\n .collection(SYSTEM_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return catalogDefault(definition)\n\n const versionId = templateSnapshot.get('versionId')\n // Cleared by \"reset to default\", which nulls the pointer rather than\n // deleting the document so the audit trail survives.\n if (!versionId) return catalogDefault(definition)\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms (AGL-1223). The email besigner compresses like every\n // other besigner document, and versions written before it did are still\n // plain maps — `decodeStoredNodes` returns those unchanged.\n //\n // Reading raw is the failure the guard below cannot catch: over a\n // `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and a staff-authored email renders empty rather than falling\n // back to its built-in copy.\n const nodes = decodeStoredNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return catalogDefault(definition)\n\n const components = await loadPlatformEmailBlocks(\n firestore,\n templateKey,\n nodes,\n )\n return {\n definition,\n nodes,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n definition.defaultSubject,\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n source: 'designed',\n ...(components && { components }),\n }\n } catch (error) {\n // Never let a template problem block the send, or strip it bare: the\n // recipient gets the built-in copy, in the same header and footer.\n console.error(\n `system email template ${templateKey} failed to load; sending its built-in copy`,\n error,\n )\n return catalogDefault(definition)\n }\n}\n\n/**\n * The theme a system email is drawn in (AGL-3370): the console's palette,\n * or, for a white-label org that set a brand color, the same palette with\n * that color as its primary. Buttons use the console's corner radius.\n */\nexport function systemEmailTheme(options: SystemEmailBrandOptions): EmailTheme {\n const primaryColor = String(options.brandPrimaryColor ?? '').trim()\n return {\n palette: primaryColor\n ? buildEmailPalette({ base: 'platform', primaryColor })\n : PLATFORM_EMAIL_PALETTE,\n buttonRadius: 4,\n }\n}\n\n/**\n * Renders a loaded email for one recipient's merge values (AGL-768). No\n * Firestore access — call {@link loadSystemEmail} once, then this per\n * recipient. `null` only if the renderer produced no document at all.\n *\n * Which header and footer it wears is decided here, per send, because one\n * batch-loaded design goes to recipients of different brands:\n *\n * - A send under the platform's own brand of a design whose email blocks the\n * load read draws those blocks (AGL-3318), and nothing else: they ARE its\n * header and footer, and a second one around them would draw both.\n * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome\n * (AGL-3322): the built-in copy, which places no blocks; a design sent to a\n * white-label org's people, whose blocks stay unexpanded and draw nothing,\n * since they are the platform's; and a design on a deployment that names no\n * marketing site, or whose blocks could not be read. The chrome is in the\n * brand of the send — the org's email logo rides in its header, so a\n * white-label mail carries exactly one logo.\n *\n * Either way, a band the design draws itself is left out of the chrome\n * (AGL-3372): a design that opens with its own Header gets the footer only.\n */\nexport function renderLoadedSystemEmail(\n loaded: LoadedSystemEmail,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): RenderedSystemEmail | null {\n const merged = { ...PLATFORM_BRAND_MERGE_TOKENS, ...merge }\n const blocks = drawsPlatformBlocks(loaded, merged, options)\n const nodes = (\n blocks\n ? composeReusableComponentNodes(loaded.nodes as never, loaded.components)\n : loaded.nodes\n ) as Record<string, unknown>\n // The brand's chrome around every copy, less any band the design draws\n // itself (AGL-3372). Unexpanded placements draw nothing in this send, so\n // they only count when the blocks are drawn.\n const chrome = chromeForDesign({\n stored: blocks ? loaded.nodes : null,\n composed: nodes,\n chrome: buildSystemEmailChrome({\n definition: loaded.definition,\n merged,\n brandLogoUrl: options.brandLogoUrl,\n brandHomeUrl: options.brandHomeUrl,\n }),\n })\n const rendered = renderEmailHtml({\n nodes: nodes as never,\n // Besigner maps are rooted at '_@_', not renderEmailHtml's default\n // 'root' — without this a designed template rendered empty and the send\n // fell back to built-in copy, so designing did nothing (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merged),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merged),\n merge: merged,\n // The same policy the console's own preview of these nodes applies —\n // `sanitizeCustomHtml` in the email plugin delegates to this exact\n // function, so what staff review and what the recipient receives are one\n // function of one string rather than two that have to be kept in step.\n sanitize: sanitizeAuthorHtml,\n // Without this a staff-picked image resolves to a site-relative CDN path,\n // which is a broken-image box in the recipient's inbox (AGL-1224). The\n // chrome's logo resolves against it too.\n mediaOrigin: CONSOLE_ORIGIN,\n // In the console's theme (AGL-3370), under a white-label org's own color\n // when it set one: a color staff picked in the Besigner is a palette\n // token, and a button nobody colored wears the brand's accent.\n ...systemEmailTheme(options),\n // No `brandLogoUrl`: the org's email logo (AGL-2139) is the chrome\n // header's logo whenever there is one to draw, and a send that draws the\n // platform's blocks is by definition one that carries none.\n ...(chrome ? { chrome } : {}),\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merged),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n source: loaded.source,\n }\n}\n\n/**\n * Renders the system email a key sends to one recipient (AGL-750): the\n * published staff design, or the catalog's built-in copy (AGL-3322), in the\n * header and footer of the brand it is sent under.\n *\n * **`null` only for a key that is unknown or not Resend-delivered**, which\n * has nothing here to render. No template document, no published version, an\n * empty node map and a Firestore failure all render the built-in copy — the\n * same copy the editor seeds a first design with — so a missing, half-saved\n * or broken template can never mean a customer receives no email, or a bare\n * one. Every caller still keeps its own hard-coded copy behind this, as the\n * last resort for a render that produced nothing.\n *\n * A single-recipient convenience over {@link loadSystemEmail} +\n * {@link renderLoadedSystemEmail}; a batch should call those two directly so\n * it reads the template once.\n */\nexport async function renderSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n const loaded = await loadSystemEmail(templateKey)\n return loaded ? renderLoadedSystemEmail(loaded, merge, options) : null\n}\n\n/**\n * The email a template sends right now, for the staff test-send endpoint\n * (AGL-766): exactly what a real send of it renders — the published design,\n * or the built-in copy, with the same header and footer — so a test shows\n * what recipients receive. `null` for an unknown or non-Resend key.\n */\nexport async function renderEffectiveSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n return renderSystemEmail(templateKey, merge, options)\n}\n\n/**\n * The brand one system email is sent under (AGL-3367): the merge tokens, the\n * chrome options and the sender name, from one place.\n *\n * `null` is the platform's own brand, and it is the answer for everything\n * the platform says on its own behalf: a staff alert, a legal receipt, an\n * enforcement notice. A workspace-context email passes the workspace's org\n * document, and `resolveBrandingProfile` decides from there: a white-label\n * org gets its own name, logo, home and support in the header and footer,\n * and every other org resolves to the platform profile, so the same call is\n * right for both.\n */\nexport interface SystemEmailBrand {\n merge: Record<string, string>\n options: SystemEmailBrandOptions\n /** The display name to send under; absent sends under the platform's. */\n fromName?: string\n /** The org's own name, for the `{{org.name}}` its copy may use. */\n orgName?: string\n}\n\n/** See {@link SystemEmailBrand}. */\nexport function systemEmailBrand(\n org: Readonly<Record<string, unknown>> | null | undefined,\n): SystemEmailBrand {\n if (!org) return { merge: {}, options: {} }\n const branding = resolveBrandingProfile(org as never)\n const orgName = typeof org['name'] === 'string' ? org['name'].trim() : ''\n return {\n ...(orgName ? { orgName } : {}),\n merge: brandMergeTokens(branding),\n options: {\n brandLogoUrl: branding.emailLogoUrl,\n brandHomeUrl: branding.homeUrl,\n brandPrimaryColor: branding.primaryColor,\n },\n fromName: branding.fromName,\n }\n}\n\n/**\n * {@link systemEmailBrand} for an org read by id, for a sender that holds\n * only the id. A failed or missing read is the platform's brand: the email\n * still goes, and a white-label org's people see the platform's chrome on\n * one message rather than no message.\n */\nexport async function orgSystemEmailBrand(\n orgId: string | null | undefined,\n): Promise<SystemEmailBrand> {\n if (!orgId) return systemEmailBrand(null)\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .get()\n return systemEmailBrand(snapshot.exists ? (snapshot.data() ?? null) : null)\n } catch (error) {\n console.error(`system email brand for org ${orgId} failed to load`, error)\n return systemEmailBrand(null)\n }\n}\n\n/** What a send takes from a rendered system email. */\nexport interface SystemEmailContent {\n subject: string\n text: string\n html?: string\n}\n\n/**\n * The subject, text and HTML to send, with the caller's own copy as the\n * last resort for a render that produced nothing: the shape every sender of\n * a system email repeats, written once.\n */\nexport function systemEmailContent(\n rendered: RenderedSystemEmail | null,\n fallback: { subject: string; text: string },\n): SystemEmailContent {\n return {\n subject: rendered?.subject || fallback.subject,\n text: rendered?.text || fallback.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n }\n}\n\n/**\n * Renders a key for one recipient in a brand and returns what to send\n * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in\n * one call, for the senders with one message to build. Never throws; a\n * render that fails sends the fallback copy, which is what it sent before.\n */\nexport async function renderSystemEmailContent(\n templateKey: string,\n merge: Record<string, string>,\n brand: SystemEmailBrand,\n fallback: { subject: string; text: string },\n): Promise<SystemEmailContent> {\n const rendered = await renderSystemEmail(\n templateKey,\n { ...brand.merge, ...merge },\n brand.options,\n ).catch((error) => {\n console.error(`system email ${templateKey} failed to render`, error)\n return null\n })\n return systemEmailContent(rendered, fallback)\n}\n\nexport default renderSystemEmail\n"],"names":["process","EMAIL_NODE_ROOT_ID","PLATFORM_EMAIL_PALETTE","chromeForDesign","SYSTEM_EMAIL_COLLECTION","buildEmailPalette","buildDefaultEmailNodeMap","getSystemEmailTemplate","renderEmailHtml","substituteMergeTokens","firebaseAdmin","platformMarketingHostId","brandMergeTokens","collectReferencedComponentIds","composeReusableComponentNodes","decodeStoredNodes","resolveBrandingProfile","sanitizeAuthorHtml","hostComponentReader","loadReferencedComponents","PLATFORM_BRAND_MERGE_TOKENS","buildSystemEmailChrome","isPlatformBrandedSend","blankUnresolvedTokens","value","replace","CONSOLE_ORIGIN","env","NEXT_PUBLIC_CONSOLE_URL","loadPlatformEmailBlocks","firestore","templateKey","nodes","size","undefined","hostId","onSkipped","skipped","console","warn","JSON","stringify","tag","error","drawsPlatformBlocks","loaded","merged","options","Boolean","components","Object","keys","length","catalogDefault","definition","subjectTemplate","defaultSubject","preheaderTemplate","source","loadSystemEmail","deliveredBy","templateSnapshot","app","templateRef","collection","doc","get","exists","versionId","versionSnapshot","String","systemEmailTheme","primaryColor","brandPrimaryColor","trim","palette","base","buttonRadius","renderLoadedSystemEmail","merge","rendered","blocks","chrome","stored","composed","brandLogoUrl","brandHomeUrl","rootId","subject","preheader","sanitize","mediaOrigin","html","text","renderSystemEmail","renderEffectiveSystemEmail","systemEmailBrand","org","branding","orgName","emailLogoUrl","homeUrl","fromName","orgSystemEmailBrand","orgId","snapshot","data","systemEmailContent","fallback","renderSystemEmailContent","brand","catch"],"mappings":"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"}
|