@aglyn/plugins-forms 1.0.0-beta.220 → 1.0.0-beta.222
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/README.md +1 -0
- package/package.json +14 -12
- package/src/lib/components/form-detail-card.js +166 -29
- package/src/lib/components/form-detail-card.js.map +1 -1
- package/src/lib/components/form-submissions-card.component.d.ts +12 -0
- package/src/lib/components/form-submissions-card.component.js +35 -2
- package/src/lib/components/form-submissions-card.component.js.map +1 -1
- package/src/lib/components/form.js +2 -1
- package/src/lib/components/form.js.map +1 -1
- package/src/lib/components/host-forms-card.component.d.ts +8 -0
- package/src/lib/components/host-forms-card.component.js +26 -1
- package/src/lib/components/host-forms-card.component.js.map +1 -1
- package/src/lib/components/site-package-form-preview.component.d.ts +13 -0
- package/src/lib/components/site-package-form-preview.component.js +57 -0
- package/src/lib/components/site-package-form-preview.component.js.map +1 -0
- package/src/lib/constants/site-package.d.ts +18 -0
- package/src/lib/constants/site-package.js +18 -0
- package/src/lib/constants/site-package.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +39 -0
- package/src/lib/declarations.console-server.js +68 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.js +22 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/form-door.d.ts +37 -0
- package/src/lib/model/form-door.js +50 -0
- package/src/lib/model/form-door.js.map +1 -0
- package/src/lib/model/form-submission-counts.d.ts +26 -0
- package/src/lib/model/form-submission-counts.js +35 -0
- package/src/lib/model/form-submission-counts.js.map +1 -0
- package/src/lib/model/form-submission-list.d.ts +40 -0
- package/src/lib/model/form-submission-list.js +66 -0
- package/src/lib/model/form-submission-list.js.map +1 -0
- package/src/lib/model/form-submission-record.d.ts +50 -0
- package/src/lib/model/form-submission-record.js +68 -0
- package/src/lib/model/form-submission-record.js.map +1 -0
- package/src/lib/model/form-unavailable.d.ts +83 -0
- package/src/lib/model/form-unavailable.js +88 -0
- package/src/lib/model/form-unavailable.js.map +1 -0
- package/src/lib/model/forms-record-lists.d.ts +31 -0
- package/src/lib/model/forms-record-lists.js +50 -0
- package/src/lib/model/forms-record-lists.js.map +1 -0
- package/src/lib/plan-entitlements.js +6 -6
- package/src/lib/plan-entitlements.js.map +1 -1
- package/src/lib/plugin.js +34 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/form-submissions.d.ts +34 -0
- package/src/lib/server/api-v1/form-submissions.js +273 -0
- package/src/lib/server/api-v1/form-submissions.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +26 -0
- package/src/lib/server/api-v1/openapi.js +133 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/form-intake-gate.d.ts +34 -0
- package/src/lib/server/form-intake-gate.js +44 -0
- package/src/lib/server/form-intake-gate.js.map +1 -0
- package/src/lib/server/form-submission-index.d.ts +26 -0
- package/src/lib/server/form-submission-index.js +51 -0
- package/src/lib/server/form-submission-index.js.map +1 -0
- package/src/lib/server/form-submit.d.ts +43 -0
- package/src/lib/server/form-submit.js +769 -0
- package/src/lib/server/form-submit.js.map +1 -0
- package/src/lib/server/increment-form-stats.d.ts +70 -0
- package/src/lib/server/increment-form-stats.js +112 -0
- package/src/lib/server/increment-form-stats.js.map +1 -0
- package/src/lib/server.d.ts +10 -0
- package/src/lib/server.js +15 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/form-submissions-transfer-key.d.ts +37 -0
- package/src/lib/transfer/form-submissions-transfer-key.js +40 -0
- package/src/lib/transfer/form-submissions-transfer-key.js.map +1 -0
- package/src/lib/transfer/form-submissions-transfer.d.ts +98 -0
- package/src/lib/transfer/form-submissions-transfer.js +563 -0
- package/src/lib/transfer/form-submissions-transfer.js.map +1 -0
- package/src/lib/transfer/form-submissions-transfer.server.d.ts +26 -0
- package/src/lib/transfer/form-submissions-transfer.server.js +31 -0
- package/src/lib/transfer/form-submissions-transfer.server.js.map +1 -0
|
@@ -0,0 +1,563 @@
|
|
|
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 { FORMS_MAX_PER_HOST } from "@aglyn/aglyn/app-utils/forms";
|
|
18
|
+
import { TRANSFER_ID_FIELD, buildMatchLookup, transferIdField } from "@aglyn/aglyn/data-transfer";
|
|
19
|
+
import { FieldPath, Timestamp } from "firebase-admin/firestore";
|
|
20
|
+
import { FORM_SUBMISSIONS_MATCH_KEYS } from "./form-submissions-transfer-key.js";
|
|
21
|
+
/*==========================================
|
|
22
|
+
* A SITE'S FORM SUBMISSIONS, AS AN EXPORT.
|
|
23
|
+
*
|
|
24
|
+
* `hosts/{hostId}/formSubmissions/{id}`, written only by this plugin's door
|
|
25
|
+
* (`server/form-submit.ts`): the answers a visitor sent, keyed by the form
|
|
26
|
+
* field's `fieldName`, with when, from which page and through which form.
|
|
27
|
+
* The record as a reader may rely on it is `model/form-submission-record.ts`'s;
|
|
28
|
+
* this module reads it the same way.
|
|
29
|
+
*
|
|
30
|
+
* ## Export only
|
|
31
|
+
*
|
|
32
|
+
* The declaration carries `exportOnly`: a submission is what a visitor sent
|
|
33
|
+
* through a published form, and a file cannot have sent one. So the resource
|
|
34
|
+
* answers the catalog, the reads, the count and a lookup by id, and registers
|
|
35
|
+
* no `apply` or `revert` — the upload route refuses it before a byte is
|
|
36
|
+
* stored.
|
|
37
|
+
*
|
|
38
|
+
* ## The columns follow the form
|
|
39
|
+
*
|
|
40
|
+
* A submission's answers have no fixed shape: each form asks its own
|
|
41
|
+
* questions, and a form edited since asks different ones than its older
|
|
42
|
+
* submissions answered. So the catalog is read from the site's forms, and
|
|
43
|
+
* from the list filter the dialog was opened on (`ctx.filter`):
|
|
44
|
+
*
|
|
45
|
+
* - one form (`{ formId }`): that form's questions, each `answer:<fieldName>`,
|
|
46
|
+
* labeled with the question and typed from the field; then "Other
|
|
47
|
+
* answers" — keys its most recent submissions carry and today's form no
|
|
48
|
+
* longer declares, so an answer to a removed question is still exported.
|
|
49
|
+
* - the whole site: every form's questions, each form its own group. A
|
|
50
|
+
* `fieldName` two forms share is one column (the first form's label), so
|
|
51
|
+
* an "email" asked by every form lands in one place.
|
|
52
|
+
*
|
|
53
|
+
* The submission's own details — the Aglyn ID, when it arrived, the form, the
|
|
54
|
+
* page, read, replied, campaigns, where it was filed — are read-only, and the
|
|
55
|
+
* platform-written ones are `system`.
|
|
56
|
+
*
|
|
57
|
+
* ## The reads are queries, never a loaded window
|
|
58
|
+
*
|
|
59
|
+
* `{ formId }` and `{ read }` are equalities on the stored row, ordered by
|
|
60
|
+
* `createdAt` newest first with the document id breaking ties, so a page
|
|
61
|
+
* boundary between two submissions of the same instant neither repeats nor
|
|
62
|
+
* skips one. `formId ↑ createdAt ↓` and `read ↑ createdAt ↓` are the
|
|
63
|
+
* collection's existing indexes (the console's per-form list and the Inbox's
|
|
64
|
+
* Read filter); no filter rides the single-field `createdAt` index. Both at
|
|
65
|
+
* once needs `formId ↑ read ↑ createdAt ↓`, which the export dialog never
|
|
66
|
+
* asks for today.
|
|
67
|
+
*
|
|
68
|
+
* ## Scope
|
|
69
|
+
*
|
|
70
|
+
* A host resource: the export route admits a reader only on the site, with
|
|
71
|
+
* `data.manage` there. Submissions carry no `visibleTo`, so `scopeTokens` —
|
|
72
|
+
* a site-scoped collaborator's tokens — narrows nothing here: a collaborator
|
|
73
|
+
* the route let onto this site may read this site's submissions, as the
|
|
74
|
+
* Inbox lets them.
|
|
75
|
+
*=========================================*/ const SUBMISSIONS = 'formSubmissions';
|
|
76
|
+
const FORMS = 'forms';
|
|
77
|
+
/** The prefix of an answer's field id; the rest is the form field's `fieldName`. */ export const FORM_SUBMISSION_ANSWER_PREFIX = 'answer:';
|
|
78
|
+
/** How many of the most recent submissions the catalog reads for answers today's forms no longer ask. */ export const FORM_SUBMISSIONS_ANSWER_SAMPLE_ROWS = 200;
|
|
79
|
+
/**
|
|
80
|
+
* The most "other answers" columns the catalog offers. A submission's keys
|
|
81
|
+
* come from the visitor's request (up to twenty, each at most 64 characters),
|
|
82
|
+
* so a sample can hold keys no form ever asked; the most frequent are kept.
|
|
83
|
+
*/ export const FORM_SUBMISSIONS_OTHER_ANSWERS_MAX = 50;
|
|
84
|
+
const PAGE_ROWS_DEFAULT = 200;
|
|
85
|
+
const PAGE_ROWS_MAX = 500;
|
|
86
|
+
/** Documents one `getAll` reads at a time, for a selection. */ const READ_BY_ID_CHUNK = 300;
|
|
87
|
+
/** The group the submission's own details are listed under. */ export const FORM_SUBMISSION_DETAILS_GROUP = {
|
|
88
|
+
id: 'submission',
|
|
89
|
+
label: 'Submission'
|
|
90
|
+
};
|
|
91
|
+
/** The group of answers no form declares today. */ export const FORM_SUBMISSION_OTHER_ANSWERS_GROUP = {
|
|
92
|
+
id: 'otherAnswers',
|
|
93
|
+
label: 'Other answers'
|
|
94
|
+
};
|
|
95
|
+
/** A form's questions are listed under `form:<formId>`. */ export function formAnswersGroupId(formId) {
|
|
96
|
+
return `form:${formId}`;
|
|
97
|
+
}
|
|
98
|
+
/** The field id an answer to `fieldName` is exported under. */ export function formSubmissionAnswerFieldId(fieldName) {
|
|
99
|
+
return `${FORM_SUBMISSION_ANSWER_PREFIX}${fieldName}`;
|
|
100
|
+
}
|
|
101
|
+
/** What an answer is, from the form field that asked it. */ export function formAnswerTransferType(fieldType) {
|
|
102
|
+
switch(fieldType){
|
|
103
|
+
case 'email':
|
|
104
|
+
return 'email';
|
|
105
|
+
case 'textarea':
|
|
106
|
+
return 'longText';
|
|
107
|
+
case 'rating':
|
|
108
|
+
return 'integer';
|
|
109
|
+
default:
|
|
110
|
+
return 'text';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The filter a dialog or a route handed over, or why it cannot be used.
|
|
115
|
+
* Refused rather than ignored: a filter dropped without a word exports more
|
|
116
|
+
* than the person asked for.
|
|
117
|
+
*/ export function readFormSubmissionsFilter(value) {
|
|
118
|
+
if (value === null || value === undefined) return {};
|
|
119
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
120
|
+
throw new Error('A form submissions filter is an object naming formId and read.');
|
|
121
|
+
}
|
|
122
|
+
const filter = {};
|
|
123
|
+
for (const [key, raw] of Object.entries(value)){
|
|
124
|
+
if (raw === undefined || raw === null) continue;
|
|
125
|
+
if (key === 'formId') {
|
|
126
|
+
const formId = typeof raw === 'string' ? raw.trim() : '';
|
|
127
|
+
if (!formId || formId.includes('/')) throw new Error('The form submissions filter names no usable form.');
|
|
128
|
+
filter.formId = formId;
|
|
129
|
+
} else if (key === 'read') {
|
|
130
|
+
if (raw === true || raw === 'true') filter.read = true;
|
|
131
|
+
else if (raw === false || raw === 'false') filter.read = false;
|
|
132
|
+
else throw new Error('The form submissions filter says read as true or false.');
|
|
133
|
+
} else {
|
|
134
|
+
throw new Error(`A form submissions filter names formId and read; "${key}" is not one.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return filter;
|
|
138
|
+
}
|
|
139
|
+
function matchesFilter(data, filter) {
|
|
140
|
+
if (filter.formId !== undefined && data['formId'] !== filter.formId) return false;
|
|
141
|
+
if (filter.read !== undefined && data['read'] === true !== filter.read) return false;
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/*==========================================
|
|
145
|
+
* THE CATALOG
|
|
146
|
+
*=========================================*/ /** The submission's own details, in the order a person reads them. */ export function formSubmissionDetailFields() {
|
|
147
|
+
const group = FORM_SUBMISSION_DETAILS_GROUP.id;
|
|
148
|
+
return [
|
|
149
|
+
_extends({}, transferIdField(), {
|
|
150
|
+
group
|
|
151
|
+
}),
|
|
152
|
+
{
|
|
153
|
+
id: 'createdAt',
|
|
154
|
+
label: 'Submitted at',
|
|
155
|
+
group,
|
|
156
|
+
type: 'datetime',
|
|
157
|
+
readOnly: true,
|
|
158
|
+
system: true,
|
|
159
|
+
aliases: [
|
|
160
|
+
'submitted',
|
|
161
|
+
'created',
|
|
162
|
+
'date'
|
|
163
|
+
],
|
|
164
|
+
description: 'When the submission arrived, by the server’s clock.'
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'formId',
|
|
168
|
+
label: 'Form ID',
|
|
169
|
+
group,
|
|
170
|
+
type: 'text',
|
|
171
|
+
readOnly: true,
|
|
172
|
+
system: true,
|
|
173
|
+
description: 'The form it was sent through; blank on submissions older than the form itself.'
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'formName',
|
|
177
|
+
label: 'Form name',
|
|
178
|
+
group,
|
|
179
|
+
type: 'text',
|
|
180
|
+
readOnly: true,
|
|
181
|
+
system: true,
|
|
182
|
+
aliases: [
|
|
183
|
+
'form'
|
|
184
|
+
],
|
|
185
|
+
description: 'What the form was called when the submission arrived.'
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'path',
|
|
189
|
+
label: 'Page path',
|
|
190
|
+
group,
|
|
191
|
+
type: 'text',
|
|
192
|
+
readOnly: true,
|
|
193
|
+
system: true,
|
|
194
|
+
aliases: [
|
|
195
|
+
'page',
|
|
196
|
+
'path'
|
|
197
|
+
],
|
|
198
|
+
description: 'The page of the site it was sent from.'
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'read',
|
|
202
|
+
label: 'Read',
|
|
203
|
+
group,
|
|
204
|
+
type: 'boolean',
|
|
205
|
+
readOnly: true,
|
|
206
|
+
description: 'Whether someone has marked it read in the Inbox.'
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'repliedAt',
|
|
210
|
+
label: 'Replied at',
|
|
211
|
+
group,
|
|
212
|
+
type: 'datetime',
|
|
213
|
+
readOnly: true,
|
|
214
|
+
description: 'When it was last answered from the Inbox.'
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: 'campaignIds',
|
|
218
|
+
label: 'Campaign IDs',
|
|
219
|
+
group,
|
|
220
|
+
type: 'tags',
|
|
221
|
+
readOnly: true,
|
|
222
|
+
system: true,
|
|
223
|
+
description: 'The campaigns the form was filed under when the submission arrived.'
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'routing',
|
|
227
|
+
label: 'Where it was filed',
|
|
228
|
+
group,
|
|
229
|
+
type: 'json',
|
|
230
|
+
readOnly: true,
|
|
231
|
+
system: true,
|
|
232
|
+
description: 'Where the submission was also filed beyond the Inbox, such as a dataset, or why it was not.'
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: 'rateDegraded',
|
|
236
|
+
label: 'Arrived while rate limits were unchecked',
|
|
237
|
+
group,
|
|
238
|
+
type: 'boolean',
|
|
239
|
+
readOnly: true,
|
|
240
|
+
system: true,
|
|
241
|
+
description: 'Yes when it arrived during a moment the site’s submission rate limit could not be checked.'
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'hostId',
|
|
245
|
+
label: 'Site ID',
|
|
246
|
+
group,
|
|
247
|
+
type: 'text',
|
|
248
|
+
readOnly: true,
|
|
249
|
+
system: true
|
|
250
|
+
}
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
function formQuestionsOf(id, data) {
|
|
254
|
+
const declared = Array.isArray(data == null ? void 0 : data['fields']) ? data == null ? void 0 : data['fields'] : [];
|
|
255
|
+
const displayName = data == null ? void 0 : data['displayName'];
|
|
256
|
+
return {
|
|
257
|
+
id,
|
|
258
|
+
name: typeof displayName === 'string' && displayName.trim() ? displayName.trim() : id,
|
|
259
|
+
fields: declared.filter((field)=>Boolean(field) && typeof field === 'object' && typeof field.fieldName === 'string' && field.fieldName.trim() !== '')
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function answerField(decl, group) {
|
|
263
|
+
const label = typeof decl.label === 'string' && decl.label.trim() ? decl.label.trim() : decl.fieldName;
|
|
264
|
+
return _extends({
|
|
265
|
+
id: formSubmissionAnswerFieldId(decl.fieldName),
|
|
266
|
+
label,
|
|
267
|
+
group,
|
|
268
|
+
type: formAnswerTransferType(decl.fieldType)
|
|
269
|
+
}, label !== decl.fieldName ? {
|
|
270
|
+
aliases: [
|
|
271
|
+
decl.fieldName
|
|
272
|
+
]
|
|
273
|
+
} : {});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The catalog, from the forms read and the answer keys sampled (see the
|
|
277
|
+
* block header). `scoped` is true when the dialog was opened on one form.
|
|
278
|
+
*/ export function formSubmissionsCatalog(input) {
|
|
279
|
+
const answers = [];
|
|
280
|
+
const groups = [
|
|
281
|
+
FORM_SUBMISSION_DETAILS_GROUP
|
|
282
|
+
];
|
|
283
|
+
const taken = new Set();
|
|
284
|
+
for (const form of input.forms){
|
|
285
|
+
const group = {
|
|
286
|
+
id: formAnswersGroupId(form.id),
|
|
287
|
+
label: input.scoped ? 'Answers' : `Answers: ${form.name}`
|
|
288
|
+
};
|
|
289
|
+
let added = false;
|
|
290
|
+
for (const decl of form.fields){
|
|
291
|
+
if (taken.has(decl.fieldName)) continue;
|
|
292
|
+
taken.add(decl.fieldName);
|
|
293
|
+
answers.push(answerField(decl, group.id));
|
|
294
|
+
added = true;
|
|
295
|
+
}
|
|
296
|
+
if (added) groups.push(group);
|
|
297
|
+
}
|
|
298
|
+
const others = input.sampledKeys.filter((key)=>!taken.has(key)).slice(0, FORM_SUBMISSIONS_OTHER_ANSWERS_MAX);
|
|
299
|
+
for (const key of others){
|
|
300
|
+
answers.push({
|
|
301
|
+
id: formSubmissionAnswerFieldId(key),
|
|
302
|
+
label: key,
|
|
303
|
+
group: FORM_SUBMISSION_OTHER_ANSWERS_GROUP.id,
|
|
304
|
+
type: 'text',
|
|
305
|
+
description: input.scoped ? 'An answer earlier submissions to this form carry that the form no longer asks for.' : 'An answer earlier submissions carry that no form on this site asks for today.'
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (others.length) groups.push(FORM_SUBMISSION_OTHER_ANSWERS_GROUP);
|
|
309
|
+
return {
|
|
310
|
+
standard: [
|
|
311
|
+
...formSubmissionDetailFields(),
|
|
312
|
+
...answers
|
|
313
|
+
],
|
|
314
|
+
groups
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** The answer keys the sampled submissions carry, most frequent first, then first seen. */ export function sampledAnswerKeys(rows) {
|
|
318
|
+
const seen = new Map();
|
|
319
|
+
for (const data of rows){
|
|
320
|
+
const fields = data == null ? void 0 : data['fields'];
|
|
321
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) continue;
|
|
322
|
+
for (const key of Object.keys(fields)){
|
|
323
|
+
if (!key.trim()) continue;
|
|
324
|
+
const entry = seen.get(key);
|
|
325
|
+
if (entry) entry.count += 1;
|
|
326
|
+
else seen.set(key, {
|
|
327
|
+
count: 1,
|
|
328
|
+
order: seen.size
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return [
|
|
333
|
+
...seen.entries()
|
|
334
|
+
].sort(([, a], [, b])=>b.count - a.count || a.order - b.order).map(([key])=>key);
|
|
335
|
+
}
|
|
336
|
+
/*==========================================
|
|
337
|
+
* ONE ROW
|
|
338
|
+
*=========================================*/ function textOrNull(value) {
|
|
339
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
340
|
+
}
|
|
341
|
+
/** A stored instant as ISO text, so a CSV reads it: a Firestore Timestamp, a Date, or milliseconds. */ function isoOf(value) {
|
|
342
|
+
if (value === null || value === undefined) return null;
|
|
343
|
+
if (typeof value === 'number') return Number.isFinite(value) ? new Date(value).toISOString() : null;
|
|
344
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
345
|
+
const toDate = value.toDate;
|
|
346
|
+
if (typeof toDate === 'function') return isoOf(toDate.call(value));
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const DETAIL_READERS = {
|
|
350
|
+
[TRANSFER_ID_FIELD]: (_data, id)=>id,
|
|
351
|
+
createdAt: (data)=>isoOf(data['createdAt']),
|
|
352
|
+
formId: (data)=>textOrNull(data['formId']),
|
|
353
|
+
formName: (data)=>textOrNull(data['formName']),
|
|
354
|
+
path: (data)=>textOrNull(data['path']),
|
|
355
|
+
read: (data)=>data['read'] === true,
|
|
356
|
+
repliedAt: (data)=>isoOf(data['repliedAtMs']),
|
|
357
|
+
campaignIds: (data)=>Array.isArray(data['campaignIds']) ? data['campaignIds'].filter((one)=>typeof one === 'string' && one !== '') : [],
|
|
358
|
+
routing: (data)=>data['routing'] && typeof data['routing'] === 'object' && !Array.isArray(data['routing']) ? data['routing'] : null,
|
|
359
|
+
rateDegraded: (data)=>data['rateDegraded'] === true,
|
|
360
|
+
hostId: (data)=>textOrNull(data['hostId'])
|
|
361
|
+
};
|
|
362
|
+
/** The rating questions of each form (AGL-3548). */ export function formRatingQuestions(forms) {
|
|
363
|
+
return new Map(forms.map((form)=>[
|
|
364
|
+
form.id,
|
|
365
|
+
new Set(form.fields.filter((field)=>field.fieldType === 'rating').map((field)=>field.fieldName))
|
|
366
|
+
]));
|
|
367
|
+
}
|
|
368
|
+
const RATING_TEXT = /^-?\d+(?:\.\d+)?$/;
|
|
369
|
+
/**
|
|
370
|
+
* One submission as an export row: only `fieldIds`, keyed by field id. An
|
|
371
|
+
* answer is the value as the visitor sent it — but an answer to a rating
|
|
372
|
+
* question of the submission's form (`ratings`) that reads as a number is
|
|
373
|
+
* that number, so a JSON file holds `4` and a CSV writes it as one, not as
|
|
374
|
+
* text. A question they did not answer, or a field id this resource does
|
|
375
|
+
* not know, is `null`.
|
|
376
|
+
*/ export function formSubmissionTransferRow(id, data, fieldIds, ratings) {
|
|
377
|
+
const answers = data['fields'] && typeof data['fields'] === 'object' && !Array.isArray(data['fields']) ? data['fields'] : {};
|
|
378
|
+
const formId = typeof data['formId'] === 'string' ? data['formId'] : '';
|
|
379
|
+
const rated = ratings == null ? void 0 : ratings.get(formId);
|
|
380
|
+
const row = {};
|
|
381
|
+
for (const fieldId of fieldIds){
|
|
382
|
+
const reader = DETAIL_READERS[fieldId];
|
|
383
|
+
if (reader) row[fieldId] = reader(data, id);
|
|
384
|
+
else if (fieldId.startsWith(FORM_SUBMISSION_ANSWER_PREFIX)) {
|
|
385
|
+
const name = fieldId.slice(FORM_SUBMISSION_ANSWER_PREFIX.length);
|
|
386
|
+
const answer = answers[name];
|
|
387
|
+
row[fieldId] = answer === undefined ? null : (rated == null ? void 0 : rated.has(name)) && typeof answer === 'string' && RATING_TEXT.test(answer.trim()) ? Number(answer.trim()) : answer;
|
|
388
|
+
} else row[fieldId] = null;
|
|
389
|
+
}
|
|
390
|
+
return row;
|
|
391
|
+
}
|
|
392
|
+
/*==========================================
|
|
393
|
+
* THE CURSOR
|
|
394
|
+
*=========================================*/ /**
|
|
395
|
+
* Where the next page of a query starts: the last row's `createdAt` (seconds
|
|
396
|
+
* and nanoseconds, so two submissions a microsecond apart stay apart) and
|
|
397
|
+
* its id. A selection's cursor is the offset into the ids.
|
|
398
|
+
*/ export function formSubmissionsCursor(createdAt, id) {
|
|
399
|
+
const at = createdAt;
|
|
400
|
+
if (typeof (at == null ? void 0 : at.seconds) !== 'number' || typeof (at == null ? void 0 : at.nanoseconds) !== 'number') {
|
|
401
|
+
throw new Error(`Form submission ${id} has no submission time to page from.`);
|
|
402
|
+
}
|
|
403
|
+
return `${at.seconds}:${at.nanoseconds}:${id}`;
|
|
404
|
+
}
|
|
405
|
+
function readQueryCursor(cursor) {
|
|
406
|
+
const found = /^(-?\d+):(\d+):([^]+)$/.exec(cursor);
|
|
407
|
+
if (!found) throw new Error('The export cursor could not be read.');
|
|
408
|
+
return {
|
|
409
|
+
createdAt: new Timestamp(Number(found[1]), Number(found[2])),
|
|
410
|
+
id: found[3]
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function readOffsetCursor(cursor) {
|
|
414
|
+
if (cursor === null) return 0;
|
|
415
|
+
if (!/^\d+$/.test(cursor)) throw new Error('The export cursor could not be read.');
|
|
416
|
+
return Number(cursor);
|
|
417
|
+
}
|
|
418
|
+
function pageSizeOf(options) {
|
|
419
|
+
const asked = Math.floor(Number(options == null ? void 0 : options.pageSize) || PAGE_ROWS_DEFAULT);
|
|
420
|
+
return Math.max(1, Math.min(PAGE_ROWS_MAX, asked));
|
|
421
|
+
}
|
|
422
|
+
/** The ids of a selection, each once, in the order given. */ function selectionIds(ids) {
|
|
423
|
+
return [
|
|
424
|
+
...new Set(ids.filter((id)=>typeof id === 'string' && id !== '' && !id.includes('/')))
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The form submissions resource's server half (see the block header).
|
|
429
|
+
* Firestore is handed in, so a spec runs it over an in-memory one.
|
|
430
|
+
*/ export function createFormSubmissionsTransferResource(deps) {
|
|
431
|
+
const { firestore } = deps;
|
|
432
|
+
const site = (hostId)=>firestore.collection('hosts').doc(hostId);
|
|
433
|
+
const submissions = (hostId)=>site(hostId).collection(SUBMISSIONS);
|
|
434
|
+
/** The filter as Firestore equalities, over the collection's existing indexes. */ const filtered = (hostId, filter)=>{
|
|
435
|
+
let query = submissions(hostId);
|
|
436
|
+
if (filter.formId !== undefined) query = query.where('formId', '==', filter.formId);
|
|
437
|
+
if (filter.read !== undefined) query = query.where('read', '==', filter.read);
|
|
438
|
+
return query;
|
|
439
|
+
};
|
|
440
|
+
const newestFirst = (query)=>query.orderBy('createdAt', 'desc').orderBy(FieldPath.documentId(), 'desc');
|
|
441
|
+
/** The selection's documents that exist and keep the filter, in the order given. */ const readByIds = async (hostId, ids, filter)=>{
|
|
442
|
+
const found = [];
|
|
443
|
+
for(let at = 0; at < ids.length; at += READ_BY_ID_CHUNK){
|
|
444
|
+
const refs = ids.slice(at, at + READ_BY_ID_CHUNK).map((id)=>submissions(hostId).doc(id));
|
|
445
|
+
if (!refs.length) continue;
|
|
446
|
+
const snapshots = await firestore.getAll(...refs);
|
|
447
|
+
for (const snapshot of snapshots){
|
|
448
|
+
const data = snapshot.data();
|
|
449
|
+
if (snapshot.exists && data && matchesFilter(data, filter)) found.push(snapshot);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return found;
|
|
453
|
+
};
|
|
454
|
+
const readForms = async (hostId, formId)=>{
|
|
455
|
+
if (formId !== undefined) {
|
|
456
|
+
const snapshot = await site(hostId).collection(FORMS).doc(formId).get();
|
|
457
|
+
return snapshot.exists ? [
|
|
458
|
+
formQuestionsOf(snapshot.id, snapshot.data())
|
|
459
|
+
] : [];
|
|
460
|
+
}
|
|
461
|
+
const snapshot = await site(hostId).collection(FORMS).limit(FORMS_MAX_PER_HOST).get();
|
|
462
|
+
return snapshot.docs.map((document)=>formQuestionsOf(document.id, document.data())).sort((a, b)=>a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
463
|
+
};
|
|
464
|
+
const readPage = async (ctx, cursor, fieldIds, options)=>{
|
|
465
|
+
const hostId = ctx.hostId;
|
|
466
|
+
if (!hostId) return {
|
|
467
|
+
rows: [],
|
|
468
|
+
next: null
|
|
469
|
+
};
|
|
470
|
+
const filter = readFormSubmissionsFilter(options == null ? void 0 : options.filter);
|
|
471
|
+
const size = pageSizeOf(options);
|
|
472
|
+
// Which answers are ratings, read only when an answer is asked for.
|
|
473
|
+
const ratings = fieldIds.some((fieldId)=>fieldId.startsWith(FORM_SUBMISSION_ANSWER_PREFIX)) ? formRatingQuestions(await readForms(hostId, filter.formId)) : undefined;
|
|
474
|
+
if (options == null ? void 0 : options.ids) {
|
|
475
|
+
const ids = selectionIds(options.ids);
|
|
476
|
+
const from = readOffsetCursor(cursor);
|
|
477
|
+
const to = from + size;
|
|
478
|
+
const snapshots = await readByIds(hostId, ids.slice(from, to), filter);
|
|
479
|
+
return {
|
|
480
|
+
rows: snapshots.map((snapshot)=>{
|
|
481
|
+
var _snapshot_data;
|
|
482
|
+
return formSubmissionTransferRow(snapshot.id, (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}, fieldIds, ratings);
|
|
483
|
+
}),
|
|
484
|
+
next: to < ids.length ? String(to) : null
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
let query = newestFirst(filtered(hostId, filter));
|
|
488
|
+
if (cursor !== null) {
|
|
489
|
+
const after = readQueryCursor(cursor);
|
|
490
|
+
query = query.startAfter(after.createdAt, after.id);
|
|
491
|
+
}
|
|
492
|
+
// One past the page, so the last page says it is the last.
|
|
493
|
+
const snapshot = await query.limit(size + 1).get();
|
|
494
|
+
const page = snapshot.docs.slice(0, size);
|
|
495
|
+
const last = page[page.length - 1];
|
|
496
|
+
return {
|
|
497
|
+
rows: page.map((document)=>formSubmissionTransferRow(document.id, document.data(), fieldIds, ratings)),
|
|
498
|
+
next: snapshot.docs.length > size && last ? formSubmissionsCursor(last.get('createdAt'), last.id) : null
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
return {
|
|
502
|
+
matchKeys: FORM_SUBMISSIONS_MATCH_KEYS,
|
|
503
|
+
async fields (ctx) {
|
|
504
|
+
const hostId = ctx.hostId;
|
|
505
|
+
if (!hostId) return formSubmissionsCatalog({
|
|
506
|
+
forms: [],
|
|
507
|
+
sampledKeys: [],
|
|
508
|
+
scoped: false
|
|
509
|
+
});
|
|
510
|
+
const { formId } = readFormSubmissionsFilter(ctx.filter);
|
|
511
|
+
const [forms, sample] = await Promise.all([
|
|
512
|
+
readForms(hostId, formId),
|
|
513
|
+
newestFirst(filtered(hostId, formId === undefined ? {} : {
|
|
514
|
+
formId
|
|
515
|
+
})).limit(FORM_SUBMISSIONS_ANSWER_SAMPLE_ROWS).get()
|
|
516
|
+
]);
|
|
517
|
+
return formSubmissionsCatalog({
|
|
518
|
+
forms,
|
|
519
|
+
sampledKeys: sampledAnswerKeys(sample.docs.map((document)=>document.data())),
|
|
520
|
+
scoped: formId !== undefined
|
|
521
|
+
});
|
|
522
|
+
},
|
|
523
|
+
async count (ctx, options) {
|
|
524
|
+
const hostId = ctx.hostId;
|
|
525
|
+
if (!hostId) return 0;
|
|
526
|
+
const filter = readFormSubmissionsFilter(options.filter);
|
|
527
|
+
if (options.ids) return (await readByIds(hostId, selectionIds(options.ids), filter)).length;
|
|
528
|
+
const counted = await filtered(hostId, filter).count().get();
|
|
529
|
+
return counted.data().count;
|
|
530
|
+
},
|
|
531
|
+
readPage,
|
|
532
|
+
/**
|
|
533
|
+
* What a submission holds now, by its Aglyn ID — the one key this
|
|
534
|
+
* resource answers, since nothing ever imports into it.
|
|
535
|
+
*/ async lookup (ctx, requests) {
|
|
536
|
+
const byId = requests.filter((request)=>request.fieldId === TRANSFER_ID_FIELD);
|
|
537
|
+
const ids = byId.flatMap((request)=>request.values);
|
|
538
|
+
const found = ctx.hostId ? await readByIds(ctx.hostId, selectionIds(ids), {}) : [];
|
|
539
|
+
const records = new Map();
|
|
540
|
+
for (const document of found){
|
|
541
|
+
var _document_data;
|
|
542
|
+
const data = (_document_data = document.data()) != null ? _document_data : {};
|
|
543
|
+
const answers = data['fields'] && typeof data['fields'] === 'object' ? Object.keys(data['fields']) : [];
|
|
544
|
+
records.set(document.id, formSubmissionTransferRow(document.id, data, [
|
|
545
|
+
...Object.keys(DETAIL_READERS),
|
|
546
|
+
...answers.map(formSubmissionAnswerFieldId)
|
|
547
|
+
]));
|
|
548
|
+
}
|
|
549
|
+
const lookup = buildMatchLookup([
|
|
550
|
+
...records
|
|
551
|
+
].map(([id, values])=>({
|
|
552
|
+
id,
|
|
553
|
+
values
|
|
554
|
+
})), byId);
|
|
555
|
+
return {
|
|
556
|
+
lookup,
|
|
557
|
+
records
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
//# sourceMappingURL=form-submissions-transfer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/forms/src/lib/transfer/form-submissions-transfer.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 FORMS_MAX_PER_HOST,\n type FormFieldDecl,\n type FormFieldType,\n} from '@aglyn/aglyn/app-utils/forms'\nimport {\n TRANSFER_ID_FIELD,\n buildMatchLookup,\n transferIdField,\n type TransferCatalogInput,\n type TransferField,\n type TransferFieldGroup,\n type TransferFieldType,\n} from '@aglyn/aglyn/data-transfer'\nimport type {\n TransferReadOptions,\n TransferRecordsHooks,\n TransferReadPage,\n TransferResourceContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\nimport { FORM_SUBMISSIONS_MATCH_KEYS } from './form-submissions-transfer-key'\n\n/*==========================================\n * A SITE'S FORM SUBMISSIONS, AS AN EXPORT.\n *\n * `hosts/{hostId}/formSubmissions/{id}`, written only by this plugin's door\n * (`server/form-submit.ts`): the answers a visitor sent, keyed by the form\n * field's `fieldName`, with when, from which page and through which form.\n * The record as a reader may rely on it is `model/form-submission-record.ts`'s;\n * this module reads it the same way.\n *\n * ## Export only\n *\n * The declaration carries `exportOnly`: a submission is what a visitor sent\n * through a published form, and a file cannot have sent one. So the resource\n * answers the catalog, the reads, the count and a lookup by id, and registers\n * no `apply` or `revert` — the upload route refuses it before a byte is\n * stored.\n *\n * ## The columns follow the form\n *\n * A submission's answers have no fixed shape: each form asks its own\n * questions, and a form edited since asks different ones than its older\n * submissions answered. So the catalog is read from the site's forms, and\n * from the list filter the dialog was opened on (`ctx.filter`):\n *\n * - one form (`{ formId }`): that form's questions, each `answer:<fieldName>`,\n * labeled with the question and typed from the field; then \"Other\n * answers\" — keys its most recent submissions carry and today's form no\n * longer declares, so an answer to a removed question is still exported.\n * - the whole site: every form's questions, each form its own group. A\n * `fieldName` two forms share is one column (the first form's label), so\n * an \"email\" asked by every form lands in one place.\n *\n * The submission's own details — the Aglyn ID, when it arrived, the form, the\n * page, read, replied, campaigns, where it was filed — are read-only, and the\n * platform-written ones are `system`.\n *\n * ## The reads are queries, never a loaded window\n *\n * `{ formId }` and `{ read }` are equalities on the stored row, ordered by\n * `createdAt` newest first with the document id breaking ties, so a page\n * boundary between two submissions of the same instant neither repeats nor\n * skips one. `formId ↑ createdAt ↓` and `read ↑ createdAt ↓` are the\n * collection's existing indexes (the console's per-form list and the Inbox's\n * Read filter); no filter rides the single-field `createdAt` index. Both at\n * once needs `formId ↑ read ↑ createdAt ↓`, which the export dialog never\n * asks for today.\n *\n * ## Scope\n *\n * A host resource: the export route admits a reader only on the site, with\n * `data.manage` there. Submissions carry no `visibleTo`, so `scopeTokens` —\n * a site-scoped collaborator's tokens — narrows nothing here: a collaborator\n * the route let onto this site may read this site's submissions, as the\n * Inbox lets them.\n *=========================================*/\n\nconst SUBMISSIONS = 'formSubmissions'\nconst FORMS = 'forms'\n\n/** The prefix of an answer's field id; the rest is the form field's `fieldName`. */\nexport const FORM_SUBMISSION_ANSWER_PREFIX = 'answer:'\n\n/** How many of the most recent submissions the catalog reads for answers today's forms no longer ask. */\nexport const FORM_SUBMISSIONS_ANSWER_SAMPLE_ROWS = 200\n\n/**\n * The most \"other answers\" columns the catalog offers. A submission's keys\n * come from the visitor's request (up to twenty, each at most 64 characters),\n * so a sample can hold keys no form ever asked; the most frequent are kept.\n */\nexport const FORM_SUBMISSIONS_OTHER_ANSWERS_MAX = 50\n\nconst PAGE_ROWS_DEFAULT = 200\nconst PAGE_ROWS_MAX = 500\n/** Documents one `getAll` reads at a time, for a selection. */\nconst READ_BY_ID_CHUNK = 300\n\n/** The group the submission's own details are listed under. */\nexport const FORM_SUBMISSION_DETAILS_GROUP: TransferFieldGroup = { id: 'submission', label: 'Submission' }\n/** The group of answers no form declares today. */\nexport const FORM_SUBMISSION_OTHER_ANSWERS_GROUP: TransferFieldGroup = {\n id: 'otherAnswers',\n label: 'Other answers',\n}\n\n/** A form's questions are listed under `form:<formId>`. */\nexport function formAnswersGroupId(formId: string): string {\n return `form:${formId}`\n}\n\n/** The field id an answer to `fieldName` is exported under. */\nexport function formSubmissionAnswerFieldId(fieldName: string): string {\n return `${FORM_SUBMISSION_ANSWER_PREFIX}${fieldName}`\n}\n\n/** What an answer is, from the form field that asked it. */\nexport function formAnswerTransferType(fieldType: FormFieldType | undefined): TransferFieldType {\n switch (fieldType) {\n case 'email':\n return 'email'\n case 'textarea':\n return 'longText'\n case 'rating':\n return 'integer'\n default:\n return 'text'\n }\n}\n\n/*==========================================\n * THE FILTER\n *=========================================*/\n\n/** The list filter, in this resource's terms: one form, and read or unread. */\nexport interface FormSubmissionsFilter {\n formId?: string\n read?: boolean\n}\n\n/**\n * The filter a dialog or a route handed over, or why it cannot be used.\n * Refused rather than ignored: a filter dropped without a word exports more\n * than the person asked for.\n */\nexport function readFormSubmissionsFilter(value: unknown): FormSubmissionsFilter {\n if (value === null || value === undefined) return {}\n if (typeof value !== 'object' || Array.isArray(value)) {\n throw new Error('A form submissions filter is an object naming formId and read.')\n }\n const filter: FormSubmissionsFilter = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (raw === undefined || raw === null) continue\n if (key === 'formId') {\n const formId = typeof raw === 'string' ? raw.trim() : ''\n if (!formId || formId.includes('/')) throw new Error('The form submissions filter names no usable form.')\n filter.formId = formId\n } else if (key === 'read') {\n if (raw === true || raw === 'true') filter.read = true\n else if (raw === false || raw === 'false') filter.read = false\n else throw new Error('The form submissions filter says read as true or false.')\n } else {\n throw new Error(`A form submissions filter names formId and read; \"${key}\" is not one.`)\n }\n }\n return filter\n}\n\nfunction matchesFilter(data: Readonly<Record<string, unknown>>, filter: FormSubmissionsFilter): boolean {\n if (filter.formId !== undefined && data['formId'] !== filter.formId) return false\n if (filter.read !== undefined && (data['read'] === true) !== filter.read) return false\n return true\n}\n\n/*==========================================\n * THE CATALOG\n *=========================================*/\n\n/** The submission's own details, in the order a person reads them. */\nexport function formSubmissionDetailFields(): TransferField[] {\n const group = FORM_SUBMISSION_DETAILS_GROUP.id\n return [\n { ...transferIdField(), group },\n {\n id: 'createdAt',\n label: 'Submitted at',\n group,\n type: 'datetime',\n readOnly: true,\n system: true,\n aliases: ['submitted', 'created', 'date'],\n description: 'When the submission arrived, by the server’s clock.',\n },\n {\n id: 'formId',\n label: 'Form ID',\n group,\n type: 'text',\n readOnly: true,\n system: true,\n description: 'The form it was sent through; blank on submissions older than the form itself.',\n },\n {\n id: 'formName',\n label: 'Form name',\n group,\n type: 'text',\n readOnly: true,\n system: true,\n aliases: ['form'],\n description: 'What the form was called when the submission arrived.',\n },\n {\n id: 'path',\n label: 'Page path',\n group,\n type: 'text',\n readOnly: true,\n system: true,\n aliases: ['page', 'path'],\n description: 'The page of the site it was sent from.',\n },\n {\n id: 'read',\n label: 'Read',\n group,\n type: 'boolean',\n readOnly: true,\n description: 'Whether someone has marked it read in the Inbox.',\n },\n {\n id: 'repliedAt',\n label: 'Replied at',\n group,\n type: 'datetime',\n readOnly: true,\n description: 'When it was last answered from the Inbox.',\n },\n {\n id: 'campaignIds',\n label: 'Campaign IDs',\n group,\n type: 'tags',\n readOnly: true,\n system: true,\n description: 'The campaigns the form was filed under when the submission arrived.',\n },\n {\n id: 'routing',\n label: 'Where it was filed',\n group,\n type: 'json',\n readOnly: true,\n system: true,\n description: 'Where the submission was also filed beyond the Inbox, such as a dataset, or why it was not.',\n },\n {\n id: 'rateDegraded',\n label: 'Arrived while rate limits were unchecked',\n group,\n type: 'boolean',\n readOnly: true,\n system: true,\n description: 'Yes when it arrived during a moment the site’s submission rate limit could not be checked.',\n },\n {\n id: 'hostId',\n label: 'Site ID',\n group,\n type: 'text',\n readOnly: true,\n system: true,\n },\n ]\n}\n\n/** One form as the catalog reads it: its id, its name, and the questions it declares. */\nexport interface FormQuestions {\n id: string\n name: string\n fields: readonly FormFieldDecl[]\n}\n\nfunction formQuestionsOf(id: string, data: Readonly<Record<string, unknown>> | undefined): FormQuestions {\n const declared = Array.isArray(data?.['fields']) ? (data?.['fields'] as unknown[]) : []\n const displayName = data?.['displayName']\n return {\n id,\n name: typeof displayName === 'string' && displayName.trim() ? displayName.trim() : id,\n fields: declared.filter(\n (field): field is FormFieldDecl =>\n Boolean(field) &&\n typeof field === 'object' &&\n typeof (field as FormFieldDecl).fieldName === 'string' &&\n (field as FormFieldDecl).fieldName.trim() !== '',\n ),\n }\n}\n\nfunction answerField(decl: FormFieldDecl, group: string): TransferField {\n const label = typeof decl.label === 'string' && decl.label.trim() ? decl.label.trim() : decl.fieldName\n return {\n id: formSubmissionAnswerFieldId(decl.fieldName),\n label,\n group,\n type: formAnswerTransferType(decl.fieldType),\n ...(label !== decl.fieldName ? { aliases: [decl.fieldName] } : {}),\n }\n}\n\n/**\n * The catalog, from the forms read and the answer keys sampled (see the\n * block header). `scoped` is true when the dialog was opened on one form.\n */\nexport function formSubmissionsCatalog(input: {\n forms: readonly FormQuestions[]\n sampledKeys: readonly string[]\n scoped: boolean\n}): TransferCatalogInput {\n const answers: TransferField[] = []\n const groups: TransferFieldGroup[] = [FORM_SUBMISSION_DETAILS_GROUP]\n const taken = new Set<string>()\n for (const form of input.forms) {\n const group: TransferFieldGroup = {\n id: formAnswersGroupId(form.id),\n label: input.scoped ? 'Answers' : `Answers: ${form.name}`,\n }\n let added = false\n for (const decl of form.fields) {\n if (taken.has(decl.fieldName)) continue\n taken.add(decl.fieldName)\n answers.push(answerField(decl, group.id))\n added = true\n }\n if (added) groups.push(group)\n }\n const others = input.sampledKeys.filter((key) => !taken.has(key)).slice(0, FORM_SUBMISSIONS_OTHER_ANSWERS_MAX)\n for (const key of others) {\n answers.push({\n id: formSubmissionAnswerFieldId(key),\n label: key,\n group: FORM_SUBMISSION_OTHER_ANSWERS_GROUP.id,\n type: 'text',\n description: input.scoped\n ? 'An answer earlier submissions to this form carry that the form no longer asks for.'\n : 'An answer earlier submissions carry that no form on this site asks for today.',\n })\n }\n if (others.length) groups.push(FORM_SUBMISSION_OTHER_ANSWERS_GROUP)\n return { standard: [...formSubmissionDetailFields(), ...answers], groups }\n}\n\n/** The answer keys the sampled submissions carry, most frequent first, then first seen. */\nexport function sampledAnswerKeys(rows: ReadonlyArray<Readonly<Record<string, unknown>> | undefined>): string[] {\n const seen = new Map<string, { count: number; order: number }>()\n for (const data of rows) {\n const fields = data?.['fields']\n if (!fields || typeof fields !== 'object' || Array.isArray(fields)) continue\n for (const key of Object.keys(fields)) {\n if (!key.trim()) continue\n const entry = seen.get(key)\n if (entry) entry.count += 1\n else seen.set(key, { count: 1, order: seen.size })\n }\n }\n return [...seen.entries()]\n .sort(([, a], [, b]) => b.count - a.count || a.order - b.order)\n .map(([key]) => key)\n}\n\n/*==========================================\n * ONE ROW\n *=========================================*/\n\nfunction textOrNull(value: unknown): string | null {\n return typeof value === 'string' && value !== '' ? value : null\n}\n\n/** A stored instant as ISO text, so a CSV reads it: a Firestore Timestamp, a Date, or milliseconds. */\nfunction isoOf(value: unknown): string | null {\n if (value === null || value === undefined) return null\n if (typeof value === 'number') return Number.isFinite(value) ? new Date(value).toISOString() : null\n if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString()\n const toDate = (value as { toDate?: () => Date }).toDate\n if (typeof toDate === 'function') return isoOf(toDate.call(value))\n return null\n}\n\nconst DETAIL_READERS: Readonly<Record<string, (data: Readonly<Record<string, unknown>>, id: string) => unknown>> = {\n [TRANSFER_ID_FIELD]: (_data, id) => id,\n createdAt: (data) => isoOf(data['createdAt']),\n formId: (data) => textOrNull(data['formId']),\n formName: (data) => textOrNull(data['formName']),\n path: (data) => textOrNull(data['path']),\n read: (data) => data['read'] === true,\n repliedAt: (data) => isoOf(data['repliedAtMs']),\n campaignIds: (data) =>\n Array.isArray(data['campaignIds'])\n ? (data['campaignIds'] as unknown[]).filter((one): one is string => typeof one === 'string' && one !== '')\n : [],\n routing: (data) =>\n data['routing'] && typeof data['routing'] === 'object' && !Array.isArray(data['routing'])\n ? data['routing']\n : null,\n rateDegraded: (data) => data['rateDegraded'] === true,\n hostId: (data) => textOrNull(data['hostId']),\n}\n\n/** Form id → the names of its rating questions, whose answers are numbers. */\nexport type FormRatingQuestions = ReadonlyMap<string, ReadonlySet<string>>\n\n/** The rating questions of each form (AGL-3548). */\nexport function formRatingQuestions(forms: readonly FormQuestions[]): Map<string, Set<string>> {\n return new Map(\n forms.map((form) => [\n form.id,\n new Set(form.fields.filter((field) => field.fieldType === 'rating').map((field) => field.fieldName)),\n ]),\n )\n}\n\nconst RATING_TEXT = /^-?\\d+(?:\\.\\d+)?$/\n\n/**\n * One submission as an export row: only `fieldIds`, keyed by field id. An\n * answer is the value as the visitor sent it — but an answer to a rating\n * question of the submission's form (`ratings`) that reads as a number is\n * that number, so a JSON file holds `4` and a CSV writes it as one, not as\n * text. A question they did not answer, or a field id this resource does\n * not know, is `null`.\n */\nexport function formSubmissionTransferRow(\n id: string,\n data: Readonly<Record<string, unknown>>,\n fieldIds: readonly string[],\n ratings?: FormRatingQuestions,\n): Record<string, unknown> {\n const answers =\n data['fields'] && typeof data['fields'] === 'object' && !Array.isArray(data['fields'])\n ? (data['fields'] as Readonly<Record<string, unknown>>)\n : {}\n const formId = typeof data['formId'] === 'string' ? data['formId'] : ''\n const rated = ratings?.get(formId)\n const row: Record<string, unknown> = {}\n for (const fieldId of fieldIds) {\n const reader = DETAIL_READERS[fieldId]\n if (reader) row[fieldId] = reader(data, id)\n else if (fieldId.startsWith(FORM_SUBMISSION_ANSWER_PREFIX)) {\n const name = fieldId.slice(FORM_SUBMISSION_ANSWER_PREFIX.length)\n const answer = answers[name]\n row[fieldId] =\n answer === undefined\n ? null\n : rated?.has(name) && typeof answer === 'string' && RATING_TEXT.test(answer.trim())\n ? Number(answer.trim())\n : answer\n } else row[fieldId] = null\n }\n return row\n}\n\n/*==========================================\n * THE CURSOR\n *=========================================*/\n\n/**\n * Where the next page of a query starts: the last row's `createdAt` (seconds\n * and nanoseconds, so two submissions a microsecond apart stay apart) and\n * its id. A selection's cursor is the offset into the ids.\n */\nexport function formSubmissionsCursor(createdAt: unknown, id: string): string {\n const at = createdAt as { seconds?: unknown; nanoseconds?: unknown } | null | undefined\n if (typeof at?.seconds !== 'number' || typeof at?.nanoseconds !== 'number') {\n throw new Error(`Form submission ${id} has no submission time to page from.`)\n }\n return `${at.seconds}:${at.nanoseconds}:${id}`\n}\n\nfunction readQueryCursor(cursor: string): { createdAt: Timestamp; id: string } {\n const found = /^(-?\\d+):(\\d+):([^]+)$/.exec(cursor)\n if (!found) throw new Error('The export cursor could not be read.')\n return { createdAt: new Timestamp(Number(found[1]), Number(found[2])), id: found[3] as string }\n}\n\nfunction readOffsetCursor(cursor: string | null): number {\n if (cursor === null) return 0\n if (!/^\\d+$/.test(cursor)) throw new Error('The export cursor could not be read.')\n return Number(cursor)\n}\n\n/*==========================================\n * THE RESOURCE\n *=========================================*/\n\nexport interface FormSubmissionsTransferDeps {\n firestore: FirebaseFirestore.Firestore\n}\n\n/** The hooks an export-only records resource answers with: no apply or revert. */\nexport type FormSubmissionsTransferResource = Required<\n Pick<TransferRecordsHooks, 'matchKeys' | 'fields' | 'count' | 'readPage' | 'lookup'>\n>\n\nfunction pageSizeOf(options: TransferReadOptions | undefined): number {\n const asked = Math.floor(Number(options?.pageSize) || PAGE_ROWS_DEFAULT)\n return Math.max(1, Math.min(PAGE_ROWS_MAX, asked))\n}\n\n/** The ids of a selection, each once, in the order given. */\nfunction selectionIds(ids: readonly string[]): string[] {\n return [...new Set(ids.filter((id) => typeof id === 'string' && id !== '' && !id.includes('/')))]\n}\n\n/**\n * The form submissions resource's server half (see the block header).\n * Firestore is handed in, so a spec runs it over an in-memory one.\n */\nexport function createFormSubmissionsTransferResource(\n deps: FormSubmissionsTransferDeps,\n): FormSubmissionsTransferResource {\n const { firestore } = deps\n const site = (hostId: string) => firestore.collection('hosts').doc(hostId)\n const submissions = (hostId: string) => site(hostId).collection(SUBMISSIONS)\n\n /** The filter as Firestore equalities, over the collection's existing indexes. */\n const filtered = (hostId: string, filter: FormSubmissionsFilter): FirebaseFirestore.Query => {\n let query: FirebaseFirestore.Query = submissions(hostId)\n if (filter.formId !== undefined) query = query.where('formId', '==', filter.formId)\n if (filter.read !== undefined) query = query.where('read', '==', filter.read)\n return query\n }\n const newestFirst = (query: FirebaseFirestore.Query): FirebaseFirestore.Query =>\n query.orderBy('createdAt', 'desc').orderBy(FieldPath.documentId(), 'desc')\n\n /** The selection's documents that exist and keep the filter, in the order given. */\n const readByIds = async (\n hostId: string,\n ids: readonly string[],\n filter: FormSubmissionsFilter,\n ): Promise<FirebaseFirestore.DocumentSnapshot[]> => {\n const found: FirebaseFirestore.DocumentSnapshot[] = []\n for (let at = 0; at < ids.length; at += READ_BY_ID_CHUNK) {\n const refs = ids.slice(at, at + READ_BY_ID_CHUNK).map((id) => submissions(hostId).doc(id))\n if (!refs.length) continue\n const snapshots = await firestore.getAll(...refs)\n for (const snapshot of snapshots) {\n const data = snapshot.data()\n if (snapshot.exists && data && matchesFilter(data, filter)) found.push(snapshot)\n }\n }\n return found\n }\n\n const readForms = async (hostId: string, formId: string | undefined): Promise<FormQuestions[]> => {\n if (formId !== undefined) {\n const snapshot = await site(hostId).collection(FORMS).doc(formId).get()\n return snapshot.exists ? [formQuestionsOf(snapshot.id, snapshot.data())] : []\n }\n const snapshot = await site(hostId).collection(FORMS).limit(FORMS_MAX_PER_HOST).get()\n return snapshot.docs\n .map((document) => formQuestionsOf(document.id, document.data()))\n .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))\n }\n\n const readPage = async (\n ctx: TransferResourceContext,\n cursor: string | null,\n fieldIds: readonly string[],\n options?: TransferReadOptions,\n ): Promise<TransferReadPage> => {\n const hostId = ctx.hostId\n if (!hostId) return { rows: [], next: null }\n const filter = readFormSubmissionsFilter(options?.filter)\n const size = pageSizeOf(options)\n // Which answers are ratings, read only when an answer is asked for.\n const ratings = fieldIds.some((fieldId) => fieldId.startsWith(FORM_SUBMISSION_ANSWER_PREFIX))\n ? formRatingQuestions(await readForms(hostId, filter.formId))\n : undefined\n if (options?.ids) {\n const ids = selectionIds(options.ids)\n const from = readOffsetCursor(cursor)\n const to = from + size\n const snapshots = await readByIds(hostId, ids.slice(from, to), filter)\n return {\n rows: snapshots.map((snapshot) =>\n formSubmissionTransferRow(snapshot.id, snapshot.data() ?? {}, fieldIds, ratings),\n ),\n next: to < ids.length ? String(to) : null,\n }\n }\n let query = newestFirst(filtered(hostId, filter))\n if (cursor !== null) {\n const after = readQueryCursor(cursor)\n query = query.startAfter(after.createdAt, after.id)\n }\n // One past the page, so the last page says it is the last.\n const snapshot = await query.limit(size + 1).get()\n const page = snapshot.docs.slice(0, size)\n const last = page[page.length - 1]\n return {\n rows: page.map((document) => formSubmissionTransferRow(document.id, document.data(), fieldIds, ratings)),\n next: snapshot.docs.length > size && last ? formSubmissionsCursor(last.get('createdAt'), last.id) : null,\n }\n }\n\n return {\n matchKeys: FORM_SUBMISSIONS_MATCH_KEYS,\n\n async fields(ctx) {\n const hostId = ctx.hostId\n if (!hostId) return formSubmissionsCatalog({ forms: [], sampledKeys: [], scoped: false })\n const { formId } = readFormSubmissionsFilter(ctx.filter)\n const [forms, sample] = await Promise.all([\n readForms(hostId, formId),\n newestFirst(filtered(hostId, formId === undefined ? {} : { formId }))\n .limit(FORM_SUBMISSIONS_ANSWER_SAMPLE_ROWS)\n .get(),\n ])\n return formSubmissionsCatalog({\n forms,\n sampledKeys: sampledAnswerKeys(sample.docs.map((document) => document.data())),\n scoped: formId !== undefined,\n })\n },\n\n async count(ctx, options) {\n const hostId = ctx.hostId\n if (!hostId) return 0\n const filter = readFormSubmissionsFilter(options.filter)\n if (options.ids) return (await readByIds(hostId, selectionIds(options.ids), filter)).length\n const counted = await filtered(hostId, filter).count().get()\n return counted.data().count\n },\n\n readPage,\n\n /**\n * What a submission holds now, by its Aglyn ID — the one key this\n * resource answers, since nothing ever imports into it.\n */\n async lookup(ctx, requests) {\n const byId = requests.filter((request) => request.fieldId === TRANSFER_ID_FIELD)\n const ids = byId.flatMap((request) => request.values)\n const found = ctx.hostId ? await readByIds(ctx.hostId, selectionIds(ids), {}) : []\n const records = new Map<string, Record<string, unknown>>()\n for (const document of found) {\n const data = document.data() ?? {}\n const answers = data['fields'] && typeof data['fields'] === 'object' ? Object.keys(data['fields']) : []\n records.set(\n document.id,\n formSubmissionTransferRow(document.id, data, [\n ...Object.keys(DETAIL_READERS),\n ...answers.map(formSubmissionAnswerFieldId),\n ]),\n )\n }\n const lookup = buildMatchLookup(\n [...records].map(([id, values]) => ({ id, values })),\n byId,\n )\n return { lookup, records }\n },\n }\n}\n"],"names":["FORMS_MAX_PER_HOST","TRANSFER_ID_FIELD","buildMatchLookup","transferIdField","FieldPath","Timestamp","FORM_SUBMISSIONS_MATCH_KEYS","SUBMISSIONS","FORMS","FORM_SUBMISSION_ANSWER_PREFIX","FORM_SUBMISSIONS_ANSWER_SAMPLE_ROWS","FORM_SUBMISSIONS_OTHER_ANSWERS_MAX","PAGE_ROWS_DEFAULT","PAGE_ROWS_MAX","READ_BY_ID_CHUNK","FORM_SUBMISSION_DETAILS_GROUP","id","label","FORM_SUBMISSION_OTHER_ANSWERS_GROUP","formAnswersGroupId","formId","formSubmissionAnswerFieldId","fieldName","formAnswerTransferType","fieldType","readFormSubmissionsFilter","value","undefined","Array","isArray","Error","filter","key","raw","Object","entries","trim","includes","read","matchesFilter","data","formSubmissionDetailFields","group","type","readOnly","system","aliases","description","formQuestionsOf","declared","displayName","name","fields","field","Boolean","answerField","decl","formSubmissionsCatalog","input","answers","groups","taken","Set","form","forms","scoped","added","has","add","push","others","sampledKeys","slice","length","standard","sampledAnswerKeys","rows","seen","Map","keys","entry","get","count","set","order","size","sort","a","b","map","textOrNull","isoOf","Number","isFinite","Date","toISOString","isNaN","getTime","toDate","call","DETAIL_READERS","_data","createdAt","formName","path","repliedAt","campaignIds","one","routing","rateDegraded","hostId","formRatingQuestions","RATING_TEXT","formSubmissionTransferRow","fieldIds","ratings","rated","row","fieldId","reader","startsWith","answer","test","formSubmissionsCursor","at","seconds","nanoseconds","readQueryCursor","cursor","found","exec","readOffsetCursor","pageSizeOf","options","asked","Math","floor","pageSize","max","min","selectionIds","ids","createFormSubmissionsTransferResource","deps","firestore","site","collection","doc","submissions","filtered","query","where","newestFirst","orderBy","documentId","readByIds","refs","snapshots","getAll","snapshot","exists","readForms","limit","docs","document","localeCompare","readPage","ctx","next","some","from","to","String","after","startAfter","page","last","matchKeys","sample","Promise","all","counted","lookup","requests","byId","request","flatMap","values","records"],"mappings":"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"}
|