@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223
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/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +25 -4
- package/src/lib/server/host-visitor-records.js +39 -11
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +52 -36
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +11 -7
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +50 -9
- package/src/lib/server/web-risk.js +290 -155
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -3
- package/src/tenancy.js +5 -3
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
/**
|
|
3
|
-
* @license
|
|
4
|
-
* Copyright 2026 Aglyn LLC
|
|
5
|
-
*
|
|
6
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
-
* you may not use this file except in compliance with the License.
|
|
8
|
-
* You may obtain a copy of the License at
|
|
9
|
-
*
|
|
10
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
-
*
|
|
12
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
-
* See the License for the specific language governing permissions and
|
|
16
|
-
* limitations under the License.
|
|
17
|
-
*/ import { CRM_COLLECTIONS, isCrmEmailDeliveryState, nextCrmEmailDeliveryState } from "@aglyn/aglyn/server";
|
|
18
|
-
import { createHash } from "crypto";
|
|
19
|
-
import { FieldValue } from "firebase-admin/firestore";
|
|
20
|
-
/**
|
|
21
|
-
* The activity row a one-to-one email is logged as, on the server side
|
|
22
|
-
* (AGL-2615): where it lives, how it is written, and how the delivery
|
|
23
|
-
* webhook moves its state.
|
|
24
|
-
*
|
|
25
|
-
* Two writers log a sent email — the console's `crm/email-send` route and
|
|
26
|
-
* the `sendEmail` automation step — and one reader advances it, the Resend
|
|
27
|
-
* event webhook. The reader has to find the row from nothing but the tags
|
|
28
|
-
* on the message, so the id is minted BEFORE the send and stamped on it,
|
|
29
|
-
* and the row is written only after the provider accepted the message. A
|
|
30
|
-
* row written first would be an "email" on the timeline for a message that
|
|
31
|
-
* never left.
|
|
32
|
-
*
|
|
33
|
-
* Firestore is a parameter throughout, as in `crm-records.ts`: the callers
|
|
34
|
-
* hold one, and a module that reached for the Admin app itself would drag
|
|
35
|
-
* it into every spec that only wants the arithmetic.
|
|
36
|
-
*/ /** `orgs/{orgId}/crmActivities/{activityId}`. */ export function crmActivityRef(firestore, orgId, activityId) {
|
|
37
|
-
return firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities).doc(activityId);
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* A fresh reference for the row a send is about to earn — `doc()` with no
|
|
41
|
-
* id mints one locally and reads nothing, so the id can ride the message
|
|
42
|
-
* as a tag before anything is written.
|
|
43
|
-
*/ export function newCrmActivityRef(firestore, orgId) {
|
|
44
|
-
return firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities).doc();
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Writes the row a sent email is logged as, stamped with the server clock.
|
|
48
|
-
*
|
|
49
|
-
* `set` rather than `add`, because the id was minted ahead of the send and
|
|
50
|
-
* is already on the message; a second id here would be the one the webhook
|
|
51
|
-
* could not find.
|
|
52
|
-
*/ export async function writeCrmEmailActivity(ref, activity) {
|
|
53
|
-
await ref.set(_extends({}, activity, {
|
|
54
|
-
createdAt: FieldValue.serverTimestamp(),
|
|
55
|
-
updatedAt: FieldValue.serverTimestamp()
|
|
56
|
-
}));
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Which delivery state one normalized delivery event moves a row to, or
|
|
60
|
-
* `null` for an event that moves nothing.
|
|
61
|
-
*
|
|
62
|
-
* Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the
|
|
63
|
-
* provider's wire strings: the mail provider's `deliveryEvents` is the one place
|
|
64
|
-
* that reads those, and this module must not become a second. The five
|
|
65
|
-
* states the timeline shows share the log's names exactly, so the mapping
|
|
66
|
-
* is membership; `delayed` and `failed` are the log's own and the row keeps
|
|
67
|
-
* whatever it held.
|
|
68
|
-
*/ export function crmEmailDeliveryStateForEvent(type) {
|
|
69
|
-
return isCrmEmailDeliveryState(type) ? type : null;
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* One delivery event, onto the activity row it names (AGL-2615).
|
|
73
|
-
*
|
|
74
|
-
* A TRANSACTION, because the rule is "keep the later state" and two events
|
|
75
|
-
* for one message routinely arrive together — `delivered` and `opened`
|
|
76
|
-
* within the same second from a mail client that fetches images on
|
|
77
|
-
* receipt. Two plain read-then-writes interleaved would let the earlier
|
|
78
|
-
* event land last and print "Delivered" over an open that already
|
|
79
|
-
* happened; inside a transaction the second read sees the first write and
|
|
80
|
-
* `nextCrmEmailDeliveryState` answers "unchanged".
|
|
81
|
-
*
|
|
82
|
-
* A row that is not an email activity is left alone: the tag named it, but
|
|
83
|
-
* a delivery state on a call log would be a chip the timeline has no way to
|
|
84
|
-
* explain. **Never throws** — the webhook answers the provider 200 whatever
|
|
85
|
-
* happens here, so a failure is logged and reported rather than raised.
|
|
86
|
-
*/ export async function recordCrmEmailDelivery(firestore, input) {
|
|
87
|
-
var _input_detail;
|
|
88
|
-
const { orgId, activityId, state } = input;
|
|
89
|
-
if (!orgId || !activityId) return 'missing';
|
|
90
|
-
const ref = crmActivityRef(firestore, orgId, activityId);
|
|
91
|
-
const detail = String((_input_detail = input.detail) != null ? _input_detail : '').replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
92
|
-
try {
|
|
93
|
-
return await firestore.runTransaction(async (transaction)=>{
|
|
94
|
-
const snapshot = await transaction.get(ref);
|
|
95
|
-
if (!snapshot.exists || snapshot.get('kind') !== 'email') return 'missing';
|
|
96
|
-
const current = snapshot.get('deliveryState');
|
|
97
|
-
const next = nextCrmEmailDeliveryState(current, state);
|
|
98
|
-
if (next === current) return 'unchanged';
|
|
99
|
-
transaction.update(ref, _extends({
|
|
100
|
-
deliveryState: next,
|
|
101
|
-
deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now()
|
|
102
|
-
}, detail ? {
|
|
103
|
-
deliveryDetail: detail
|
|
104
|
-
} : {}, {
|
|
105
|
-
updatedAt: FieldValue.serverTimestamp()
|
|
106
|
-
}));
|
|
107
|
-
return 'advanced';
|
|
108
|
-
});
|
|
109
|
-
} catch (error) {
|
|
110
|
-
console.error('[crm] email delivery state write failed', orgId, activityId, error);
|
|
111
|
-
return 'failed';
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with
|
|
116
|
-
* the id derived from the message's key — its `Message-ID`, else the
|
|
117
|
-
* provider's id — so a second delivery of one message addresses the row
|
|
118
|
-
* the first delivery wrote. A hash rather than the key itself, because a
|
|
119
|
-
* `Message-ID` carries `<`, `@` and `/`, which a document id may not.
|
|
120
|
-
*/ export function crmCapturedEmailActivityRef(firestore, orgId, key) {
|
|
121
|
-
const digest = createHash('sha256').update(key).digest('hex').slice(0, 28);
|
|
122
|
-
return crmActivityRef(firestore, orgId, `cap_${digest}`);
|
|
123
|
-
}
|
|
124
|
-
const isAlreadyExists = (error)=>{
|
|
125
|
-
var _ref;
|
|
126
|
-
const failure = error;
|
|
127
|
-
return (failure == null ? void 0 : failure.code) === 6 || (failure == null ? void 0 : failure.code) === 'already-exists' || /ALREADY_EXISTS/i.test(String((_ref = failure == null ? void 0 : failure.message) != null ? _ref : ''));
|
|
128
|
-
};
|
|
129
|
-
/**
|
|
130
|
-
* Writes a captured message's row ONCE: `create()`, which refuses a
|
|
131
|
-
* document that already exists, is what makes two deliveries of one
|
|
132
|
-
* message one row even when they arrive together — the second create is
|
|
133
|
-
* the one Firestore refuses, and this answers `duplicate` for it. Any
|
|
134
|
-
* other failure is the caller's.
|
|
135
|
-
*/ export async function createCrmEmailActivity(ref, activity) {
|
|
136
|
-
try {
|
|
137
|
-
await ref.create(_extends({}, activity, {
|
|
138
|
-
createdAt: FieldValue.serverTimestamp(),
|
|
139
|
-
updatedAt: FieldValue.serverTimestamp()
|
|
140
|
-
}));
|
|
141
|
-
return 'created';
|
|
142
|
-
} catch (error) {
|
|
143
|
-
if (isAlreadyExists(error)) return 'duplicate';
|
|
144
|
-
throw error;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
//# sourceMappingURL=crm-email-activity.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-email-activity.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 CRM_COLLECTIONS,\n type CrmActivity,\n type CrmEmailDeliveryState,\n isCrmEmailDeliveryState,\n nextCrmEmailDeliveryState,\n} from '@aglyn/aglyn/server'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The activity row a one-to-one email is logged as, on the server side\n * (AGL-2615): where it lives, how it is written, and how the delivery\n * webhook moves its state.\n *\n * Two writers log a sent email — the console's `crm/email-send` route and\n * the `sendEmail` automation step — and one reader advances it, the Resend\n * event webhook. The reader has to find the row from nothing but the tags\n * on the message, so the id is minted BEFORE the send and stamped on it,\n * and the row is written only after the provider accepted the message. A\n * row written first would be an \"email\" on the timeline for a message that\n * never left.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts`: the callers\n * hold one, and a module that reached for the Admin app itself would drag\n * it into every spec that only wants the arithmetic.\n */\n\n/** `orgs/{orgId}/crmActivities/{activityId}`. */\nexport function crmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n activityId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc(activityId)\n}\n\n/**\n * A fresh reference for the row a send is about to earn — `doc()` with no\n * id mints one locally and reads nothing, so the id can ride the message\n * as a tag before anything is written.\n */\nexport function newCrmActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.activities)\n .doc()\n}\n\n/**\n * Writes the row a sent email is logged as, stamped with the server clock.\n *\n * `set` rather than `add`, because the id was minted ahead of the send and\n * is already on the message; a second id here would be the one the webhook\n * could not find.\n */\nexport async function writeCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<void> {\n await ref.set({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n}\n\n/**\n * Which delivery state one normalized delivery event moves a row to, or\n * `null` for an event that moves nothing.\n *\n * Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the\n * provider's wire strings: the mail provider's `deliveryEvents` is the one place\n * that reads those, and this module must not become a second. The five\n * states the timeline shows share the log's names exactly, so the mapping\n * is membership; `delayed` and `failed` are the log's own and the row keeps\n * whatever it held.\n */\nexport function crmEmailDeliveryStateForEvent(\n type: string | null | undefined,\n): CrmEmailDeliveryState | null {\n return isCrmEmailDeliveryState(type) ? type : null\n}\n\nexport type CrmEmailDeliveryOutcome =\n /** The row moved to the incoming state. */\n | 'advanced'\n /** The row already held this state or a later one; nothing written. */\n | 'unchanged'\n /** No email activity by that id in that org; nothing written. */\n | 'missing'\n /** The read or the write threw; logged, and nothing is promised. */\n | 'failed'\n\n/**\n * One delivery event, onto the activity row it names (AGL-2615).\n *\n * A TRANSACTION, because the rule is \"keep the later state\" and two events\n * for one message routinely arrive together — `delivered` and `opened`\n * within the same second from a mail client that fetches images on\n * receipt. Two plain read-then-writes interleaved would let the earlier\n * event land last and print \"Delivered\" over an open that already\n * happened; inside a transaction the second read sees the first write and\n * `nextCrmEmailDeliveryState` answers \"unchanged\".\n *\n * A row that is not an email activity is left alone: the tag named it, but\n * a delivery state on a call log would be a chip the timeline has no way to\n * explain. **Never throws** — the webhook answers the provider 200 whatever\n * happens here, so a failure is logged and reported rather than raised.\n */\nexport async function recordCrmEmailDelivery(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n activityId: string\n state: CrmEmailDeliveryState\n /** When the provider says it happened, epoch ms. */\n atMs: number\n /**\n * What the receiving server said (AGL-3245), for a failure: the bounce's\n * diagnostic, scrubbed of the address. Kept beside the state as\n * `deliveryDetail`, so the timeline can say why the message did not land.\n */\n detail?: string | null\n },\n): Promise<CrmEmailDeliveryOutcome> {\n const { orgId, activityId, state } = input\n if (!orgId || !activityId) return 'missing'\n const ref = crmActivityRef(firestore, orgId, activityId)\n const detail = String(input.detail ?? '').replace(/\\s+/g, ' ').trim().slice(0, 500)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n if (!snapshot.exists || snapshot.get('kind') !== 'email') return 'missing'\n const current = snapshot.get('deliveryState')\n const next = nextCrmEmailDeliveryState(current, state)\n if (next === current) return 'unchanged'\n transaction.update(ref, {\n deliveryState: next,\n deliveryAtMs: Number.isFinite(input.atMs) ? input.atMs : Date.now(),\n ...(detail ? { deliveryDetail: detail } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'advanced'\n })\n } catch (error) {\n console.error('[crm] email delivery state write failed', orgId, activityId, error)\n return 'failed'\n }\n}\n\n/**\n * `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with\n * the id derived from the message's key — its `Message-ID`, else the\n * provider's id — so a second delivery of one message addresses the row\n * the first delivery wrote. A hash rather than the key itself, because a\n * `Message-ID` carries `<`, `@` and `/`, which a document id may not.\n */\nexport function crmCapturedEmailActivityRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n key: string,\n): FirebaseFirestore.DocumentReference {\n const digest = createHash('sha256').update(key).digest('hex').slice(0, 28)\n return crmActivityRef(firestore, orgId, `cap_${digest}`)\n}\n\n/** What a create answered: the row is new, or it was already there. */\nexport type CrmEmailActivityCreateOutcome = 'created' | 'duplicate'\n\nconst isAlreadyExists = (error: unknown): boolean => {\n const failure = error as { code?: unknown; message?: unknown } | null\n return (\n failure?.code === 6 ||\n failure?.code === 'already-exists' ||\n /ALREADY_EXISTS/i.test(String(failure?.message ?? ''))\n )\n}\n\n/**\n * Writes a captured message's row ONCE: `create()`, which refuses a\n * document that already exists, is what makes two deliveries of one\n * message one row even when they arrive together — the second create is\n * the one Firestore refuses, and this answers `duplicate` for it. Any\n * other failure is the caller's.\n */\nexport async function createCrmEmailActivity(\n ref: FirebaseFirestore.DocumentReference,\n activity: CrmActivity,\n): Promise<CrmEmailActivityCreateOutcome> {\n try {\n await ref.create({\n ...activity,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return 'created'\n } catch (error) {\n if (isAlreadyExists(error)) return 'duplicate'\n throw error\n }\n}\n"],"names":["CRM_COLLECTIONS","isCrmEmailDeliveryState","nextCrmEmailDeliveryState","createHash","FieldValue","crmActivityRef","firestore","orgId","activityId","collection","doc","activities","newCrmActivityRef","writeCrmEmailActivity","ref","activity","set","createdAt","serverTimestamp","updatedAt","crmEmailDeliveryStateForEvent","type","recordCrmEmailDelivery","input","state","detail","String","replace","trim","slice","runTransaction","transaction","snapshot","get","exists","current","next","update","deliveryState","deliveryAtMs","Number","isFinite","atMs","Date","now","deliveryDetail","error","console","crmCapturedEmailActivityRef","key","digest","isAlreadyExists","failure","code","test","message","createCrmEmailActivity","create"],"mappings":"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"}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/
|
|
17
|
-
import { type CrmNextActivityLink, type CrmNextActivityLinkField } from '@aglyn/aglyn/app-utils/crm-next-activity';
|
|
18
|
-
/** The collection each link field's record lives in, under `orgs/{orgId}/`. */
|
|
19
|
-
export declare const CRM_NEXT_ACTIVITY_COLLECTIONS: Readonly<Record<CrmNextActivityLinkField, string>>;
|
|
20
|
-
export interface CrmNextActivityRecompute {
|
|
21
|
-
/** Records whose stored value was written (or rewritten to the same figure). */
|
|
22
|
-
records: number;
|
|
23
|
-
/** Records that no longer exist — a task can outlive what it was filed against. */
|
|
24
|
-
missing: number;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Recompute `nextTaskAtMs` for every record the links name, once each.
|
|
28
|
-
*
|
|
29
|
-
* Hand in BOTH sides of a change — the links a task carried before an
|
|
30
|
-
* update and the ones it carries after — so a task moved from one deal to
|
|
31
|
-
* another leaves neither stale. A record the task named that has since been
|
|
32
|
-
* deleted is counted in `missing` and skipped; nothing here creates one.
|
|
33
|
-
*/
|
|
34
|
-
export declare function recomputeCrmNextTaskAt(firestore: FirebaseFirestore.Firestore, orgId: string, links: readonly (CrmNextActivityLink | null | undefined)[]): Promise<CrmNextActivityRecompute>;
|
|
35
|
-
/**
|
|
36
|
-
* The same, from a task document rather than a link — what a writer holds
|
|
37
|
-
* after it has read the task it is about to change or has just changed.
|
|
38
|
-
*/
|
|
39
|
-
export declare function crmNextActivityLinksOf(task: Record<string, unknown> | null | undefined): CrmNextActivityLink;
|
|
40
|
-
/** The most open tasks one org-wide recompute walks. */
|
|
41
|
-
export declare const CRM_NEXT_ACTIVITY_SWEEP_MAX = 20000;
|
|
42
|
-
export interface CrmNextActivitySweep {
|
|
43
|
-
/** Open tasks read. */
|
|
44
|
-
tasks: number;
|
|
45
|
-
/** Records that now carry a time. */
|
|
46
|
-
scheduled: number;
|
|
47
|
-
/** Records that carried a time and now carry `null`. */
|
|
48
|
-
cleared: number;
|
|
49
|
-
/** Whether the org held more open tasks than the sweep reads. */
|
|
50
|
-
truncated: boolean;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* The one-off recompute for a whole organization — the Fields section's
|
|
54
|
-
* "Recompute next activity" (AGL-2661), for the records written before the
|
|
55
|
-
* field existed and for whatever a client-direct write left behind.
|
|
56
|
-
*
|
|
57
|
-
* Two passes and no per-record task query: every OPEN task once, grouped
|
|
58
|
-
* by the records it names, so each record with something scheduled is
|
|
59
|
-
* written its earliest due time; then every record in the three
|
|
60
|
-
* collections that CARRIES a time and was not in that group, cleared to
|
|
61
|
-
* `null`. A record that was never written is left alone — every reader
|
|
62
|
-
* already treats absent as `null`, and writing a null onto every contact
|
|
63
|
-
* an org holds would cost a write per person to say nothing.
|
|
64
|
-
*
|
|
65
|
-
* The clearing pass runs only when the first pass read EVERY open task.
|
|
66
|
-
* "No open task names this record" is a statement about all of them, and
|
|
67
|
-
* an organization holding more than the cap has open tasks the sweep never
|
|
68
|
-
* saw — clearing on a partial read would blank records that are perfectly
|
|
69
|
-
* scheduled. Past the cap the sweep is the writing half alone, and
|
|
70
|
-
* `truncated` says so.
|
|
71
|
-
*/
|
|
72
|
-
export declare function sweepCrmNextTaskAt(firestore: FirebaseFirestore.Firestore, orgId: string, options?: {
|
|
73
|
-
max?: number;
|
|
74
|
-
}): Promise<CrmNextActivitySweep>;
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/ /**
|
|
17
|
-
* The `nextTaskAtMs` a record carries, as the Admin SDK writes it (AGL-2661).
|
|
18
|
-
*
|
|
19
|
-
* A contact, a company and a deal each denormalize when their earliest OPEN
|
|
20
|
-
* task is due, so a list can draw a "Next activity" column and answer
|
|
21
|
-
* "which deals have nothing scheduled" without a task query per row. This
|
|
22
|
-
* is the writer: given the records a task names, read that record's tasks
|
|
23
|
-
* back and store the answer `nextTaskAtMsOf` gives. Every server-side task
|
|
24
|
-
* writer — the console's task routes, the REST resource, the workflow step
|
|
25
|
-
* that files a task, the merge that repoints one — calls it after its own
|
|
26
|
-
* write; a client-direct task write asks the console's `crm/next-activity`
|
|
27
|
-
* route, which calls it too.
|
|
28
|
-
*
|
|
29
|
-
* Imported by module path rather than from the `@aglyn/aglyn/server`
|
|
30
|
-
* barrel, for the reason `contact-company-link.ts` gives.
|
|
31
|
-
*
|
|
32
|
-
* The task query is ONE equality on the link field, the query the record
|
|
33
|
-
* cards already run, and the status is judged in memory — no composite
|
|
34
|
-
* index, and a record with a hundred done tasks reads a hundred small
|
|
35
|
-
* documents once per write against it, which is the price of never being
|
|
36
|
-
* wrong. The record write is a bare `update()` of the one field, so
|
|
37
|
-
* `updatedAt` stays where the person's last edit left it: a task moving is
|
|
38
|
-
* not the record being edited, and the lists order by `updatedAt`.
|
|
39
|
-
*/ import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
|
|
40
|
-
import { CRM_NEXT_ACTIVITY_FIELD, mergeNextActivityLinks, nextActivityLinksOfTask, nextTaskAtMsOf, readNextTaskAtMs } from "@aglyn/aglyn/app-utils/crm-next-activity";
|
|
41
|
-
/** The collection each link field's record lives in, under `orgs/{orgId}/`. */ export const CRM_NEXT_ACTIVITY_COLLECTIONS = {
|
|
42
|
-
contactId: 'contacts',
|
|
43
|
-
companyId: CRM_COLLECTIONS.companies,
|
|
44
|
-
dealId: CRM_COLLECTIONS.deals
|
|
45
|
-
};
|
|
46
|
-
const isNotFound = (error)=>{
|
|
47
|
-
const { code, message } = error != null ? error : {};
|
|
48
|
-
return code === 5 || code === 'not-found' || code === 'NOT_FOUND' || typeof message === 'string' && message.includes('NOT_FOUND');
|
|
49
|
-
};
|
|
50
|
-
/**
|
|
51
|
-
* Store one record's `nextTaskAtMs` from its tasks. `null` for a record
|
|
52
|
-
* that no longer exists, else the value written.
|
|
53
|
-
*/ async function recomputeOne(orgRef, field, id) {
|
|
54
|
-
const tasks = await orgRef.collection(CRM_COLLECTIONS.tasks).where(field, '==', id).get();
|
|
55
|
-
const value = nextTaskAtMsOf(tasks.docs.map((doc)=>({
|
|
56
|
-
status: doc.get('status'),
|
|
57
|
-
dueAtMs: doc.get('dueAtMs')
|
|
58
|
-
})));
|
|
59
|
-
try {
|
|
60
|
-
await orgRef.collection(CRM_NEXT_ACTIVITY_COLLECTIONS[field]).doc(id).update({
|
|
61
|
-
[CRM_NEXT_ACTIVITY_FIELD]: value
|
|
62
|
-
});
|
|
63
|
-
} catch (error) {
|
|
64
|
-
if (isNotFound(error)) return {
|
|
65
|
-
written: false
|
|
66
|
-
};
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
written: true,
|
|
71
|
-
value
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Recompute `nextTaskAtMs` for every record the links name, once each.
|
|
76
|
-
*
|
|
77
|
-
* Hand in BOTH sides of a change — the links a task carried before an
|
|
78
|
-
* update and the ones it carries after — so a task moved from one deal to
|
|
79
|
-
* another leaves neither stale. A record the task named that has since been
|
|
80
|
-
* deleted is counted in `missing` and skipped; nothing here creates one.
|
|
81
|
-
*/ export async function recomputeCrmNextTaskAt(firestore, orgId, links) {
|
|
82
|
-
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
83
|
-
const result = {
|
|
84
|
-
records: 0,
|
|
85
|
-
missing: 0
|
|
86
|
-
};
|
|
87
|
-
for (const { field, id } of mergeNextActivityLinks(links)){
|
|
88
|
-
const outcome = await recomputeOne(orgRef, field, id);
|
|
89
|
-
if (outcome.written) result.records += 1;
|
|
90
|
-
else result.missing += 1;
|
|
91
|
-
}
|
|
92
|
-
return result;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* The same, from a task document rather than a link — what a writer holds
|
|
96
|
-
* after it has read the task it is about to change or has just changed.
|
|
97
|
-
*/ export function crmNextActivityLinksOf(task) {
|
|
98
|
-
return nextActivityLinksOfTask(task);
|
|
99
|
-
}
|
|
100
|
-
/** The most open tasks one org-wide recompute walks. */ export const CRM_NEXT_ACTIVITY_SWEEP_MAX = 20000;
|
|
101
|
-
/**
|
|
102
|
-
* The one-off recompute for a whole organization — the Fields section's
|
|
103
|
-
* "Recompute next activity" (AGL-2661), for the records written before the
|
|
104
|
-
* field existed and for whatever a client-direct write left behind.
|
|
105
|
-
*
|
|
106
|
-
* Two passes and no per-record task query: every OPEN task once, grouped
|
|
107
|
-
* by the records it names, so each record with something scheduled is
|
|
108
|
-
* written its earliest due time; then every record in the three
|
|
109
|
-
* collections that CARRIES a time and was not in that group, cleared to
|
|
110
|
-
* `null`. A record that was never written is left alone — every reader
|
|
111
|
-
* already treats absent as `null`, and writing a null onto every contact
|
|
112
|
-
* an org holds would cost a write per person to say nothing.
|
|
113
|
-
*
|
|
114
|
-
* The clearing pass runs only when the first pass read EVERY open task.
|
|
115
|
-
* "No open task names this record" is a statement about all of them, and
|
|
116
|
-
* an organization holding more than the cap has open tasks the sweep never
|
|
117
|
-
* saw — clearing on a partial read would blank records that are perfectly
|
|
118
|
-
* scheduled. Past the cap the sweep is the writing half alone, and
|
|
119
|
-
* `truncated` says so.
|
|
120
|
-
*/ export async function sweepCrmNextTaskAt(firestore, orgId, options = {}) {
|
|
121
|
-
var _options_max;
|
|
122
|
-
const max = (_options_max = options.max) != null ? _options_max : CRM_NEXT_ACTIVITY_SWEEP_MAX;
|
|
123
|
-
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
124
|
-
const open = await orgRef.collection(CRM_COLLECTIONS.tasks).where('status', '==', 'open').limit(max + 1).get();
|
|
125
|
-
const truncated = open.docs.length > max;
|
|
126
|
-
const docs = open.docs.slice(0, max);
|
|
127
|
-
const earliest = new Map();
|
|
128
|
-
for (const field of Object.keys(CRM_NEXT_ACTIVITY_COLLECTIONS)){
|
|
129
|
-
earliest.set(field, new Map());
|
|
130
|
-
}
|
|
131
|
-
for (const doc of docs){
|
|
132
|
-
const task = {
|
|
133
|
-
status: doc.get('status'),
|
|
134
|
-
dueAtMs: doc.get('dueAtMs')
|
|
135
|
-
};
|
|
136
|
-
const links = nextActivityLinksOfTask({
|
|
137
|
-
contactId: doc.get('contactId'),
|
|
138
|
-
companyId: doc.get('companyId'),
|
|
139
|
-
dealId: doc.get('dealId')
|
|
140
|
-
});
|
|
141
|
-
for (const { field, id } of mergeNextActivityLinks([
|
|
142
|
-
links
|
|
143
|
-
])){
|
|
144
|
-
var _byId_get;
|
|
145
|
-
const byId = earliest.get(field);
|
|
146
|
-
byId.set(id, nextTaskAtMsOf([
|
|
147
|
-
task,
|
|
148
|
-
{
|
|
149
|
-
status: 'open',
|
|
150
|
-
dueAtMs: (_byId_get = byId.get(id)) != null ? _byId_get : null
|
|
151
|
-
}
|
|
152
|
-
]));
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const sweep = {
|
|
156
|
-
tasks: docs.length,
|
|
157
|
-
scheduled: 0,
|
|
158
|
-
cleared: 0,
|
|
159
|
-
truncated
|
|
160
|
-
};
|
|
161
|
-
for (const [field, byId] of earliest){
|
|
162
|
-
const records = orgRef.collection(CRM_NEXT_ACTIVITY_COLLECTIONS[field]);
|
|
163
|
-
for (const [id, value] of byId){
|
|
164
|
-
try {
|
|
165
|
-
await records.doc(id).update({
|
|
166
|
-
[CRM_NEXT_ACTIVITY_FIELD]: value
|
|
167
|
-
});
|
|
168
|
-
sweep.scheduled += 1;
|
|
169
|
-
} catch (error) {
|
|
170
|
-
if (!isNotFound(error)) throw error;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
/*
|
|
174
|
-
* The stale half: a record that says something is scheduled when no
|
|
175
|
-
* open task names it any more. `> 0` is the one query a stored time
|
|
176
|
-
* answers without an index of its own, and matches what
|
|
177
|
-
* `readNextTaskAtMs` calls a usable value.
|
|
178
|
-
*/ if (truncated) continue;
|
|
179
|
-
const carrying = await records.where(CRM_NEXT_ACTIVITY_FIELD, '>', 0).get();
|
|
180
|
-
for (const doc of carrying.docs){
|
|
181
|
-
if (byId.has(doc.id)) continue;
|
|
182
|
-
if (readNextTaskAtMs({
|
|
183
|
-
nextTaskAtMs: doc.get(CRM_NEXT_ACTIVITY_FIELD)
|
|
184
|
-
}) === null) continue;
|
|
185
|
-
await doc.ref.update({
|
|
186
|
-
[CRM_NEXT_ACTIVITY_FIELD]: null
|
|
187
|
-
});
|
|
188
|
-
sweep.cleared += 1;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return sweep;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
//# sourceMappingURL=crm-next-activity.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-next-activity.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 `nextTaskAtMs` a record carries, as the Admin SDK writes it (AGL-2661).\n *\n * A contact, a company and a deal each denormalize when their earliest OPEN\n * task is due, so a list can draw a \"Next activity\" column and answer\n * \"which deals have nothing scheduled\" without a task query per row. This\n * is the writer: given the records a task names, read that record's tasks\n * back and store the answer `nextTaskAtMsOf` gives. Every server-side task\n * writer — the console's task routes, the REST resource, the workflow step\n * that files a task, the merge that repoints one — calls it after its own\n * write; a client-direct task write asks the console's `crm/next-activity`\n * route, which calls it too.\n *\n * Imported by module path rather than from the `@aglyn/aglyn/server`\n * barrel, for the reason `contact-company-link.ts` gives.\n *\n * The task query is ONE equality on the link field, the query the record\n * cards already run, and the status is judged in memory — no composite\n * index, and a record with a hundred done tasks reads a hundred small\n * documents once per write against it, which is the price of never being\n * wrong. The record write is a bare `update()` of the one field, so\n * `updatedAt` stays where the person's last edit left it: a task moving is\n * not the record being edited, and the lists order by `updatedAt`.\n */\n\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n CRM_NEXT_ACTIVITY_FIELD,\n type CrmNextActivityLink,\n type CrmNextActivityLinkField,\n mergeNextActivityLinks,\n nextActivityLinksOfTask,\n nextTaskAtMsOf,\n readNextTaskAtMs,\n} from '@aglyn/aglyn/app-utils/crm-next-activity'\n\n/** The collection each link field's record lives in, under `orgs/{orgId}/`. */\nexport const CRM_NEXT_ACTIVITY_COLLECTIONS: Readonly<Record<CrmNextActivityLinkField, string>> = {\n contactId: 'contacts',\n companyId: CRM_COLLECTIONS.companies,\n dealId: CRM_COLLECTIONS.deals,\n}\n\nexport interface CrmNextActivityRecompute {\n /** Records whose stored value was written (or rewritten to the same figure). */\n records: number\n /** Records that no longer exist — a task can outlive what it was filed against. */\n missing: number\n}\n\nconst isNotFound = (error: unknown): boolean => {\n const { code, message } = (error ?? {}) as { code?: unknown; message?: unknown }\n return (\n code === 5 ||\n code === 'not-found' ||\n code === 'NOT_FOUND' ||\n (typeof message === 'string' && message.includes('NOT_FOUND'))\n )\n}\n\n/**\n * Store one record's `nextTaskAtMs` from its tasks. `null` for a record\n * that no longer exists, else the value written.\n */\nasync function recomputeOne(\n orgRef: FirebaseFirestore.DocumentReference,\n field: CrmNextActivityLinkField,\n id: string,\n): Promise<{ written: true; value: number | null } | { written: false }> {\n const tasks = await orgRef\n .collection(CRM_COLLECTIONS.tasks)\n .where(field, '==', id)\n .get()\n const value = nextTaskAtMsOf(\n tasks.docs.map((doc) => ({\n status: doc.get('status'),\n dueAtMs: doc.get('dueAtMs'),\n })),\n )\n try {\n await orgRef\n .collection(CRM_NEXT_ACTIVITY_COLLECTIONS[field])\n .doc(id)\n .update({ [CRM_NEXT_ACTIVITY_FIELD]: value })\n } catch (error) {\n if (isNotFound(error)) return { written: false }\n throw error\n }\n return { written: true, value }\n}\n\n/**\n * Recompute `nextTaskAtMs` for every record the links name, once each.\n *\n * Hand in BOTH sides of a change — the links a task carried before an\n * update and the ones it carries after — so a task moved from one deal to\n * another leaves neither stale. A record the task named that has since been\n * deleted is counted in `missing` and skipped; nothing here creates one.\n */\nexport async function recomputeCrmNextTaskAt(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n links: readonly (CrmNextActivityLink | null | undefined)[],\n): Promise<CrmNextActivityRecompute> {\n const orgRef = firestore.collection('orgs').doc(orgId)\n const result: CrmNextActivityRecompute = { records: 0, missing: 0 }\n for (const { field, id } of mergeNextActivityLinks(links)) {\n const outcome = await recomputeOne(orgRef, field, id)\n if (outcome.written) result.records += 1\n else result.missing += 1\n }\n return result\n}\n\n/**\n * The same, from a task document rather than a link — what a writer holds\n * after it has read the task it is about to change or has just changed.\n */\nexport function crmNextActivityLinksOf(\n task: Record<string, unknown> | null | undefined,\n): CrmNextActivityLink {\n return nextActivityLinksOfTask(task as Parameters<typeof nextActivityLinksOfTask>[0])\n}\n\n/** The most open tasks one org-wide recompute walks. */\nexport const CRM_NEXT_ACTIVITY_SWEEP_MAX = 20_000\n\nexport interface CrmNextActivitySweep {\n /** Open tasks read. */\n tasks: number\n /** Records that now carry a time. */\n scheduled: number\n /** Records that carried a time and now carry `null`. */\n cleared: number\n /** Whether the org held more open tasks than the sweep reads. */\n truncated: boolean\n}\n\n/**\n * The one-off recompute for a whole organization — the Fields section's\n * \"Recompute next activity\" (AGL-2661), for the records written before the\n * field existed and for whatever a client-direct write left behind.\n *\n * Two passes and no per-record task query: every OPEN task once, grouped\n * by the records it names, so each record with something scheduled is\n * written its earliest due time; then every record in the three\n * collections that CARRIES a time and was not in that group, cleared to\n * `null`. A record that was never written is left alone — every reader\n * already treats absent as `null`, and writing a null onto every contact\n * an org holds would cost a write per person to say nothing.\n *\n * The clearing pass runs only when the first pass read EVERY open task.\n * \"No open task names this record\" is a statement about all of them, and\n * an organization holding more than the cap has open tasks the sweep never\n * saw — clearing on a partial read would blank records that are perfectly\n * scheduled. Past the cap the sweep is the writing half alone, and\n * `truncated` says so.\n */\nexport async function sweepCrmNextTaskAt(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { max?: number } = {},\n): Promise<CrmNextActivitySweep> {\n const max = options.max ?? CRM_NEXT_ACTIVITY_SWEEP_MAX\n const orgRef = firestore.collection('orgs').doc(orgId)\n const open = await orgRef\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .limit(max + 1)\n .get()\n const truncated = open.docs.length > max\n const docs = open.docs.slice(0, max)\n\n const earliest = new Map<CrmNextActivityLinkField, Map<string, number | null>>()\n for (const field of Object.keys(CRM_NEXT_ACTIVITY_COLLECTIONS) as CrmNextActivityLinkField[]) {\n earliest.set(field, new Map())\n }\n for (const doc of docs) {\n const task = { status: doc.get('status'), dueAtMs: doc.get('dueAtMs') }\n const links = nextActivityLinksOfTask({\n contactId: doc.get('contactId'),\n companyId: doc.get('companyId'),\n dealId: doc.get('dealId'),\n })\n for (const { field, id } of mergeNextActivityLinks([links])) {\n const byId = earliest.get(field) as Map<string, number | null>\n byId.set(id, nextTaskAtMsOf([task, { status: 'open', dueAtMs: byId.get(id) ?? null }]))\n }\n }\n\n const sweep: CrmNextActivitySweep = { tasks: docs.length, scheduled: 0, cleared: 0, truncated }\n for (const [field, byId] of earliest) {\n const records = orgRef.collection(CRM_NEXT_ACTIVITY_COLLECTIONS[field])\n for (const [id, value] of byId) {\n try {\n await records.doc(id).update({ [CRM_NEXT_ACTIVITY_FIELD]: value })\n sweep.scheduled += 1\n } catch (error) {\n if (!isNotFound(error)) throw error\n }\n }\n /*\n * The stale half: a record that says something is scheduled when no\n * open task names it any more. `> 0` is the one query a stored time\n * answers without an index of its own, and matches what\n * `readNextTaskAtMs` calls a usable value.\n */\n if (truncated) continue\n const carrying = await records.where(CRM_NEXT_ACTIVITY_FIELD, '>', 0).get()\n for (const doc of carrying.docs) {\n if (byId.has(doc.id)) continue\n if (readNextTaskAtMs({ nextTaskAtMs: doc.get(CRM_NEXT_ACTIVITY_FIELD) }) === null) continue\n await doc.ref.update({ [CRM_NEXT_ACTIVITY_FIELD]: null })\n sweep.cleared += 1\n }\n }\n return sweep\n}\n"],"names":["CRM_COLLECTIONS","CRM_NEXT_ACTIVITY_FIELD","mergeNextActivityLinks","nextActivityLinksOfTask","nextTaskAtMsOf","readNextTaskAtMs","CRM_NEXT_ACTIVITY_COLLECTIONS","contactId","companyId","companies","dealId","deals","isNotFound","error","code","message","includes","recomputeOne","orgRef","field","id","tasks","collection","where","get","value","docs","map","doc","status","dueAtMs","update","written","recomputeCrmNextTaskAt","firestore","orgId","links","result","records","missing","outcome","crmNextActivityLinksOf","task","CRM_NEXT_ACTIVITY_SWEEP_MAX","sweepCrmNextTaskAt","options","max","open","limit","truncated","length","slice","earliest","Map","Object","keys","set","byId","sweep","scheduled","cleared","carrying","has","nextTaskAtMs","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,SAASA,eAAe,QAAQ,6BAA4B;AAC5D,SACEC,uBAAuB,EAGvBC,sBAAsB,EACtBC,uBAAuB,EACvBC,cAAc,EACdC,gBAAgB,QACX,2CAA0C;AAEjD,6EAA6E,GAC7E,OAAO,MAAMC,gCAAoF;IAC/FC,WAAW;IACXC,WAAWR,gBAAgBS,SAAS;IACpCC,QAAQV,gBAAgBW,KAAK;AAC/B,EAAC;AASD,MAAMC,aAAa,CAACC;IAClB,MAAM,EAAEC,IAAI,EAAEC,OAAO,EAAE,GAAIF,gBAAAA,QAAS,CAAC;IACrC,OACEC,SAAS,KACTA,SAAS,eACTA,SAAS,eACR,OAAOC,YAAY,YAAYA,QAAQC,QAAQ,CAAC;AAErD;AAEA;;;CAGC,GACD,eAAeC,aACbC,MAA2C,EAC3CC,KAA+B,EAC/BC,EAAU;IAEV,MAAMC,QAAQ,MAAMH,OACjBI,UAAU,CAACtB,gBAAgBqB,KAAK,EAChCE,KAAK,CAACJ,OAAO,MAAMC,IACnBI,GAAG;IACN,MAAMC,QAAQrB,eACZiB,MAAMK,IAAI,CAACC,GAAG,CAAC,CAACC,MAAS,CAAA;YACvBC,QAAQD,IAAIJ,GAAG,CAAC;YAChBM,SAASF,IAAIJ,GAAG,CAAC;QACnB,CAAA;IAEF,IAAI;QACF,MAAMN,OACHI,UAAU,CAAChB,6BAA6B,CAACa,MAAM,EAC/CS,GAAG,CAACR,IACJW,MAAM,CAAC;YAAE,CAAC9B,wBAAwB,EAAEwB;QAAM;IAC/C,EAAE,OAAOZ,OAAO;QACd,IAAID,WAAWC,QAAQ,OAAO;YAAEmB,SAAS;QAAM;QAC/C,MAAMnB;IACR;IACA,OAAO;QAAEmB,SAAS;QAAMP;IAAM;AAChC;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeQ,uBACpBC,SAAsC,EACtCC,KAAa,EACbC,KAA0D;IAE1D,MAAMlB,SAASgB,UAAUZ,UAAU,CAAC,QAAQM,GAAG,CAACO;IAChD,MAAME,SAAmC;QAAEC,SAAS;QAAGC,SAAS;IAAE;IAClE,KAAK,MAAM,EAAEpB,KAAK,EAAEC,EAAE,EAAE,IAAIlB,uBAAuBkC,OAAQ;QACzD,MAAMI,UAAU,MAAMvB,aAAaC,QAAQC,OAAOC;QAClD,IAAIoB,QAAQR,OAAO,EAAEK,OAAOC,OAAO,IAAI;aAClCD,OAAOE,OAAO,IAAI;IACzB;IACA,OAAOF;AACT;AAEA;;;CAGC,GACD,OAAO,SAASI,uBACdC,IAAgD;IAEhD,OAAOvC,wBAAwBuC;AACjC;AAEA,sDAAsD,GACtD,OAAO,MAAMC,8BAA8B,MAAM;AAajD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,mBACpBV,SAAsC,EACtCC,KAAa,EACbU,UAA4B,CAAC,CAAC;QAElBA;IAAZ,MAAMC,OAAMD,eAAAA,QAAQC,GAAG,YAAXD,eAAeF;IAC3B,MAAMzB,SAASgB,UAAUZ,UAAU,CAAC,QAAQM,GAAG,CAACO;IAChD,MAAMY,OAAO,MAAM7B,OAChBI,UAAU,CAACtB,gBAAgBqB,KAAK,EAChCE,KAAK,CAAC,UAAU,MAAM,QACtByB,KAAK,CAACF,MAAM,GACZtB,GAAG;IACN,MAAMyB,YAAYF,KAAKrB,IAAI,CAACwB,MAAM,GAAGJ;IACrC,MAAMpB,OAAOqB,KAAKrB,IAAI,CAACyB,KAAK,CAAC,GAAGL;IAEhC,MAAMM,WAAW,IAAIC;IACrB,KAAK,MAAMlC,SAASmC,OAAOC,IAAI,CAACjD,+BAA8D;QAC5F8C,SAASI,GAAG,CAACrC,OAAO,IAAIkC;IAC1B;IACA,KAAK,MAAMzB,OAAOF,KAAM;QACtB,MAAMgB,OAAO;YAAEb,QAAQD,IAAIJ,GAAG,CAAC;YAAWM,SAASF,IAAIJ,GAAG,CAAC;QAAW;QACtE,MAAMY,QAAQjC,wBAAwB;YACpCI,WAAWqB,IAAIJ,GAAG,CAAC;YACnBhB,WAAWoB,IAAIJ,GAAG,CAAC;YACnBd,QAAQkB,IAAIJ,GAAG,CAAC;QAClB;QACA,KAAK,MAAM,EAAEL,KAAK,EAAEC,EAAE,EAAE,IAAIlB,uBAAuB;YAACkC;SAAM,EAAG;gBAEGqB;YAD9D,MAAMA,OAAOL,SAAS5B,GAAG,CAACL;YAC1BsC,KAAKD,GAAG,CAACpC,IAAIhB,eAAe;gBAACsC;gBAAM;oBAAEb,QAAQ;oBAAQC,OAAO,GAAE2B,YAAAA,KAAKjC,GAAG,CAACJ,eAATqC,YAAgB;gBAAK;aAAE;QACvF;IACF;IAEA,MAAMC,QAA8B;QAAErC,OAAOK,KAAKwB,MAAM;QAAES,WAAW;QAAGC,SAAS;QAAGX;IAAU;IAC9F,KAAK,MAAM,CAAC9B,OAAOsC,KAAK,IAAIL,SAAU;QACpC,MAAMd,UAAUpB,OAAOI,UAAU,CAAChB,6BAA6B,CAACa,MAAM;QACtE,KAAK,MAAM,CAACC,IAAIK,MAAM,IAAIgC,KAAM;YAC9B,IAAI;gBACF,MAAMnB,QAAQV,GAAG,CAACR,IAAIW,MAAM,CAAC;oBAAE,CAAC9B,wBAAwB,EAAEwB;gBAAM;gBAChEiC,MAAMC,SAAS,IAAI;YACrB,EAAE,OAAO9C,OAAO;gBACd,IAAI,CAACD,WAAWC,QAAQ,MAAMA;YAChC;QACF;QACA;;;;;KAKC,GACD,IAAIoC,WAAW;QACf,MAAMY,WAAW,MAAMvB,QAAQf,KAAK,CAACtB,yBAAyB,KAAK,GAAGuB,GAAG;QACzE,KAAK,MAAMI,OAAOiC,SAASnC,IAAI,CAAE;YAC/B,IAAI+B,KAAKK,GAAG,CAAClC,IAAIR,EAAE,GAAG;YACtB,IAAIf,iBAAiB;gBAAE0D,cAAcnC,IAAIJ,GAAG,CAACvB;YAAyB,OAAO,MAAM;YACnF,MAAM2B,IAAIoC,GAAG,CAACjC,MAAM,CAAC;gBAAE,CAAC9B,wBAAwB,EAAE;YAAK;YACvDyD,MAAME,OAAO,IAAI;QACnB;IACF;IACA,OAAOF;AACT"}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/
|
|
17
|
-
import type { Firestore } from 'firebase-admin/firestore';
|
|
18
|
-
import { type UsageSources } from './live-page-usage';
|
|
19
|
-
/**
|
|
20
|
-
* How many documents per collection the per-site scan reads.
|
|
21
|
-
*
|
|
22
|
-
* The console's publish scan uses the same number for the same reason
|
|
23
|
-
* (AGL-1161): this decides which caches get dropped, so a prefix scan would
|
|
24
|
-
* report a refreshed site and leave real pages serving the old rows. Bounded
|
|
25
|
-
* all the same, and when the bound bites it is SAID rather than absorbed.
|
|
26
|
-
*/
|
|
27
|
-
export declare const DATASET_SCAN_LIMIT = 2000;
|
|
28
|
-
/**
|
|
29
|
-
* How many sites one record write fans out to.
|
|
30
|
-
*
|
|
31
|
-
* `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide
|
|
32
|
-
* dataset can reach this — an org with more sites than this pays a scan per
|
|
33
|
-
* site on every write otherwise, which is a cost the person writing one row
|
|
34
|
-
* did not ask for. Sites past the cap keep the TTL they had before this
|
|
35
|
-
* existed, and the shortfall is returned and logged rather than swallowed.
|
|
36
|
-
*/
|
|
37
|
-
export declare const MAX_DATASET_FANOUT_HOSTS = 30;
|
|
38
|
-
/**
|
|
39
|
-
* How long one site-and-dataset pair waits before announcing again.
|
|
40
|
-
*
|
|
41
|
-
* A 1,000-row import is one announce because the importer announces after its
|
|
42
|
-
* loop, not per row. This covers the other burst — many small writes arriving
|
|
43
|
-
* separately, a form under load or an automation fanning out — where each one
|
|
44
|
-
* would otherwise pay a full scan per site and hand the tenant the same path
|
|
45
|
-
* list again.
|
|
46
|
-
*
|
|
47
|
-
* Leading edge, so the FIRST write of a burst refreshes immediately; what is
|
|
48
|
-
* skipped is a repeat inside the window, and the next write after it announces
|
|
49
|
-
* again. The cost of the skip is bounded by the window: a row written during
|
|
50
|
-
* one is visible no later than the next announce.
|
|
51
|
-
*/
|
|
52
|
-
export declare const DATASET_ANNOUNCE_WINDOW_MS = 2000;
|
|
53
|
-
/** Test seam: the window is process-wide state, so a spec has to reset it. */
|
|
54
|
-
export declare function resetDatasetAnnounceThrottle(): void;
|
|
55
|
-
/** One site's share of a dataset change. */
|
|
56
|
-
export interface DatasetLivePageTarget {
|
|
57
|
-
hostId: string;
|
|
58
|
-
/** The tenant keys its cache on this, never on `hostId`. */
|
|
59
|
-
subdomain: string;
|
|
60
|
-
/** The attached custom domain, whose pages live under a second cache key. */
|
|
61
|
-
cname?: string;
|
|
62
|
-
/** Site-absolute addresses (`/`, `/team`) that repeat over the dataset. */
|
|
63
|
-
paths: string[];
|
|
64
|
-
/** The site held more documents than the scan read, so `paths` is partial. */
|
|
65
|
-
truncated: boolean;
|
|
66
|
-
}
|
|
67
|
-
export interface DatasetLivePageScope {
|
|
68
|
-
targets: DatasetLivePageTarget[];
|
|
69
|
-
/**
|
|
70
|
-
* Sites the fan-out cap left out. Zero in the ordinary case, and reported
|
|
71
|
-
* rather than logged because a caller that says "refreshed" while some sites
|
|
72
|
-
* were skipped is the failure this whole arc exists to remove.
|
|
73
|
-
*/
|
|
74
|
-
hostsDropped: number;
|
|
75
|
-
/**
|
|
76
|
-
* Why there is nothing to announce, or `'ok'`.
|
|
77
|
-
*
|
|
78
|
-
* `'not-rendered'` is a success: a dataset no page repeats over has no live
|
|
79
|
-
* page to refresh. It is kept apart from `'error'` for the same reason the
|
|
80
|
-
* revalidate route keeps `not-routed` apart from a refusal — one means there
|
|
81
|
-
* was nothing to do, the other means something was left stale.
|
|
82
|
-
*/
|
|
83
|
-
reason: 'ok' | 'no-dataset' | 'no-sites' | 'not-rendered' | 'error';
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Every live screen whose rendered output repeats over the dataset, however
|
|
87
|
-
* indirectly.
|
|
88
|
-
*
|
|
89
|
-
* The same three-bucket closure a placed form's pages are found by, for the
|
|
90
|
-
* same reason: a binding is found by SEARCHING node trees rather than by
|
|
91
|
-
* matching a pointer, so it can sit on the screen, in the chrome of the layout
|
|
92
|
-
* the screen renders inside, or inside a reusable component a third component
|
|
93
|
-
* nests. After the first level the fan-out is identical, so it is delegated
|
|
94
|
-
* rather than restated.
|
|
95
|
-
*
|
|
96
|
-
* Pure, and separate from the Firestore read, so the closure is testable
|
|
97
|
-
* without a database.
|
|
98
|
-
*/
|
|
99
|
-
export declare function screenIdsRepeatingDataset(keys: ReadonlySet<string>, sources: UsageSources): string[];
|
|
100
|
-
/**
|
|
101
|
-
* Every live page a change to this dataset's records makes stale, per site.
|
|
102
|
-
*
|
|
103
|
-
* Reads only; announcing is the caller's, because the two runtimes announce
|
|
104
|
-
* differently — the console posts to the tenant over the shared secret, and
|
|
105
|
-
* the tenant drops its own caches in process. Splitting it here is what lets
|
|
106
|
-
* both use one answer to the question of WHICH pages.
|
|
107
|
-
*
|
|
108
|
-
* Never throws. A scope that could not be computed is a scope of nothing, and
|
|
109
|
-
* the TTL underneath is unaffected.
|
|
110
|
-
*/
|
|
111
|
-
export declare function datasetLivePageScope(options: {
|
|
112
|
-
firestore: Firestore;
|
|
113
|
-
orgId: string;
|
|
114
|
-
datasetId: string;
|
|
115
|
-
}): Promise<DatasetLivePageScope>;
|
|
116
|
-
/**
|
|
117
|
-
* How one runtime drops a site's cached pages.
|
|
118
|
-
*
|
|
119
|
-
* The generic seam, registered by the deployment that owns the caches. The
|
|
120
|
-
* tenant registers an in-process drop at boot; the console announces over the
|
|
121
|
-
* shared secret and passes its own dropper at the call site. Core names
|
|
122
|
-
* neither, exactly as it names no repeat source.
|
|
123
|
-
*
|
|
124
|
-
* Returns whether the drop landed. It must never throw: everything above it
|
|
125
|
-
* has already been written.
|
|
126
|
-
*/
|
|
127
|
-
export type LivePageDropper = (target: DatasetLivePageTarget) => Promise<boolean>;
|
|
128
|
-
/**
|
|
129
|
-
* Registers the dropper for this process, replacing any previous one. Returns
|
|
130
|
-
* the unregister, which removes it only if it is still the one registered.
|
|
131
|
-
*
|
|
132
|
-
* Called from a function the platform calls BY NAME at boot, never from a
|
|
133
|
-
* module's top level: a bundler deletes a module imported only for its side
|
|
134
|
-
* effect when its package says it has none (AGL-3025).
|
|
135
|
-
*/
|
|
136
|
-
export declare function registerLivePageDropper(drop: LivePageDropper): () => void;
|
|
137
|
-
export interface DatasetAnnounceResult {
|
|
138
|
-
/** Sites whose cached pages were dropped. */
|
|
139
|
-
sitesRefreshed: number;
|
|
140
|
-
/** Pages named across those sites. */
|
|
141
|
-
pathsRefreshed: number;
|
|
142
|
-
/** Sites the drop was attempted on and refused. */
|
|
143
|
-
sitesRefused: number;
|
|
144
|
-
/** Sites past the fan-out cap, and sites whose scan was truncated. */
|
|
145
|
-
hostsDropped: number;
|
|
146
|
-
scanTruncated: boolean;
|
|
147
|
-
/** `'throttled'` when a drop for this pair already went out just now. */
|
|
148
|
-
reason: DatasetLivePageScope['reason'] | 'throttled' | 'no-dropper';
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Refresh every live page that shows this dataset's records.
|
|
152
|
-
*
|
|
153
|
-
* The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and
|
|
154
|
-
* never rejects, so a caller can `await` it inside the try that already guards
|
|
155
|
-
* the write without any chance of turning a stored record into a failed one.
|
|
156
|
-
*
|
|
157
|
-
* `drop` overrides the registered dropper for callers that hold their own —
|
|
158
|
-
* the console, whose announce is an authenticated request rather than a local
|
|
159
|
-
* cache call.
|
|
160
|
-
*/
|
|
161
|
-
export declare function announceDatasetRecordChange(options: {
|
|
162
|
-
firestore: Firestore;
|
|
163
|
-
orgId: string;
|
|
164
|
-
datasetId: string;
|
|
165
|
-
drop?: LivePageDropper;
|
|
166
|
-
}): Promise<DatasetAnnounceResult>;
|
|
167
|
-
/**
|
|
168
|
-
* One sentence for a refresh that did not cover every page, or `null` when it
|
|
169
|
-
* did.
|
|
170
|
-
*
|
|
171
|
-
* Beside the call rather than at each surface, so the console cannot word this
|
|
172
|
-
* differently in two places — the same reason `describeRevalidateShortfall`
|
|
173
|
-
* lives beside `revalidateLivePages`. `lead` names the act that succeeded,
|
|
174
|
-
* because only that word differs between surfaces: a record is SAVED, and
|
|
175
|
-
* telling someone who fixed a typo that they published it is a worse error
|
|
176
|
-
* than the staleness the sentence is about.
|
|
177
|
-
*/
|
|
178
|
-
export declare function describeDatasetAnnounceShortfall(result: DatasetAnnounceResult | null, lead?: string): string | null;
|
|
179
|
-
export default announceDatasetRecordChange;
|