@aglyn/plugins-forms 1.0.0-beta.221 → 1.0.0-beta.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/package.json +14 -12
  3. package/src/lib/components/form-detail-card.js +166 -29
  4. package/src/lib/components/form-detail-card.js.map +1 -1
  5. package/src/lib/components/form-submissions-card.component.d.ts +12 -0
  6. package/src/lib/components/form-submissions-card.component.js +35 -2
  7. package/src/lib/components/form-submissions-card.component.js.map +1 -1
  8. package/src/lib/components/form.js +2 -1
  9. package/src/lib/components/form.js.map +1 -1
  10. package/src/lib/components/host-forms-card.component.d.ts +8 -0
  11. package/src/lib/components/host-forms-card.component.js +26 -1
  12. package/src/lib/components/host-forms-card.component.js.map +1 -1
  13. package/src/lib/components/site-package-form-preview.component.d.ts +13 -0
  14. package/src/lib/components/site-package-form-preview.component.js +57 -0
  15. package/src/lib/components/site-package-form-preview.component.js.map +1 -0
  16. package/src/lib/constants/site-package.d.ts +18 -0
  17. package/src/lib/constants/site-package.js +18 -0
  18. package/src/lib/constants/site-package.js.map +1 -0
  19. package/src/lib/declarations.console-server.d.ts +39 -0
  20. package/src/lib/declarations.console-server.js +68 -0
  21. package/src/lib/declarations.console-server.js.map +1 -0
  22. package/src/lib/declarations.server.js +22 -0
  23. package/src/lib/declarations.server.js.map +1 -1
  24. package/src/lib/model/form-door.d.ts +37 -0
  25. package/src/lib/model/form-door.js +50 -0
  26. package/src/lib/model/form-door.js.map +1 -0
  27. package/src/lib/model/form-submission-counts.d.ts +26 -0
  28. package/src/lib/model/form-submission-counts.js +35 -0
  29. package/src/lib/model/form-submission-counts.js.map +1 -0
  30. package/src/lib/model/form-submission-list.d.ts +40 -0
  31. package/src/lib/model/form-submission-list.js +66 -0
  32. package/src/lib/model/form-submission-list.js.map +1 -0
  33. package/src/lib/model/form-submission-record.d.ts +52 -0
  34. package/src/lib/model/form-submission-record.js +70 -0
  35. package/src/lib/model/form-submission-record.js.map +1 -0
  36. package/src/lib/model/form-unavailable.d.ts +83 -0
  37. package/src/lib/model/form-unavailable.js +88 -0
  38. package/src/lib/model/form-unavailable.js.map +1 -0
  39. package/src/lib/model/forms-record-lists.d.ts +31 -0
  40. package/src/lib/model/forms-record-lists.js +55 -0
  41. package/src/lib/model/forms-record-lists.js.map +1 -0
  42. package/src/lib/plan-entitlements.js +6 -6
  43. package/src/lib/plan-entitlements.js.map +1 -1
  44. package/src/lib/plugin.js +34 -0
  45. package/src/lib/plugin.js.map +1 -1
  46. package/src/lib/server/api-v1/form-submissions.d.ts +34 -0
  47. package/src/lib/server/api-v1/form-submissions.js +273 -0
  48. package/src/lib/server/api-v1/form-submissions.js.map +1 -0
  49. package/src/lib/server/api-v1/openapi.d.ts +26 -0
  50. package/src/lib/server/api-v1/openapi.js +133 -0
  51. package/src/lib/server/api-v1/openapi.js.map +1 -0
  52. package/src/lib/server/form-intake-gate.d.ts +34 -0
  53. package/src/lib/server/form-intake-gate.js +44 -0
  54. package/src/lib/server/form-intake-gate.js.map +1 -0
  55. package/src/lib/server/form-submission-index.d.ts +26 -0
  56. package/src/lib/server/form-submission-index.js +51 -0
  57. package/src/lib/server/form-submission-index.js.map +1 -0
  58. package/src/lib/server/form-submit.d.ts +43 -0
  59. package/src/lib/server/form-submit.js +850 -0
  60. package/src/lib/server/form-submit.js.map +1 -0
  61. package/src/lib/server/increment-form-stats.d.ts +70 -0
  62. package/src/lib/server/increment-form-stats.js +112 -0
  63. package/src/lib/server/increment-form-stats.js.map +1 -0
  64. package/src/lib/server.d.ts +10 -0
  65. package/src/lib/server.js +15 -0
  66. package/src/lib/server.js.map +1 -1
  67. package/src/lib/transfer/form-submissions-transfer-key.d.ts +37 -0
  68. package/src/lib/transfer/form-submissions-transfer-key.js +40 -0
  69. package/src/lib/transfer/form-submissions-transfer-key.js.map +1 -0
  70. package/src/lib/transfer/form-submissions-transfer.d.ts +98 -0
  71. package/src/lib/transfer/form-submissions-transfer.js +563 -0
  72. package/src/lib/transfer/form-submissions-transfer.js.map +1 -0
  73. package/src/lib/transfer/form-submissions-transfer.server.d.ts +26 -0
  74. package/src/lib/transfer/form-submissions-transfer.server.js +31 -0
  75. package/src/lib/transfer/form-submissions-transfer.server.js.map +1 -0
