@aglyn/plugins-forms 1.0.0-beta.200 → 1.0.0-beta.202

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.
@@ -0,0 +1,89 @@
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
+ */ // The seam from its own module, not the plugin-manager barrel: boot needs the
17
+ // registry and nothing else, and the barrel reaches the client contexts.
18
+ import { formIdsOfLeadSources } from "@aglyn/aglyn/app-utils/forms";
19
+ import { registerPluginEventHandler } from "@aglyn/aglyn/plugin-manager/plugin-events";
20
+ import { BUNDLE_ID } from "./constants/bundle-common.js";
21
+ /**
22
+ * The forms each removed record was counted by, per site (AGL-3330): a
23
+ * submission names its form by `formId` on its own site, and a lead names
24
+ * the forms that filed it by `form:{formId}` in `sources`, on any of the
25
+ * organization's sites.
26
+ */ export function formsCountingRemovedRecords(payload) {
27
+ const byHost = new Map();
28
+ const add = (hostId, formId)=>{
29
+ var _byHost_get;
30
+ if (!hostId || !formId) return;
31
+ const ids = (_byHost_get = byHost.get(hostId)) != null ? _byHost_get : new Set();
32
+ ids.add(formId);
33
+ byHost.set(hostId, ids);
34
+ };
35
+ for (const { data } of payload.records){
36
+ if (payload.collection === 'formSubmissions') {
37
+ const formId = typeof data['formId'] === 'string' ? data['formId'] : '';
38
+ const hostId = typeof data['hostId'] === 'string' ? data['hostId'] : payload.hostIds[0];
39
+ add(hostId != null ? hostId : '', formId);
40
+ } else if (payload.collection === 'leads') {
41
+ for (const formId of formIdsOfLeadSources(data['sources'])){
42
+ for (const hostId of payload.hostIds)add(hostId, formId);
43
+ }
44
+ }
45
+ }
46
+ return [
47
+ ...byHost
48
+ ].map(([hostId, formIds])=>({
49
+ hostId,
50
+ formIds: [
51
+ ...formIds
52
+ ]
53
+ }));
54
+ }
55
+ /**
56
+ * The forms plugin's server declarations: the light registrations core reads
57
+ * at boot, before any surface loads.
58
+ *
59
+ * A form's counters are this plugin's to keep (AGL-3330), and a row they
60
+ * counted can be removed by a path that is not the plugin's — the public
61
+ * API's delete, a person's erasure. Core says so through
62
+ * `host.records.removed`, and this recounts each form the removed rows fed.
63
+ * The recount module (and the Admin SDK with it) loads on the first such
64
+ * event, not at boot. A site that does not hold a named form answers nothing
65
+ * for one document read, so a lead's forms are looked for on each site the
66
+ * event names.
67
+ */ export function registerFormsServerDeclarations() {
68
+ registerPluginEventHandler('host.records.removed', async (payload)=>{
69
+ const targets = formsCountingRemovedRecords(payload);
70
+ if (!targets.length) return;
71
+ const { recountFormStats } = await import("./server/form-stats.js");
72
+ for (const { hostId, formIds } of targets){
73
+ for (const formId of formIds){
74
+ try {
75
+ await recountFormStats({
76
+ hostId,
77
+ formId
78
+ });
79
+ } catch (error) {
80
+ console.error(`[forms] recount after a removal failed for ${hostId}/${formId}`, error);
81
+ }
82
+ }
83
+ }
84
+ }, {
85
+ pluginId: BUNDLE_ID
86
+ });
87
+ }
88
+
89
+ //# sourceMappingURL=declarations.server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/forms/src/lib/declarations.server.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// The seam from its own module, not the plugin-manager barrel: boot needs the\n// registry and nothing else, and the barrel reaches the client contexts.\nimport { formIdsOfLeadSources } from '@aglyn/aglyn/app-utils/forms'\nimport {\n registerPluginEventHandler,\n type PluginEventPayloads,\n} from '@aglyn/aglyn/plugin-manager/plugin-events'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The forms each removed record was counted by, per site (AGL-3330): a\n * submission names its form by `formId` on its own site, and a lead names\n * the forms that filed it by `form:{formId}` in `sources`, on any of the\n * organization's sites.\n */\nexport function formsCountingRemovedRecords(\n payload: PluginEventPayloads['host.records.removed'],\n): Array<{ hostId: string; formIds: string[] }> {\n const byHost = new Map<string, Set<string>>()\n const add = (hostId: string, formId: string) => {\n if (!hostId || !formId) return\n const ids = byHost.get(hostId) ?? new Set<string>()\n ids.add(formId)\n byHost.set(hostId, ids)\n }\n for (const { data } of payload.records) {\n if (payload.collection === 'formSubmissions') {\n const formId = typeof data['formId'] === 'string' ? data['formId'] : ''\n const hostId = typeof data['hostId'] === 'string' ? data['hostId'] : payload.hostIds[0]\n add(hostId ?? '', formId)\n } else if (payload.collection === 'leads') {\n for (const formId of formIdsOfLeadSources(data['sources'])) {\n for (const hostId of payload.hostIds) add(hostId, formId)\n }\n }\n }\n return [...byHost].map(([hostId, formIds]) => ({ hostId, formIds: [...formIds] }))\n}\n\n/**\n * The forms plugin's server declarations: the light registrations core reads\n * at boot, before any surface loads.\n *\n * A form's counters are this plugin's to keep (AGL-3330), and a row they\n * counted can be removed by a path that is not the plugin's — the public\n * API's delete, a person's erasure. Core says so through\n * `host.records.removed`, and this recounts each form the removed rows fed.\n * The recount module (and the Admin SDK with it) loads on the first such\n * event, not at boot. A site that does not hold a named form answers nothing\n * for one document read, so a lead's forms are looked for on each site the\n * event names.\n */\nexport function registerFormsServerDeclarations(): void {\n registerPluginEventHandler(\n 'host.records.removed',\n async (payload) => {\n const targets = formsCountingRemovedRecords(payload)\n if (!targets.length) return\n const { recountFormStats } = await import('./server/form-stats')\n for (const { hostId, formIds } of targets) {\n for (const formId of formIds) {\n try {\n await recountFormStats({ hostId, formId })\n } catch (error) {\n console.error(`[forms] recount after a removal failed for ${hostId}/${formId}`, error)\n }\n }\n }\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["formIdsOfLeadSources","registerPluginEventHandler","BUNDLE_ID","formsCountingRemovedRecords","payload","byHost","Map","add","hostId","formId","ids","get","Set","set","data","records","collection","hostIds","map","formIds","registerFormsServerDeclarations","targets","length","recountFormStats","error","console","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,yEAAyE;AACzE,SAASA,oBAAoB,QAAQ,+BAA8B;AACnE,SACEC,0BAA0B,QAErB,4CAA2C;AAClD,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;CAKC,GACD,OAAO,SAASC,4BACdC,OAAoD;IAEpD,MAAMC,SAAS,IAAIC;IACnB,MAAMC,MAAM,CAACC,QAAgBC;YAEfJ;QADZ,IAAI,CAACG,UAAU,CAACC,QAAQ;QACxB,MAAMC,OAAML,cAAAA,OAAOM,GAAG,CAACH,mBAAXH,cAAsB,IAAIO;QACtCF,IAAIH,GAAG,CAACE;QACRJ,OAAOQ,GAAG,CAACL,QAAQE;IACrB;IACA,KAAK,MAAM,EAAEI,IAAI,EAAE,IAAIV,QAAQW,OAAO,CAAE;QACtC,IAAIX,QAAQY,UAAU,KAAK,mBAAmB;YAC5C,MAAMP,SAAS,OAAOK,IAAI,CAAC,SAAS,KAAK,WAAWA,IAAI,CAAC,SAAS,GAAG;YACrE,MAAMN,SAAS,OAAOM,IAAI,CAAC,SAAS,KAAK,WAAWA,IAAI,CAAC,SAAS,GAAGV,QAAQa,OAAO,CAAC,EAAE;YACvFV,IAAIC,iBAAAA,SAAU,IAAIC;QACpB,OAAO,IAAIL,QAAQY,UAAU,KAAK,SAAS;YACzC,KAAK,MAAMP,UAAUT,qBAAqBc,IAAI,CAAC,UAAU,EAAG;gBAC1D,KAAK,MAAMN,UAAUJ,QAAQa,OAAO,CAAEV,IAAIC,QAAQC;YACpD;QACF;IACF;IACA,OAAO;WAAIJ;KAAO,CAACa,GAAG,CAAC,CAAC,CAACV,QAAQW,QAAQ,GAAM,CAAA;YAAEX;YAAQW,SAAS;mBAAIA;aAAQ;QAAC,CAAA;AACjF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC;IACdnB,2BACE,wBACA,OAAOG;QACL,MAAMiB,UAAUlB,4BAA4BC;QAC5C,IAAI,CAACiB,QAAQC,MAAM,EAAE;QACrB,MAAM,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC1C,KAAK,MAAM,EAAEf,MAAM,EAAEW,OAAO,EAAE,IAAIE,QAAS;YACzC,KAAK,MAAMZ,UAAUU,QAAS;gBAC5B,IAAI;oBACF,MAAMI,iBAAiB;wBAAEf;wBAAQC;oBAAO;gBAC1C,EAAE,OAAOe,OAAO;oBACdC,QAAQD,KAAK,CAAC,CAAC,2CAA2C,EAAEhB,OAAO,CAAC,EAAEC,QAAQ,EAAEe;gBAClF;YACF;QACF;IACF,GACA;QAAEE,UAAUxB;IAAU;AAE1B"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A form's counters, RECOUNTED from the rows they count (AGL-3330).
19
+ *
20
+ * `/api/forms/submit` keeps `stats.submissions`, `stats.leads` and
21
+ * `stats.lastSubmissionAtMs` by increment, which never re-reads the
22
+ * collection it counts and so cannot see a submission deleted from the
23
+ * Inbox, a lead erased on request, or an increment that failed. This is the
24
+ * other half: it counts the rows and writes what they say, and it runs
25
+ * after every path that REMOVES one of those rows — the Inbox's delete and
26
+ * a switch of lead routing (through this plugin's `/api/forms/stats`), and,
27
+ * through the platform's `host.records.removed` event, the API's
28
+ * `DELETE /v1/sites/{id}/form-submissions/{id}` and a person's erasure.
29
+ * `tools/scripts/recount-form-stats.mjs` runs the same decision over every
30
+ * form.
31
+ *
32
+ * What is counted, and why each is cheap enough to ask after a delete:
33
+ *
34
+ * - submissions: `count()` of the site's `formSubmissions` with this
35
+ * `formId` — an aggregation, billed per thousand index entries, not per
36
+ * row;
37
+ * - the newest of them: one row, from the `formId ASC, createdAt DESC`
38
+ * composite the form's own submissions list already reads by;
39
+ * - leads: `count()` of the organization's leads whose `sources` hold
40
+ * `form:{formId}`, on the automatic `array-contains` index.
41
+ *
42
+ * All three and the write sit in ONE transaction with the form: a
43
+ * submission's increment landing mid-recount contends with it and one of the
44
+ * two retries, so the recount never writes a figure the increment already
45
+ * moved past.
46
+ *
47
+ * Idempotent: a form whose counters already agree is not written, so a
48
+ * recount after a no-op, or twice, costs its reads and nothing else.
49
+ */
50
+ import { type FormCounterStats } from '@aglyn/aglyn/app-utils/forms';
51
+ /** What one recount found, and whether it wrote. */
52
+ export interface FormStatsRecount {
53
+ hostId: string;
54
+ formId: string;
55
+ /** The counters as stored, `undefined` where the field is absent. */
56
+ stored: Partial<Record<keyof FormCounterStats, unknown>>;
57
+ /** The counters the rows say. */
58
+ recounted: FormCounterStats;
59
+ /** The counters that disagreed; empty when the form was already right. */
60
+ drift: (keyof FormCounterStats)[];
61
+ /** Whether the recount was written — only ever when something drifted. */
62
+ written: boolean;
63
+ }
64
+ /** The most forms one request may ask to have recounted. */
65
+ export declare const FORM_STATS_RECOUNT_MAX = 20;
66
+ /**
67
+ * Recount one form's counters, and write them when they drifted.
68
+ *
69
+ * @returns what was found, or `null` when the form does not exist on the site
70
+ * — a deleted form has no counters to keep, and a recount must never
71
+ * create a stats-only stray document (the submit path's `update`,
72
+ * never `set`, for the same reason).
73
+ */
74
+ export declare function recountFormStats(options: {
75
+ hostId: string;
76
+ formId: string;
77
+ firestore?: FirebaseFirestore.Firestore;
78
+ /**
79
+ * The organization's leads, when the caller already holds the collection
80
+ * (an erasure walking one org). Resolved from the site otherwise.
81
+ */
82
+ leads?: FirebaseFirestore.CollectionReference;
83
+ /** `false` reports without writing — the script's dry run. */
84
+ apply?: boolean;
85
+ }): Promise<FormStatsRecount | null>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * A form's counters, RECOUNTED from the rows they count (AGL-3330).
18
+ *
19
+ * `/api/forms/submit` keeps `stats.submissions`, `stats.leads` and
20
+ * `stats.lastSubmissionAtMs` by increment, which never re-reads the
21
+ * collection it counts and so cannot see a submission deleted from the
22
+ * Inbox, a lead erased on request, or an increment that failed. This is the
23
+ * other half: it counts the rows and writes what they say, and it runs
24
+ * after every path that REMOVES one of those rows — the Inbox's delete and
25
+ * a switch of lead routing (through this plugin's `/api/forms/stats`), and,
26
+ * through the platform's `host.records.removed` event, the API's
27
+ * `DELETE /v1/sites/{id}/form-submissions/{id}` and a person's erasure.
28
+ * `tools/scripts/recount-form-stats.mjs` runs the same decision over every
29
+ * form.
30
+ *
31
+ * What is counted, and why each is cheap enough to ask after a delete:
32
+ *
33
+ * - submissions: `count()` of the site's `formSubmissions` with this
34
+ * `formId` — an aggregation, billed per thousand index entries, not per
35
+ * row;
36
+ * - the newest of them: one row, from the `formId ASC, createdAt DESC`
37
+ * composite the form's own submissions list already reads by;
38
+ * - leads: `count()` of the organization's leads whose `sources` hold
39
+ * `form:{formId}`, on the automatic `array-contains` index.
40
+ *
41
+ * All three and the write sit in ONE transaction with the form: a
42
+ * submission's increment landing mid-recount contends with it and one of the
43
+ * two retries, so the recount never writes a figure the increment already
44
+ * moved past.
45
+ *
46
+ * Idempotent: a form whose counters already agree is not written, so a
47
+ * recount after a no-op, or twice, costs its reads and nothing else.
48
+ */ import { FORM_COUNTER_FIELDS, formCounterDrift, formCounterPatch, formCountersFromSource, formLeadSource } from "@aglyn/aglyn/app-utils/forms";
49
+ import { firebaseAdmin, orgLeadsForHost } from "@aglyn/tenant-data-admin";
50
+ /** The most forms one request may ask to have recounted. */ export const FORM_STATS_RECOUNT_MAX = 20;
51
+ /** A Firestore `Timestamp`, a `Date` or epoch millis, as millis. */ function millisOf(value) {
52
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
53
+ if (value instanceof Date) return value.getTime();
54
+ const toMillis = value == null ? void 0 : value.toMillis;
55
+ return typeof toMillis === 'function' ? toMillis.call(value) : null;
56
+ }
57
+ /**
58
+ * Recount one form's counters, and write them when they drifted.
59
+ *
60
+ * @returns what was found, or `null` when the form does not exist on the site
61
+ * — a deleted form has no counters to keep, and a recount must never
62
+ * create a stats-only stray document (the submit path's `update`,
63
+ * never `set`, for the same reason).
64
+ */ export async function recountFormStats(options) {
65
+ var _options_firestore, _options_leads;
66
+ const { hostId, formId } = options;
67
+ const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
68
+ const hostRef = firestore.collection('hosts').doc(hostId);
69
+ const formRef = hostRef.collection('forms').doc(formId);
70
+ const submissions = hostRef.collection('formSubmissions').where('formId', '==', formId);
71
+ /*
72
+ * A site in no organization has no lead silo — leads are org rows — so it
73
+ * has filed nobody, and its forms count none.
74
+ */ const leadsRef = (_options_leads = options.leads) != null ? _options_leads : await orgLeadsForHost(hostId).catch(()=>null);
75
+ const leads = leadsRef ? leadsRef.where('sources', 'array-contains', formLeadSource(formId)) : null;
76
+ const apply = options.apply !== false;
77
+ return firestore.runTransaction(async (tx)=>{
78
+ var _form_get;
79
+ var _newest_docs_, _form_get1;
80
+ // ALL READS BEFORE THE WRITE, which a transaction requires.
81
+ const form = await tx.get(formRef);
82
+ if (!form.exists) return null;
83
+ const submissionCount = (await tx.get(submissions.count())).data().count;
84
+ const newest = await tx.get(submissions.orderBy('createdAt', 'desc').limit(1));
85
+ const leadCount = leads ? (await tx.get(leads.count())).data().count : 0;
86
+ const recounted = formCountersFromSource({
87
+ submissions: submissionCount,
88
+ leads: leadCount,
89
+ newestSubmissionAtMs: millisOf((_newest_docs_ = newest.docs[0]) == null ? void 0 : _newest_docs_.get('createdAt')),
90
+ routesLeads: ((_form_get1 = form.get('routing')) == null ? void 0 : _form_get1.lead) === true
91
+ });
92
+ const storedStats = (_form_get = form.get('stats')) != null ? _form_get : {};
93
+ const stored = Object.fromEntries(FORM_COUNTER_FIELDS.map((field)=>[
94
+ field,
95
+ storedStats[field]
96
+ ]));
97
+ const drift = formCounterDrift(storedStats, recounted);
98
+ const written = apply && drift.length > 0;
99
+ if (written) tx.update(formRef, formCounterPatch(recounted));
100
+ return {
101
+ hostId,
102
+ formId,
103
+ stored,
104
+ recounted,
105
+ drift,
106
+ written
107
+ };
108
+ });
109
+ }
110
+
111
+ //# sourceMappingURL=form-stats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/server/form-stats.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A form's counters, RECOUNTED from the rows they count (AGL-3330).\n *\n * `/api/forms/submit` keeps `stats.submissions`, `stats.leads` and\n * `stats.lastSubmissionAtMs` by increment, which never re-reads the\n * collection it counts and so cannot see a submission deleted from the\n * Inbox, a lead erased on request, or an increment that failed. This is the\n * other half: it counts the rows and writes what they say, and it runs\n * after every path that REMOVES one of those rows — the Inbox's delete and\n * a switch of lead routing (through this plugin's `/api/forms/stats`), and,\n * through the platform's `host.records.removed` event, the API's\n * `DELETE /v1/sites/{id}/form-submissions/{id}` and a person's erasure.\n * `tools/scripts/recount-form-stats.mjs` runs the same decision over every\n * form.\n *\n * What is counted, and why each is cheap enough to ask after a delete:\n *\n * - submissions: `count()` of the site's `formSubmissions` with this\n * `formId` — an aggregation, billed per thousand index entries, not per\n * row;\n * - the newest of them: one row, from the `formId ASC, createdAt DESC`\n * composite the form's own submissions list already reads by;\n * - leads: `count()` of the organization's leads whose `sources` hold\n * `form:{formId}`, on the automatic `array-contains` index.\n *\n * All three and the write sit in ONE transaction with the form: a\n * submission's increment landing mid-recount contends with it and one of the\n * two retries, so the recount never writes a figure the increment already\n * moved past.\n *\n * Idempotent: a form whose counters already agree is not written, so a\n * recount after a no-op, or twice, costs its reads and nothing else.\n */\n\nimport {\n FORM_COUNTER_FIELDS,\n formCounterDrift,\n formCounterPatch,\n formCountersFromSource,\n formLeadSource,\n type FormCounterStats,\n} from '@aglyn/aglyn/app-utils/forms'\nimport { firebaseAdmin, orgLeadsForHost } from '@aglyn/tenant-data-admin'\n\n/** What one recount found, and whether it wrote. */\nexport interface FormStatsRecount {\n hostId: string\n formId: string\n /** The counters as stored, `undefined` where the field is absent. */\n stored: Partial<Record<keyof FormCounterStats, unknown>>\n /** The counters the rows say. */\n recounted: FormCounterStats\n /** The counters that disagreed; empty when the form was already right. */\n drift: (keyof FormCounterStats)[]\n /** Whether the recount was written — only ever when something drifted. */\n written: boolean\n}\n\n/** The most forms one request may ask to have recounted. */\nexport const FORM_STATS_RECOUNT_MAX = 20\n\n/** A Firestore `Timestamp`, a `Date` or epoch millis, as millis. */\nfunction millisOf(value: unknown): number | null {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (value instanceof Date) return value.getTime()\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * Recount one form's counters, and write them when they drifted.\n *\n * @returns what was found, or `null` when the form does not exist on the site\n * — a deleted form has no counters to keep, and a recount must never\n * create a stats-only stray document (the submit path's `update`,\n * never `set`, for the same reason).\n */\nexport async function recountFormStats(options: {\n hostId: string\n formId: string\n firestore?: FirebaseFirestore.Firestore\n /**\n * The organization's leads, when the caller already holds the collection\n * (an erasure walking one org). Resolved from the site otherwise.\n */\n leads?: FirebaseFirestore.CollectionReference\n /** `false` reports without writing — the script's dry run. */\n apply?: boolean\n}): Promise<FormStatsRecount | null> {\n const { hostId, formId } = options\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const formRef = hostRef.collection('forms').doc(formId)\n const submissions = hostRef.collection('formSubmissions').where('formId', '==', formId)\n /*\n * A site in no organization has no lead silo — leads are org rows — so it\n * has filed nobody, and its forms count none.\n */\n const leadsRef: FirebaseFirestore.CollectionReference | null =\n options.leads ?? (await orgLeadsForHost(hostId).catch(() => null))\n const leads: FirebaseFirestore.Query | null = leadsRef\n ? leadsRef.where('sources', 'array-contains', formLeadSource(formId))\n : null\n const apply = options.apply !== false\n return firestore.runTransaction(async (tx) => {\n // ALL READS BEFORE THE WRITE, which a transaction requires.\n const form = await tx.get(formRef)\n if (!form.exists) return null\n const submissionCount = (await tx.get(submissions.count())).data().count\n const newest = await tx.get(submissions.orderBy('createdAt', 'desc').limit(1))\n const leadCount = leads ? (await tx.get(leads.count())).data().count : 0\n const recounted = formCountersFromSource({\n submissions: submissionCount,\n leads: leadCount,\n newestSubmissionAtMs: millisOf(newest.docs[0]?.get('createdAt')),\n routesLeads: form.get('routing')?.lead === true,\n })\n const storedStats = (form.get('stats') ?? {}) as Record<string, unknown>\n const stored = Object.fromEntries(\n FORM_COUNTER_FIELDS.map((field) => [field, storedStats[field]]),\n ) as FormStatsRecount['stored']\n const drift = formCounterDrift(storedStats, recounted)\n const written = apply && drift.length > 0\n if (written) tx.update(formRef, formCounterPatch(recounted))\n return { hostId, formId, stored, recounted, drift, written }\n })\n}\n"],"names":["FORM_COUNTER_FIELDS","formCounterDrift","formCounterPatch","formCountersFromSource","formLeadSource","firebaseAdmin","orgLeadsForHost","FORM_STATS_RECOUNT_MAX","millisOf","value","Number","isFinite","Date","getTime","toMillis","call","recountFormStats","options","hostId","formId","firestore","app","hostRef","collection","doc","formRef","submissions","where","leadsRef","leads","catch","apply","runTransaction","tx","form","newest","get","exists","submissionCount","count","data","orderBy","limit","leadCount","recounted","newestSubmissionAtMs","docs","routesLeads","lead","storedStats","stored","Object","fromEntries","map","field","drift","written","length","update"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GAED,SACEA,mBAAmB,EACnBC,gBAAgB,EAChBC,gBAAgB,EAChBC,sBAAsB,EACtBC,cAAc,QAET,+BAA8B;AACrC,SAASC,aAAa,EAAEC,eAAe,QAAQ,2BAA0B;AAgBzE,0DAA0D,GAC1D,OAAO,MAAMC,yBAAyB,GAAE;AAExC,kEAAkE,GAClE,SAASC,SAASC,KAAc;IAC9B,IAAI,OAAOA,UAAU,YAAYC,OAAOC,QAAQ,CAACF,QAAQ,OAAOA;IAChE,IAAIA,iBAAiBG,MAAM,OAAOH,MAAMI,OAAO;IAC/C,MAAMC,WAAYL,yBAAD,AAACA,MAA8CK,QAAQ;IACxE,OAAO,OAAOA,aAAa,aAAaA,SAASC,IAAI,CAACN,SAAS;AACjE;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeO,iBAAiBC,OAWtC;QAEmBA,oBAShBA;IAVF,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAE,GAAGF;IAC3B,MAAMG,aAAYH,qBAAAA,QAAQG,SAAS,YAAjBH,qBAAqBZ,cAAcgB,GAAG,GAAGD,SAAS;IACpE,MAAME,UAAUF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACN;IAClD,MAAMO,UAAUH,QAAQC,UAAU,CAAC,SAASC,GAAG,CAACL;IAChD,MAAMO,cAAcJ,QAAQC,UAAU,CAAC,mBAAmBI,KAAK,CAAC,UAAU,MAAMR;IAChF;;;GAGC,GACD,MAAMS,YACJX,iBAAAA,QAAQY,KAAK,YAAbZ,iBAAkB,MAAMX,gBAAgBY,QAAQY,KAAK,CAAC,IAAM;IAC9D,MAAMD,QAAwCD,WAC1CA,SAASD,KAAK,CAAC,WAAW,kBAAkBvB,eAAee,WAC3D;IACJ,MAAMY,QAAQd,QAAQc,KAAK,KAAK;IAChC,OAAOX,UAAUY,cAAc,CAAC,OAAOC;YAahBC;YAHYC,eAClBD;QAVf,4DAA4D;QAC5D,MAAMA,OAAO,MAAMD,GAAGG,GAAG,CAACX;QAC1B,IAAI,CAACS,KAAKG,MAAM,EAAE,OAAO;QACzB,MAAMC,kBAAkB,AAAC,CAAA,MAAML,GAAGG,GAAG,CAACV,YAAYa,KAAK,GAAE,EAAGC,IAAI,GAAGD,KAAK;QACxE,MAAMJ,SAAS,MAAMF,GAAGG,GAAG,CAACV,YAAYe,OAAO,CAAC,aAAa,QAAQC,KAAK,CAAC;QAC3E,MAAMC,YAAYd,QAAQ,AAAC,CAAA,MAAMI,GAAGG,GAAG,CAACP,MAAMU,KAAK,GAAE,EAAGC,IAAI,GAAGD,KAAK,GAAG;QACvE,MAAMK,YAAYzC,uBAAuB;YACvCuB,aAAaY;YACbT,OAAOc;YACPE,sBAAsBrC,UAAS2B,gBAAAA,OAAOW,IAAI,CAAC,EAAE,qBAAdX,cAAgBC,GAAG,CAAC;YACnDW,aAAab,EAAAA,aAAAA,KAAKE,GAAG,CAAC,+BAATF,WAAqBc,IAAI,MAAK;QAC7C;QACA,MAAMC,eAAef,YAAAA,KAAKE,GAAG,CAAC,oBAATF,YAAqB,CAAC;QAC3C,MAAMgB,SAASC,OAAOC,WAAW,CAC/BpD,oBAAoBqD,GAAG,CAAC,CAACC,QAAU;gBAACA;gBAAOL,WAAW,CAACK,MAAM;aAAC;QAEhE,MAAMC,QAAQtD,iBAAiBgD,aAAaL;QAC5C,MAAMY,UAAUzB,SAASwB,MAAME,MAAM,GAAG;QACxC,IAAID,SAASvB,GAAGyB,MAAM,CAACjC,SAASvB,iBAAiB0C;QACjD,OAAO;YAAE1B;YAAQC;YAAQ+B;YAAQN;YAAWW;YAAOC;QAAQ;IAC7D;AACF"}
@@ -0,0 +1,42 @@
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 PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * `POST /api/forms/stats` — recount the named forms' counters from their
20
+ * rows (AGL-3330).
21
+ *
22
+ * A form's `stats` are server-written: the rules refuse a client write that
23
+ * touches them, because a merchant who could set their own submission count
24
+ * could make any lead rate read however they liked. So a CONSOLE act that
25
+ * changes what they should say — deleting a submission from the Inbox,
26
+ * switching lead routing (a lead-routing form holds `0` leads rather than
27
+ * none) — asks for them to be recounted here, and `recountFormStats` counts
28
+ * the rows and writes what they say.
29
+ *
30
+ * Nothing the caller sends is written: it names forms, and the figures come
31
+ * from Firestore. That is why any member of the site may ask — a recount can
32
+ * only ever put a form's counters in agreement with rows the member can
33
+ * already list — and why it needs no role beyond membership. It is bounded at
34
+ * {@link FORM_STATS_RECOUNT_MAX} forms a request, three aggregation reads
35
+ * each, and a form already in agreement is not written.
36
+ *
37
+ * Body: `{ hostId, formIds: string[] }`. Answers each form's recount, or
38
+ * `null` for an id that is not a form on the site.
39
+ */
40
+ export declare const formStatsHandler: PluginApiHandler;
41
+ /** Console API registration. */
42
+ export declare function registerFormsConsoleApi(): void;
@@ -0,0 +1,122 @@
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
+ */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
17
+ import { firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
18
+ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
19
+ import { FORM_STATS_RECOUNT_MAX, recountFormStats } from "./server/form-stats.js";
20
+ /**
21
+ * `POST /api/forms/stats` — recount the named forms' counters from their
22
+ * rows (AGL-3330).
23
+ *
24
+ * A form's `stats` are server-written: the rules refuse a client write that
25
+ * touches them, because a merchant who could set their own submission count
26
+ * could make any lead rate read however they liked. So a CONSOLE act that
27
+ * changes what they should say — deleting a submission from the Inbox,
28
+ * switching lead routing (a lead-routing form holds `0` leads rather than
29
+ * none) — asks for them to be recounted here, and `recountFormStats` counts
30
+ * the rows and writes what they say.
31
+ *
32
+ * Nothing the caller sends is written: it names forms, and the figures come
33
+ * from Firestore. That is why any member of the site may ask — a recount can
34
+ * only ever put a form's counters in agreement with rows the member can
35
+ * already list — and why it needs no role beyond membership. It is bounded at
36
+ * {@link FORM_STATS_RECOUNT_MAX} forms a request, three aggregation reads
37
+ * each, and a form already in agreement is not written.
38
+ *
39
+ * Body: `{ hostId, formIds: string[] }`. Answers each form's recount, or
40
+ * `null` for an id that is not a form on the site.
41
+ */ export const formStatsHandler = async (req, res)=>{
42
+ var _req_headers_authorization;
43
+ var _req_body, _req_body1;
44
+ if (req.method !== 'POST') {
45
+ return res.status(405).json({
46
+ error: 'Method not allowed'
47
+ });
48
+ }
49
+ const hostId = typeof ((_req_body = req.body) == null ? void 0 : _req_body.hostId) === 'string' ? req.body.hostId : '';
50
+ const formIds = Array.isArray((_req_body1 = req.body) == null ? void 0 : _req_body1.formIds) ? [
51
+ ...new Set(req.body.formIds.filter((id)=>typeof id === 'string' && id.length > 0 && id.length <= 64))
52
+ ] : [];
53
+ if (!hostId || !formIds.length) {
54
+ return res.status(400).json({
55
+ error: 'Missing hostId or formIds'
56
+ });
57
+ }
58
+ if (formIds.length > FORM_STATS_RECOUNT_MAX) {
59
+ return res.status(400).json({
60
+ error: `At most ${FORM_STATS_RECOUNT_MAX} forms a request`
61
+ });
62
+ }
63
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
64
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
65
+ if (!idToken) return res.status(401).json({
66
+ error: 'Unauthenticated'
67
+ });
68
+ try {
69
+ var _host_get;
70
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
71
+ if (!decoded.email_verified && !isImpersonationSession(decoded)) {
72
+ return res.status(403).json({
73
+ error: 'Verify your email address first'
74
+ });
75
+ }
76
+ const firestore = firebaseAdmin.app().firestore();
77
+ const host = await firestore.collection('hosts').doc(hostId).get();
78
+ if (!host.exists) return res.status(404).json({
79
+ error: 'Unknown site'
80
+ });
81
+ const staff = decoded['staff'] === true;
82
+ const memberRole = ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[decoded.uid];
83
+ if (!staff && !memberRole) {
84
+ return res.status(403).json({
85
+ error: 'Not a member of this site'
86
+ });
87
+ }
88
+ const recounts = {};
89
+ for (const formId of formIds){
90
+ const recount = await recountFormStats({
91
+ firestore,
92
+ hostId,
93
+ formId
94
+ });
95
+ recounts[formId] = recount ? {
96
+ recounted: recount.recounted,
97
+ drift: recount.drift,
98
+ written: recount.written
99
+ } : null;
100
+ }
101
+ return res.status(200).json({
102
+ ok: true,
103
+ recounts
104
+ });
105
+ } catch (error) {
106
+ // A refused credential is a 401; anything else is a fault of ours.
107
+ if (isRefusedIdToken(error)) {
108
+ return res.status(401).json({
109
+ error: 'Unauthenticated'
110
+ });
111
+ }
112
+ console.error('[forms] stats recount failed', error);
113
+ return res.status(500).json({
114
+ error: 'Recount failed'
115
+ });
116
+ }
117
+ };
118
+ /** Console API registration. */ export function registerFormsConsoleApi() {
119
+ registerPluginApiRoute('forms/stats', formStatsHandler);
120
+ }
121
+
122
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/forms/src/lib/server.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 { registerPluginApiRoute, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, isImpersonationSession } from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FORM_STATS_RECOUNT_MAX, recountFormStats } from './server/form-stats'\n\n/**\n * `POST /api/forms/stats` — recount the named forms' counters from their\n * rows (AGL-3330).\n *\n * A form's `stats` are server-written: the rules refuse a client write that\n * touches them, because a merchant who could set their own submission count\n * could make any lead rate read however they liked. So a CONSOLE act that\n * changes what they should say — deleting a submission from the Inbox,\n * switching lead routing (a lead-routing form holds `0` leads rather than\n * none) — asks for them to be recounted here, and `recountFormStats` counts\n * the rows and writes what they say.\n *\n * Nothing the caller sends is written: it names forms, and the figures come\n * from Firestore. That is why any member of the site may ask — a recount can\n * only ever put a form's counters in agreement with rows the member can\n * already list — and why it needs no role beyond membership. It is bounded at\n * {@link FORM_STATS_RECOUNT_MAX} forms a request, three aggregation reads\n * each, and a form already in agreement is not written.\n *\n * Body: `{ hostId, formIds: string[] }`. Answers each form's recount, or\n * `null` for an id that is not a form on the site.\n */\nexport const formStatsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = typeof req.body?.hostId === 'string' ? req.body.hostId : ''\n const formIds = Array.isArray(req.body?.formIds)\n ? [\n ...new Set(\n (req.body.formIds as unknown[]).filter(\n (id): id is string => typeof id === 'string' && id.length > 0 && id.length <= 64,\n ),\n ),\n ]\n : []\n if (!hostId || !formIds.length) {\n return res.status(400).json({ error: 'Missing hostId or formIds' })\n }\n if (formIds.length > FORM_STATS_RECOUNT_MAX) {\n return res\n .status(400)\n .json({ error: `At most ${FORM_STATS_RECOUNT_MAX} forms a request` })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return res.status(403).json({ error: 'Verify your email address first' })\n }\n const firestore = firebaseAdmin.app().firestore()\n const host = await firestore.collection('hosts').doc(hostId).get()\n if (!host.exists) return res.status(404).json({ error: 'Unknown site' })\n const staff = decoded['staff'] === true\n const memberRole = (host.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && !memberRole) {\n return res.status(403).json({ error: 'Not a member of this site' })\n }\n const recounts: Record<string, unknown> = {}\n for (const formId of formIds) {\n const recount = await recountFormStats({ firestore, hostId, formId })\n recounts[formId] = recount\n ? { recounted: recount.recounted, drift: recount.drift, written: recount.written }\n : null\n }\n return res.status(200).json({ ok: true, recounts })\n } catch (error) {\n // A refused credential is a 401; anything else is a fault of ours.\n if (isRefusedIdToken(error)) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n console.error('[forms] stats recount failed', error)\n return res.status(500).json({ error: 'Recount failed' })\n }\n}\n\n/** Console API registration. */\nexport function registerFormsConsoleApi(): void {\n registerPluginApiRoute('forms/stats', formStatsHandler)\n}\n"],"names":["registerPluginApiRoute","firebaseAdmin","isImpersonationSession","isRefusedIdToken","FORM_STATS_RECOUNT_MAX","recountFormStats","formStatsHandler","req","res","method","status","json","error","hostId","body","formIds","Array","isArray","Set","filter","id","length","authorization","String","headers","idToken","startsWith","slice","undefined","host","decoded","app","auth","verifyIdToken","email_verified","firestore","collection","doc","get","exists","staff","memberRole","uid","recounts","formId","recount","recounted","drift","written","ok","console","registerFormsConsoleApi"],"mappings":"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"}