@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.
- package/package.json +13 -12
- package/src/lib/components/form-detail-card.js +18 -4
- package/src/lib/components/form-detail-card.js.map +1 -1
- package/src/lib/components/form-list-query.d.ts +6 -1
- package/src/lib/components/form-list-query.js +34 -8
- package/src/lib/components/form-list-query.js.map +1 -1
- package/src/lib/components/form-metrics-card.component.d.ts +5 -4
- package/src/lib/components/form-metrics-card.component.js +7 -6
- package/src/lib/components/form-metrics-card.component.js.map +1 -1
- package/src/lib/components/host-forms-card.component.d.ts +7 -5
- package/src/lib/components/host-forms-card.component.js +37 -15
- package/src/lib/components/host-forms-card.component.js.map +1 -1
- package/src/lib/components/use-form-stats-recount-api.d.ts +38 -0
- package/src/lib/components/use-form-stats-recount-api.js +49 -0
- package/src/lib/components/use-form-stats-recount-api.js.map +1 -0
- package/src/lib/declarations.server.d.ts +41 -0
- package/src/lib/declarations.server.js +89 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/server/form-stats.d.ts +85 -0
- package/src/lib/server/form-stats.js +111 -0
- package/src/lib/server/form-stats.js.map +1 -0
- package/src/lib/server.d.ts +42 -0
- package/src/lib/server.js +122 -0
- package/src/lib/server.js.map +1 -0
|
@@ -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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"}
|