@aglyn/tenant-data-admin 1.0.0-beta.222 → 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/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 +13 -0
- package/src/lib/server/host-visitor-records.js +22 -4
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +6 -3
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +6 -3
- package/src/lib/server/outbound-send-review.js.map +1 -1
- 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/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/web-risk.d.ts +2 -0
- package/src/lib/server/web-risk.js +18 -21
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +0 -2
- package/src/tenancy.js +0 -2
- package/src/tenancy.js.map +1 -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
|
@@ -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":"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"}
|