@@ -0,0 +1,98 @@
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 FormFieldDecl, type FormFieldType } from '@aglyn/aglyn/app-utils/forms';
18
+ import { type TransferCatalogInput, type TransferField, type TransferFieldGroup, type TransferFieldType } from '@aglyn/aglyn/data-transfer';
19
+ import type { TransferRecordsHooks } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources';
20
+ /** The prefix of an answer's field id; the rest is the form field's `fieldName`. */
21
+ export declare const FORM_SUBMISSION_ANSWER_PREFIX = "answer:";
22
+ /** How many of the most recent submissions the catalog reads for answers today's forms no longer ask. */
23
+ export declare const FORM_SUBMISSIONS_ANSWER_SAMPLE_ROWS = 200;
24
+ /**
25
+ * The most "other answers" columns the catalog offers. A submission's keys
26
+ * come from the visitor's request (up to twenty, each at most 64 characters),
27
+ * so a sample can hold keys no form ever asked; the most frequent are kept.
28
+ */
29
+ export declare const FORM_SUBMISSIONS_OTHER_ANSWERS_MAX = 50;
30
+ /** The group the submission's own details are listed under. */
31
+ export declare const FORM_SUBMISSION_DETAILS_GROUP: TransferFieldGroup;
32
+ /** The group of answers no form declares today. */
33
+ export declare const FORM_SUBMISSION_OTHER_ANSWERS_GROUP: TransferFieldGroup;
34
+ /** A form's questions are listed under `form:<formId>`. */
35
+ export declare function formAnswersGroupId(formId: string): string;
36
+ /** The field id an answer to `fieldName` is exported under. */
37
+ export declare function formSubmissionAnswerFieldId(fieldName: string): string;
38
+ /** What an answer is, from the form field that asked it. */
39
+ export declare function formAnswerTransferType(fieldType: FormFieldType | undefined): TransferFieldType;
40
+ /** The list filter, in this resource's terms: one form, and read or unread. */
41
+ export interface FormSubmissionsFilter {
42
+ formId?: string;
43
+ read?: boolean;
44
+ }
45
+ /**
46
+ * The filter a dialog or a route handed over, or why it cannot be used.
47
+ * Refused rather than ignored: a filter dropped without a word exports more
48
+ * than the person asked for.
49
+ */
50
+ export declare function readFormSubmissionsFilter(value: unknown): FormSubmissionsFilter;
51
+ /** The submission's own details, in the order a person reads them. */
52
+ export declare function formSubmissionDetailFields(): TransferField[];
53
+ /** One form as the catalog reads it: its id, its name, and the questions it declares. */
54
+ export interface FormQuestions {
55
+ id: string;
56
+ name: string;
57
+ fields: readonly FormFieldDecl[];
58
+ }
59
+ /**
60
+ * The catalog, from the forms read and the answer keys sampled (see the
61
+ * block header). `scoped` is true when the dialog was opened on one form.
62
+ */
63
+ export declare function formSubmissionsCatalog(input: {
64
+ forms: readonly FormQuestions[];
65
+ sampledKeys: readonly string[];
66
+ scoped: boolean;
67
+ }): TransferCatalogInput;
68
+ /** The answer keys the sampled submissions carry, most frequent first, then first seen. */
69
+ export declare function sampledAnswerKeys(rows: ReadonlyArray<Readonly<Record<string, unknown>> | undefined>): string[];
70
+ /** Form id → the names of its rating questions, whose answers are numbers. */
71
+ export type FormRatingQuestions = ReadonlyMap<string, ReadonlySet<string>>;
72
+ /** The rating questions of each form (AGL-3548). */
73
+ export declare function formRatingQuestions(forms: readonly FormQuestions[]): Map<string, Set<string>>;
74
+ /**
75
+ * One submission as an export row: only `fieldIds`, keyed by field id. An
76
+ * answer is the value as the visitor sent it — but an answer to a rating
77
+ * question of the submission's form (`ratings`) that reads as a number is
78
+ * that number, so a JSON file holds `4` and a CSV writes it as one, not as
79
+ * text. A question they did not answer, or a field id this resource does
80
+ * not know, is `null`.
81
+ */
82
+ export declare function formSubmissionTransferRow(id: string, data: Readonly<Record<string, unknown>>, fieldIds: readonly string[], ratings?: FormRatingQuestions): Record<string, unknown>;
83
+ /**
84
+ * Where the next page of a query starts: the last row's `createdAt` (seconds
85
+ * and nanoseconds, so two submissions a microsecond apart stay apart) and
86
+ * its id. A selection's cursor is the offset into the ids.
87
+ */
88
+ export declare function formSubmissionsCursor(createdAt: unknown, id: string): string;
89
+ export interface FormSubmissionsTransferDeps {
90
+ firestore: FirebaseFirestore.Firestore;
91
+ }
92
+ /** The hooks an export-only records resource answers with: no apply or revert. */
93
+ export type FormSubmissionsTransferResource = Required<Pick<TransferRecordsHooks, 'matchKeys' | 'fields' | 'count' | 'readPage' | 'lookup'>>;
94
+ /**
95
+ * The form submissions resource's server half (see the block header).
96
+ * Firestore is handed in, so a spec runs it over an in-memory one.
97
+ */
98
+ export declare function createFormSubmissionsTransferResource(deps: FormSubmissionsTransferDeps): FormSubmissionsTransferResource;
@@ -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