@nextlyhq/plugin-form-builder 0.0.2-alpha.75 → 0.0.2-alpha.76

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/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { $ as SCHEMA_ONLY_READ, A as applyFieldDefaults, B as downloadFile, C as isKnownFormField, D as normalizeFormSettings, E as fieldsHiddenByCondition, F as warnIfWebhooksConfigured, G as generateExportFilename, H as exportToCSV, J as getSupportedComparisonOperators, K as evaluateConditions, M as createFormConfig, N as pluralize, O as DEFAULT_FORM_SETTINGS, P as toTitleCase, Q as SCHEMA_ONLY_CONTEXT, R as formsEmailTemplates, S as isFormFieldType, T as isTextFormField, U as exportToJSON, V as exportAndDownload, W as formatExportValue, X as NEW_SUBMISSIONS_WIDGET_ID, Y as isValidComparisonOperator, Z as readNewSubmissions, _ as BUILT_IN_FORM_FIELD_TYPES, at as addressedFormPages, b as isFileFormField, d as generateZodSchema, et as documentReachability, f as getValidationErrors, h as validateFormData, j as applyFormDefaults, k as DEFAULT_NOTIFICATION_SETTINGS, m as transformFormData, nt as pickedDocumentField, p as getValidationIssues, q as evaluateSingleCondition, r as parseFieldRef, rt as FORM_EDITOR_VIEW, tt as parseRedirectReference, u as oneResponseFieldProblem, v as isCheckboxFormField, w as isSelectFormField, x as isFormField, y as isEmailFormField } from "./field-references-oCuFEwkr.js";
1
+ import { $ as applyFieldDefaults, A as getValidationIssues, At as fieldsHiddenByCondition, B as isKnownFormField, Bt as pickedDocumentField, Ct as isConsentAnswer, D as oneResponseFieldProblem, Et as askedAfterEdit, F as isCheckboxFormField, Ft as readNewSubmissions, G as latestByRecipient, H as isTextFormField, I as isEmailFormField, It as SCHEMA_ONLY_CONTEXT, L as isFileFormField, Lt as SCHEMA_ONLY_READ, M as validateFormData, Mt as getSupportedComparisonOperators, Nt as isValidComparisonOperator, O as generateZodSchema, Ot as askedSnapshotOf, P as BUILT_IN_FORM_FIELD_TYPES, Pt as NEW_SUBMISSIONS_WIDGET_ID, Q as DEFAULT_NOTIFICATION_SETTINGS, R as isFormField, Rt as documentReachability, S as pageFromReferer, St as editedConsents, Tt as storedConsentNames, U as addressableNow, Ut as addressedFormPages, V as isSelectFormField, Vt as FORM_EDITOR_VIEW, X as normalizeFormSettings, Y as sendInProgress, Z as DEFAULT_FORM_SETTINGS, _ as listWhere, _t as CONSENT_EDIT_REFUSED, a as parseFieldRef, b as scopeFromSearch, bt as blankConsentFields, c as attachFieldsOf, d as conditionsOf, dt as exportAndDownload, et as applyFormDefaults, f as fieldsMarkedToAttach, ft as exportToCSV, ht as generateExportFilename, it as warnIfWebhooksConfigured, j as transformFormData, jt as evaluateConditions, k as getValidationErrors, kt as readAskedSnapshot, l as attachmentsFrom, m as teamAddressesOf, mt as formatExportValue, n as submissionTitleOf, nt as pluralize, pt as exportToJSON, q as reachedExcept, rt as toTitleCase, st as formsEmailTemplates, tt as createFormConfig, u as conditionsMet, ut as downloadFile, vt as CONSENT_TEXT_CHANGED, wt as staleConsentNames, yt as CONSENT_TEXT_REQUIRED, z as isFormFieldType, zt as parseRedirectReference } from "./submission-title-CVixn15Z.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash } from "node:crypto";
4
- import { MAX_WIDGET_ROWS, NextlyError, defineJob, definePlugin, defineWidget, isLocaleSelector, listEntries, setting } from "@nextlyhq/plugin-sdk";
5
- import { NO_SUCH_FORM, NextlyError as NextlyError$1, RateLimiter, buildPaginatedResponse, checkbox as checkbox$1, clampLimit, date as date$1, formAvailability, group, isPluginFieldTypeOnSurface, json, number as number$1, relationship, resolveRateLimitStore, respondAction, respondDoc, respondList, select as select$1, stripHtmlTags, text as text$1, textarea as textarea$1, trustedClientIp } from "nextly";
4
+ import { MAX_WIDGET_ROWS, NextlyError, date as date$1, defineJob, definePlugin, defineWidget, isLocaleSelector, json, listEntries, number as number$1, respondAction, respondList, respondMutation, setting, text as text$1, trustedClientIp } from "@nextlyhq/plugin-sdk";
5
+ import { NO_SUCH_FORM, NextlyError as NextlyError$1, RateLimiter, buildPaginatedResponse, checkbox as checkbox$1, clampLimit, date as date$2, formAvailability, group, isPluginFieldTypeOnSurface, json as json$1, number as number$2, relationship, resolveRateLimitStore, respondAction as respondAction$1, respondDoc, respondList as respondList$1, select as select$1, stripHtmlTags, text as text$2, textarea as textarea$1, trustedClientIp as trustedClientIp$1 } from "nextly";
6
6
  import { previewUrlFromTemplate } from "nextly/config";
7
7
  import { z } from "zod";
8
8
 
@@ -495,7 +495,7 @@ function formsCollection(pluginConfig) {
495
495
  const { slug, labels, fields: additionalFields, access: accessOverrides, hooks: hookOverrides, admin: adminOverrides, ...overrides } = pluginConfig.formOverrides;
496
496
  const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
497
497
  const settingsFields = [
498
- text$1({
498
+ text$2({
499
499
  name: "submitButtonText",
500
500
  label: "Submit Button Text",
501
501
  defaultValue: "Submit",
@@ -533,7 +533,7 @@ function formsCollection(pluginConfig) {
533
533
  }
534
534
  }
535
535
  }),
536
- text$1({
536
+ text$2({
537
537
  name: "redirectUrl",
538
538
  label: "Redirect URL",
539
539
  admin: {
@@ -582,15 +582,15 @@ function formsCollection(pluginConfig) {
582
582
  value: "ip"
583
583
  }
584
584
  ]
585
- }), text$1({
585
+ }), text$2({
586
586
  name: "oneResponseField",
587
587
  label: "Email field for one response per person"
588
- }), number$1({
588
+ }), number$2({
589
589
  name: "oneResponseVersion",
590
590
  label: "One-response version",
591
591
  defaultValue: 0,
592
592
  admin: { hidden: true }
593
- }), number$1({
593
+ }), number$2({
594
594
  name: "oneResponseKeyedVersion",
595
595
  label: "One-response keys made for version",
596
596
  admin: { hidden: true }
@@ -623,10 +623,10 @@ function formsCollection(pluginConfig) {
623
623
  value: "turnstile"
624
624
  }
625
625
  ]
626
- }), date$1({
626
+ }), date$2({
627
627
  name: "closesAt",
628
628
  label: "Closes at"
629
- }), number$1({
629
+ }), number$2({
630
630
  name: "submissionLimit",
631
631
  label: "Submission limit",
632
632
  min: 1
@@ -634,7 +634,7 @@ function formsCollection(pluginConfig) {
634
634
  name: "storeIpAndBrowser",
635
635
  label: "Store IP address and browser",
636
636
  defaultValue: true
637
- }), number$1({
637
+ }), number$2({
638
638
  name: "keepDays",
639
639
  label: "Keep submissions for (days)",
640
640
  min: 1
@@ -642,7 +642,7 @@ function formsCollection(pluginConfig) {
642
642
  name: "internalNote",
643
643
  label: "Internal note",
644
644
  access: { read: readableByFormEditors(slug) }
645
- }), text$1({
645
+ }), text$2({
646
646
  name: "captchaSiteKey",
647
647
  label: "reCAPTCHA Site Key",
648
648
  admin: {
@@ -654,13 +654,13 @@ function formsCollection(pluginConfig) {
654
654
  }
655
655
  }));
656
656
  const defaultFields = [
657
- text$1({
657
+ text$2({
658
658
  name: "name",
659
659
  label: "Form Name",
660
660
  required: true,
661
661
  admin: { description: "Internal name for this form (not shown to users)" }
662
662
  }),
663
- text$1({
663
+ text$2({
664
664
  name: "slug",
665
665
  label: "Slug",
666
666
  required: true,
@@ -672,7 +672,7 @@ function formsCollection(pluginConfig) {
672
672
  label: "Description",
673
673
  admin: { description: "Internal notes about this form's purpose" }
674
674
  }),
675
- json({
675
+ json$1({
676
676
  name: "fields",
677
677
  label: "Form Fields",
678
678
  required: true,
@@ -684,7 +684,7 @@ function formsCollection(pluginConfig) {
684
684
  label: "Form Settings",
685
685
  fields: settingsFields
686
686
  }),
687
- json({
687
+ json$1({
688
688
  name: "notifications",
689
689
  label: "Notifications",
690
690
  defaultValue: [],
@@ -712,7 +712,7 @@ function formsCollection(pluginConfig) {
712
712
  ],
713
713
  admin: { description: "Only published forms can receive submissions. Closed forms display a message instead." }
714
714
  }),
715
- date$1({
715
+ date$2({
716
716
  name: "wentLiveAt",
717
717
  label: "First Published",
718
718
  admin: {
@@ -772,6 +772,143 @@ function formsCollection(pluginConfig) {
772
772
  };
773
773
  }
774
774
 
775
+ // src/collections/sends.ts
776
+ /**
777
+ * Form Submission Sends collection
778
+ *
779
+ * The notifications each submission queued: the queued email's id and the
780
+ * idempotency key it was sent with. Kept so every path that deletes a
781
+ * submission can forget what it sent (`ctx.services.email.forgetSends`) in
782
+ * the same transaction, cancelling a notification not yet sent and erasing
783
+ * the delivery records of one that was. A notification's key is made from
784
+ * the notification as it was when it was sent, so it cannot be worked out
785
+ * again at deletion: it is recorded here.
786
+ *
787
+ * Each also keeps what became of the send, for as long as its submission
788
+ * exists: where it stands, how many attempts it took, when it last changed,
789
+ * why it was cancelled, and each recipient it went to (by a masked hint and
790
+ * the plugin's keyed digest, never the address) with what became of it.
791
+ * These are written in the transaction of each change of the queued email
792
+ * (`handlers/send-history`), so they cannot fall behind the send, and they
793
+ * outlive the delivery log, which core prunes. Written as each change
794
+ * happens, they do not change when the submission's answers or the
795
+ * notification are edited later. A send made by Resend names who asked.
796
+ *
797
+ * Internal: never in the admin's navigation, records no webhook events, and
798
+ * no caller reaches it; only this plugin, acting as itself, writes and reads
799
+ * it.
800
+ *
801
+ * @module collections/sends
802
+ */
803
+ /** The slug the sends are kept under. */
804
+ const SUBMISSION_SENDS_SLUG = "form-submission-sends";
805
+ /** The slug Resend's requests are kept under. */
806
+ const SUBMISSION_RESENDS_SLUG = "form-submission-resends";
807
+ /**
808
+ * The sends collection's slug as this install has it: a host may rename a
809
+ * collection the plugin contributes, and the services know only the name it
810
+ * was given.
811
+ */
812
+ function sendsSlugOf(ctx) {
813
+ return ctx.self.collections["form-submission-sends"] ?? "form-submission-sends";
814
+ }
815
+ /** The resends collection's slug as this install has it, as above. */
816
+ function resendsSlugOf(ctx) {
817
+ return ctx.self.collections[SUBMISSION_RESENDS_SLUG] ?? SUBMISSION_RESENDS_SLUG;
818
+ }
819
+ /** Nobody but the plugin itself, which acts as the system and skips this. */
820
+ const nobody$1 = () => false;
821
+ function submissionSendsCollection() {
822
+ return {
823
+ slug: SUBMISSION_SENDS_SLUG,
824
+ labels: {
825
+ singular: "Submission send",
826
+ plural: "Submission sends"
827
+ },
828
+ internal: true,
829
+ webhooks: false,
830
+ access: {
831
+ read: nobody$1,
832
+ create: nobody$1,
833
+ update: nobody$1,
834
+ delete: nobody$1
835
+ },
836
+ fields: [
837
+ text$1({
838
+ name: "submission",
839
+ required: true
840
+ }),
841
+ text$1({
842
+ name: "queuedId",
843
+ required: true
844
+ }),
845
+ text$1({
846
+ name: "sendKey",
847
+ required: true
848
+ }),
849
+ text$1({ name: "form" }),
850
+ text$1({ name: "notification" }),
851
+ text$1({ name: "notificationName" }),
852
+ text$1({ name: "state" }),
853
+ number$1({ name: "attempts" }),
854
+ number$1({ name: "retries" }),
855
+ date$1({ name: "changedAt" }),
856
+ json({ name: "cancel" }),
857
+ json({ name: "cancelRequested" }),
858
+ text$1({ name: "retiredBy" }),
859
+ json({ name: "configured" }),
860
+ json({ name: "recipients" }),
861
+ text$1({ name: "requestedBy" }),
862
+ text$1({ name: "resend" })
863
+ ],
864
+ indexes: [
865
+ { fields: ["submission"] },
866
+ { fields: ["queuedId"] },
867
+ { fields: ["form", "notification"] }
868
+ ]
869
+ };
870
+ }
871
+ /**
872
+ * Resend's requests: who asked to send a notification of a submission
873
+ * again, and which recipients they ticked. A request is stored with the
874
+ * send it makes, in one transaction (`handlers/resend`), so a request that
875
+ * could not be sent leaves no trace, and is deleted with its submission.
876
+ * Internal, as the sends are.
877
+ */
878
+ function submissionResendsCollection() {
879
+ return {
880
+ slug: SUBMISSION_RESENDS_SLUG,
881
+ labels: {
882
+ singular: "Submission resend",
883
+ plural: "Submission resends"
884
+ },
885
+ internal: true,
886
+ webhooks: false,
887
+ access: {
888
+ read: nobody$1,
889
+ create: nobody$1,
890
+ update: nobody$1,
891
+ delete: nobody$1
892
+ },
893
+ fields: [
894
+ text$1({
895
+ name: "submission",
896
+ required: true
897
+ }),
898
+ text$1({
899
+ name: "notification",
900
+ required: true
901
+ }),
902
+ text$1({ name: "requestedBy" }),
903
+ json({ name: "ticked" }),
904
+ json({ name: "exclude" }),
905
+ json({ name: "retire" }),
906
+ json({ name: "settled" })
907
+ ],
908
+ indexes: [{ fields: ["submission"] }]
909
+ };
910
+ }
911
+
775
912
  // src/collections/submissions.ts
776
913
  /**
777
914
  * Whether a create keeps the `submittedAt` it names: only one made on the
@@ -796,28 +933,12 @@ function sameIndex(index, other) {
796
933
  return index.fields.join("\0") === other.fields.join("\0") && (index.unique ?? false) === (other.unique ?? false);
797
934
  }
798
935
  /**
799
- * Generate the Form Submissions collection configuration.
800
- *
801
- * Creates a collection for storing form submissions with:
802
- * - Reference to the parent form
803
- * - Submitted data (JSON)
804
- * - Status tracking (new, read, archived)
805
- * - Metadata (IP, user agent, timestamp)
806
- * - Internal notes for admin use
807
- *
808
- * @param pluginConfig - Resolved plugin configuration
809
- * @returns CollectionConfig for the Submissions collection
810
- *
811
- * @example
812
- * ```typescript
813
- * const submissionsCol = submissionsCollection(resolvedConfig);
814
- * // Returns a collection with slug "form-submissions" (or custom slug from config)
815
- * ```
936
+ * The fields the plugin itself gives every submission, whatever a host adds
937
+ * through `formSubmissionOverrides.fields`. One list, read by the collection
938
+ * and by what tells the admin which fields are the host's own.
816
939
  */
817
- function submissionsCollection(pluginConfig) {
818
- const { slug, labels, fields: additionalFields, access: accessOverrides, ...overrides } = pluginConfig.formSubmissionOverrides;
819
- const formSlug = pluginConfig.formOverrides.slug;
820
- const defaultFields = [
940
+ function builtInSubmissionFields(formSlug) {
941
+ return [
821
942
  relationship({
822
943
  name: "form",
823
944
  label: "Form",
@@ -828,7 +949,7 @@ function submissionsCollection(pluginConfig) {
828
949
  description: "The form this submission belongs to"
829
950
  }
830
951
  }),
831
- json({
952
+ json$1({
832
953
  name: "data",
833
954
  label: "Submission Data",
834
955
  required: true,
@@ -863,7 +984,7 @@ function submissionsCollection(pluginConfig) {
863
984
  index: true,
864
985
  admin: { description: "Track whether this submission has been reviewed" }
865
986
  }),
866
- text$1({
987
+ text$2({
867
988
  name: "spamReason",
868
989
  label: "Spam Reason",
869
990
  admin: {
@@ -876,7 +997,7 @@ function submissionsCollection(pluginConfig) {
876
997
  label: "Internal Notes",
877
998
  admin: { description: "Notes visible only to admins (not shown to submitter)" }
878
999
  }),
879
- text$1({
1000
+ text$2({
880
1001
  name: "ipAddress",
881
1002
  label: "IP Address",
882
1003
  admin: {
@@ -884,7 +1005,7 @@ function submissionsCollection(pluginConfig) {
884
1005
  description: "IP address of the submitter"
885
1006
  }
886
1007
  }),
887
- text$1({
1008
+ text$2({
888
1009
  name: "userAgent",
889
1010
  label: "User Agent",
890
1011
  admin: {
@@ -892,7 +1013,12 @@ function submissionsCollection(pluginConfig) {
892
1013
  description: "Browser/device information of the submitter"
893
1014
  }
894
1015
  }),
895
- date$1({
1016
+ textarea$1({
1017
+ name: "page",
1018
+ label: "Page",
1019
+ admin: { hidden: true }
1020
+ }),
1021
+ date$2({
896
1022
  name: "submittedAt",
897
1023
  label: "Submitted At",
898
1024
  required: true,
@@ -902,7 +1028,7 @@ function submissionsCollection(pluginConfig) {
902
1028
  description: "When the form was submitted"
903
1029
  }
904
1030
  }),
905
- date$1({
1031
+ date$2({
906
1032
  name: "editedAt",
907
1033
  label: "Edited At",
908
1034
  admin: {
@@ -910,7 +1036,7 @@ function submissionsCollection(pluginConfig) {
910
1036
  description: "When an admin last edited the submitted data"
911
1037
  }
912
1038
  }),
913
- text$1({
1039
+ text$2({
914
1040
  name: "editedBy",
915
1041
  label: "Edited By",
916
1042
  admin: {
@@ -918,20 +1044,84 @@ function submissionsCollection(pluginConfig) {
918
1044
  description: "ID of the admin who last edited the submitted data"
919
1045
  }
920
1046
  }),
921
- text$1({
1047
+ text$2({
922
1048
  name: "responseKey",
923
1049
  label: "One-response key",
924
1050
  admin: { hidden: true }
1051
+ }),
1052
+ json$1({
1053
+ name: "asked",
1054
+ label: "Fields asked",
1055
+ admin: { hidden: true }
1056
+ }),
1057
+ text$2({
1058
+ name: "title",
1059
+ label: "Sender",
1060
+ admin: { hidden: true }
925
1061
  })
926
1062
  ];
927
- let finalFields;
928
- if (Array.isArray(additionalFields) && additionalFields.length > 0) finalFields = [...defaultFields, ...additionalFields];
929
- else finalFields = defaultFields;
1063
+ }
1064
+ /**
1065
+ * The fields a host added to the submissions collection: every named
1066
+ * top-level field that is not one of the plugin's own, and not one the host
1067
+ * hid from the admin. The submission page shows them, since it replaces the
1068
+ * default editor that would have drawn them.
1069
+ */
1070
+ function submissionExtensionFields(pluginConfig) {
1071
+ const builtIn = new Set(builtInSubmissionFields(pluginConfig.formOverrides.slug).map(fieldName));
1072
+ return submissionsCollection(pluginConfig).fields.filter((field) => !hiddenFromAdmin(field)).map((field) => ({
1073
+ name: fieldName(field),
1074
+ label: fieldLabel(field),
1075
+ type: field.type
1076
+ })).filter((field) => field.name !== "" && !builtIn.has(field.name));
1077
+ }
1078
+ /**
1079
+ * Whether the page leaves a field out: one configured to stay out of the
1080
+ * admin; one shown only when a condition holds, which the page does not
1081
+ * judge; or one holding fields of its own (a group, an array, blocks), whose
1082
+ * value the page would show whole, hidden descendants included.
1083
+ */
1084
+ function hiddenFromAdmin(field) {
1085
+ const shape = field;
1086
+ return shape.admin?.hidden === true || shape.admin?.condition !== void 0 || shape.fields !== void 0 || shape.blocks !== void 0;
1087
+ }
1088
+ /** A field's name, or "" for a layout field that has none. */
1089
+ function fieldName(field) {
1090
+ return "name" in field && typeof field.name === "string" ? field.name : "";
1091
+ }
1092
+ /** A field's label as the page shows it: its own, else its name. */
1093
+ function fieldLabel(field) {
1094
+ const label = field.label;
1095
+ return typeof label === "string" && label !== "" ? label : fieldName(field);
1096
+ }
1097
+ /**
1098
+ * Generate the Form Submissions collection configuration.
1099
+ *
1100
+ * Creates a collection for storing form submissions with:
1101
+ * - Reference to the parent form
1102
+ * - Submitted data (JSON)
1103
+ * - Status tracking (new, read, archived)
1104
+ * - Metadata (IP, user agent, timestamp)
1105
+ * - Internal notes for admin use
1106
+ *
1107
+ * @param pluginConfig - Resolved plugin configuration
1108
+ * @returns CollectionConfig for the Submissions collection
1109
+ *
1110
+ * @example
1111
+ * ```typescript
1112
+ * const submissionsCol = submissionsCollection(resolvedConfig);
1113
+ * // Returns a collection with slug "form-submissions" (or custom slug from config)
1114
+ * ```
1115
+ */
1116
+ function submissionsCollection(pluginConfig) {
1117
+ const { slug, labels, fields: additionalFields, access: accessOverrides, ...overrides } = pluginConfig.formSubmissionOverrides;
1118
+ const formSlug = pluginConfig.formOverrides.slug;
1119
+ const submissionFields = [...builtInSubmissionFields(formSlug), ...Array.isArray(additionalFields) ? additionalFields : []];
930
1120
  return {
931
1121
  slug,
932
1122
  dbName: slug.replace(/-/g, "_"),
933
1123
  labels,
934
- fields: finalFields,
1124
+ fields: submissionFields,
935
1125
  timestamps: true,
936
1126
  webhooks: {
937
1127
  record: false,
@@ -948,10 +1138,13 @@ function submissionsCollection(pluginConfig) {
948
1138
  isPlugin: true,
949
1139
  group: "Forms",
950
1140
  order: 51,
951
- useAsTitle: "id",
1141
+ useAsTitle: "title",
952
1142
  description: "View and manage form submissions",
953
1143
  disableCreate: true,
954
- components: { views: { List: { Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView" } } },
1144
+ components: { views: {
1145
+ List: { Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView" },
1146
+ Edit: { Component: "@nextlyhq/plugin-form-builder/admin#SubmissionPage" }
1147
+ } },
955
1148
  ...overrides.admin
956
1149
  },
957
1150
  access: accessWithDefaults(accessOverrides, { create: true }),
@@ -971,6 +1164,81 @@ function submissionsCollection(pluginConfig) {
971
1164
  };
972
1165
  }
973
1166
 
1167
+ // src/collections/uploads.ts
1168
+ /**
1169
+ * Form Uploads collection
1170
+ *
1171
+ * Every file a visitor uploaded through a form's upload route
1172
+ * (`POST /api/forms/:slug/upload`): the media item, the form and file field
1173
+ * it was uploaded for, and the submission that claimed it, if one has. A
1174
+ * submission a trusted write stored naming a file another already holds is
1175
+ * recorded beside it, one row per holder, so the file stays while any of
1176
+ * them is left. A
1177
+ * file answer in a submission a request writes may only name an upload
1178
+ * recorded here for the same form and field that no other submission has
1179
+ * claimed, so the files a submission holds are known to be its own and can
1180
+ * be deleted with it.
1181
+ *
1182
+ * `releasedAt` is set, in the deleting transaction, when the submission
1183
+ * holding an upload is deleted (or, for one never claimed, when it is given
1184
+ * up): from then on no submission may claim it, and once the deletion has
1185
+ * committed its file is deleted, unless something else uses it, and the row
1186
+ * with it. A row whose file could not be deleted yet stays released, and the
1187
+ * nightly retention pass tries again.
1188
+ *
1189
+ * Internal: never in the admin's navigation, records no webhook events, and
1190
+ * no caller reaches it; only this plugin, acting as itself, writes and reads
1191
+ * it.
1192
+ *
1193
+ * @module collections/uploads
1194
+ */
1195
+ /** The slug the uploads are kept under. */
1196
+ const FORM_UPLOADS_SLUG = "form-uploads";
1197
+ /** Nobody but the plugin itself, which acts as the system and skips this. */
1198
+ const nobody = () => false;
1199
+ function formUploadsCollection() {
1200
+ return {
1201
+ slug: FORM_UPLOADS_SLUG,
1202
+ labels: {
1203
+ singular: "Form upload",
1204
+ plural: "Form uploads"
1205
+ },
1206
+ internal: true,
1207
+ webhooks: false,
1208
+ timestamps: true,
1209
+ access: {
1210
+ read: nobody,
1211
+ create: nobody,
1212
+ update: nobody,
1213
+ delete: nobody
1214
+ },
1215
+ fields: [
1216
+ text$1({
1217
+ name: "media",
1218
+ required: true
1219
+ }),
1220
+ text$1({
1221
+ name: "form",
1222
+ required: true
1223
+ }),
1224
+ text$1({
1225
+ name: "field",
1226
+ required: true
1227
+ }),
1228
+ text$1({ name: "submission" }),
1229
+ date$1({ name: "releasedAt" })
1230
+ ],
1231
+ indexes: [
1232
+ {
1233
+ fields: ["media", "submission"],
1234
+ unique: true
1235
+ },
1236
+ { fields: ["submission"] },
1237
+ { fields: ["form"] }
1238
+ ]
1239
+ };
1240
+ }
1241
+
974
1242
  // src/dashboard/widget.ts
975
1243
  /**
976
1244
  * New form submissions: the form-builder plugin's dashboard widget, declared
@@ -1192,8 +1460,8 @@ async function sendVerification(fetch, provider, request) {
1192
1460
  * recoverable. A provider that cannot be reached leaves the submission
1193
1461
  * unchecked, and says so in the log: an outage must not send every real
1194
1462
  * submission to spam. A form whose check has no secret configured is
1195
- * accepted unchecked, as before bot checks were verified, with a warning at
1196
- * start and in Settings.
1463
+ * accepted unchecked, as before bot checks were verified, with a warning in
1464
+ * the log (see `warnUncheckedSiteDefault`) and in Settings.
1197
1465
  *
1198
1466
  * @module handlers/bot-check
1199
1467
  */
@@ -1338,15 +1606,18 @@ function botTokensOf(payload) {
1338
1606
  }
1339
1607
  /**
1340
1608
  * A request-made submission's bot check: none when its form's check is off,
1341
- * or names a provider missing its secret or site key (accepted unchecked, as
1342
- * warned at start and in Settings); a spam verdict when the token fails or
1343
- * is missing.
1609
+ * or names a provider missing its secret or site key (accepted unchecked,
1610
+ * and said in the log the first time, see `warnUncheckedSubmission`); a spam
1611
+ * verdict when the token fails or is missing.
1344
1612
  */
1345
1613
  async function judgeBotCheck(input) {
1346
1614
  const provider = botProviderOf(input.settings.botCheck, input.spam);
1347
1615
  if (provider === void 0) return void 0;
1348
1616
  const keys = readyKeys(provider, input.spam, input.settings);
1349
- if (keys === void 0) return void 0;
1617
+ if (keys === void 0) {
1618
+ warnUncheckedSubmission(provider, input.spam, input.form, input.warn);
1619
+ return;
1620
+ }
1350
1621
  const verdict = await judgeBotToken({
1351
1622
  provider,
1352
1623
  secret: keys.secret,
@@ -1361,30 +1632,76 @@ async function judgeBotCheck(input) {
1361
1632
  reason: provider
1362
1633
  } : void 0;
1363
1634
  }
1635
+ /** What the log is told about `provider` missing a key, naming the options. */
1636
+ function uncheckedMessage(provider) {
1637
+ return `Form Builder: the ${provider === "recaptcha" ? "reCAPTCHA" : "Turnstile"} bot check is in use without both of its keys, so its submissions are accepted unchecked. Set spamProtection.${provider}.secretKey and spamProtection.${provider}.siteKey in the plugin's options.`;
1638
+ }
1364
1639
  /**
1365
- * Warn, once at start, for each bot check that is in use (the site default,
1366
- * or named by a form) without both of its keys, naming the options: those
1367
- * forms' submissions are accepted unchecked until it is. Reads at most the
1368
- * first 500 forms; a failure to read them is not a reason to stop the start.
1640
+ * The providers a submission has already been warned about since the
1641
+ * plugin's last start, by the plugin's bot-check options. Keyed by the
1642
+ * options object, which each `formBuilder()` call resolves once, so two
1643
+ * installs of the plugin in one process keep apart, and an options object
1644
+ * no longer used is let go.
1369
1645
  */
1370
- async function warnUncheckedBotChecks(input) {
1371
- let forms = [];
1372
- try {
1373
- forms = await input.forms();
1374
- } catch {
1375
- forms = [];
1376
- }
1377
- const counts = /* @__PURE__ */ new Map();
1378
- for (const form of forms) {
1379
- const settings = input.readSettings(form.settings);
1380
- const provider = botProviderOf(settings.botCheck, input.spam);
1381
- if (provider && readyKeys(provider, input.spam, settings) === void 0) counts.set(provider, (counts.get(provider) ?? 0) + 1);
1646
+ const saidForSubmissions = /* @__PURE__ */ new WeakMap();
1647
+ /**
1648
+ * At start, from the plugin's options alone: the site default's check, when
1649
+ * it is in use without both of its keys. Every form left on "Site default"
1650
+ * is accepted unchecked until it has them.
1651
+ *
1652
+ * A form that names a check of its own is not read for here: reading the
1653
+ * forms cost every start a round trip to say something that matters only
1654
+ * when such a form is saved (`warnUncheckedFormCheck`) or submitted
1655
+ * (`judgeBotCheck`), which is where it is said instead. A start also
1656
+ * forgets which providers submissions were warned about, so a restart or a
1657
+ * reload says it again, as the warning at start always did.
1658
+ */
1659
+ function warnUncheckedSiteDefault(spam, warn) {
1660
+ saidForSubmissions.set(spam, /* @__PURE__ */ new Set());
1661
+ const provider = botProviderOf("default", spam);
1662
+ if (provider && readyKeys(provider, spam, {}) === void 0) warn(uncheckedMessage(provider), { provider });
1663
+ }
1664
+ /**
1665
+ * When a form is saved: the check it names itself, when that check has not
1666
+ * both of its keys. Said at every such save, since the person saving it is
1667
+ * the one who can act; a form on "Site default" or "Off" is not, the site
1668
+ * default's having been said at start.
1669
+ */
1670
+ function warnUncheckedFormCheck(settings, spam, form, warn) {
1671
+ const named = settings.botCheck;
1672
+ if (named !== "recaptcha" && named !== "turnstile") return;
1673
+ if (readyKeys(named, spam, settings) === void 0) warn(uncheckedMessage(named), {
1674
+ provider: named,
1675
+ ...form ? { form } : {}
1676
+ });
1677
+ }
1678
+ /**
1679
+ * `warnUncheckedFormCheck` for a forms save, as its `beforeChange` hook is
1680
+ * handed it: only a save that carries settings chose a check, and the form
1681
+ * is named by the slug it is saved with, or the one it had.
1682
+ */
1683
+ function warnSavedFormCheck(context, spam, warn) {
1684
+ const { data, originalData } = context;
1685
+ if (data?.settings === void 0) return;
1686
+ const slug = data.slug ?? originalData?.slug;
1687
+ warnUncheckedFormCheck(normalizeFormSettings(data.settings), spam, typeof slug === "string" ? slug : void 0, warn);
1688
+ }
1689
+ /**
1690
+ * When a submission is accepted unchecked because its check is missing a
1691
+ * key: said for the first such submission of each provider since the
1692
+ * plugin's start, naming its form, and not again for every one after it.
1693
+ */
1694
+ function warnUncheckedSubmission(provider, spam, form, warn) {
1695
+ let said = saidForSubmissions.get(spam);
1696
+ if (said === void 0) {
1697
+ said = /* @__PURE__ */ new Set();
1698
+ saidForSubmissions.set(spam, said);
1382
1699
  }
1383
- const siteDefault = botProviderOf("default", input.spam);
1384
- if (siteDefault && readyKeys(siteDefault, input.spam, {}) === void 0) counts.set(siteDefault, counts.get(siteDefault) ?? 0);
1385
- for (const [provider, forms] of counts) input.warn(`Form Builder: the ${provider === "recaptcha" ? "reCAPTCHA" : "Turnstile"} bot check is in use without both of its keys, so its submissions are accepted unchecked. Set spamProtection.${provider}.secretKey and spamProtection.${provider}.siteKey in the plugin's options.`, {
1700
+ if (said.has(provider)) return;
1701
+ said.add(provider);
1702
+ warn(uncheckedMessage(provider), {
1386
1703
  provider,
1387
- forms
1704
+ ...form ? { form } : {}
1388
1705
  });
1389
1706
  }
1390
1707
  /** {@link BotCheckDefaults} for the admin: whether keys are set, never the keys. */
@@ -1420,6 +1737,507 @@ function publicBotCheck(storedSettings, spam) {
1420
1737
  } : void 0;
1421
1738
  }
1422
1739
 
1740
+ // src/handlers/consent-text-check.ts
1741
+ /**
1742
+ * The save of a form refuses a consent field with no text to agree to. The
1743
+ * builder refuses the same before it sends; this holds it for every other way
1744
+ * a form is written (the API, an import), since a consent with a blank text
1745
+ * would record agreement to nothing.
1746
+ *
1747
+ * @module handlers/consent-text-check
1748
+ */
1749
+ /** The saved fields as written: a list, or a JSON string of one. */
1750
+ function fieldsOf(value) {
1751
+ let decoded = value;
1752
+ if (typeof value === "string") try {
1753
+ decoded = JSON.parse(value);
1754
+ } catch {
1755
+ return [];
1756
+ }
1757
+ return Array.isArray(decoded) ? decoded : [];
1758
+ }
1759
+ /** Refuse a form save holding a consent field with a blank agreement text. */
1760
+ function refuseBlankConsentText(context) {
1761
+ const fields = context.data?.fields;
1762
+ if (fields === void 0) return;
1763
+ const blank = blankConsentFields(fieldsOf(fields));
1764
+ if (blank.length === 0) return;
1765
+ throw NextlyError.validation({ errors: blank.map((field) => ({
1766
+ path: `fields.${field.name}.agreementText`,
1767
+ code: "REQUIRED",
1768
+ message: CONSENT_TEXT_REQUIRED
1769
+ })) });
1770
+ }
1771
+
1772
+ // src/handlers/send-views.ts
1773
+ const RECIPIENT = z.object({
1774
+ digest: z.string(),
1775
+ hint: z.string(),
1776
+ kind: z.enum([
1777
+ "to",
1778
+ "cc",
1779
+ "bcc"
1780
+ ]),
1781
+ source: z.enum([
1782
+ "team",
1783
+ "visitor",
1784
+ "cc",
1785
+ "bcc",
1786
+ "added"
1787
+ ]).catch("added"),
1788
+ outcome: z.enum([
1789
+ "accepted",
1790
+ "refused",
1791
+ "unknown",
1792
+ "skipped"
1793
+ ]).optional(),
1794
+ retries: z.number().int().optional()
1795
+ });
1796
+ const CAUSE = z.union([z.object({
1797
+ by: z.literal("person"),
1798
+ userId: z.string().nullable()
1799
+ }), z.object({ by: z.enum([
1800
+ "spam",
1801
+ "erasure",
1802
+ "sender",
1803
+ "request"
1804
+ ]) })]);
1805
+ const STATE = z.enum([
1806
+ "waiting",
1807
+ "retrying",
1808
+ "sending",
1809
+ "sent",
1810
+ "failed",
1811
+ "cancelled"
1812
+ ]);
1813
+ /** A JSON column's value, whether a dialect hands it back parsed or as text. */
1814
+ function jsonOf(value) {
1815
+ if (typeof value !== "string") return value;
1816
+ try {
1817
+ return JSON.parse(value);
1818
+ } catch {
1819
+ return;
1820
+ }
1821
+ }
1822
+ /** A text column's value, or null. */
1823
+ function textOf$1(value) {
1824
+ return typeof value === "string" && value !== "" ? value : null;
1825
+ }
1826
+ /** A date column's value as text, or null. */
1827
+ function dateOf(value) {
1828
+ if (value instanceof Date) return value.toISOString();
1829
+ return textOf$1(value);
1830
+ }
1831
+ /** A stored row of `form-submission-sends`, as the views read it. */
1832
+ function sendRowOf(entry) {
1833
+ const recipients = z.array(RECIPIENT).safeParse(jsonOf(entry.recipients));
1834
+ const configured = z.array(RECIPIENT).safeParse(jsonOf(entry.configured));
1835
+ const cancel = CAUSE.safeParse(jsonOf(entry.cancel));
1836
+ const requested = CAUSE.safeParse(jsonOf(entry.cancelRequested));
1837
+ const state = STATE.safeParse(entry.state);
1838
+ return {
1839
+ id: String(entry.id),
1840
+ submission: textOf$1(entry.submission) ?? "",
1841
+ queuedId: textOf$1(entry.queuedId) ?? "",
1842
+ form: textOf$1(entry.form),
1843
+ notification: textOf$1(entry.notification),
1844
+ notificationName: textOf$1(entry.notificationName),
1845
+ state: state.success ? state.data : null,
1846
+ attempts: typeof entry.attempts === "number" ? entry.attempts : null,
1847
+ retries: typeof entry.retries === "number" ? entry.retries : null,
1848
+ resend: textOf$1(entry.resend),
1849
+ changedAt: dateOf(entry.changedAt),
1850
+ createdAt: dateOf(entry.createdAt),
1851
+ cancel: cancel.success ? cancel.data : null,
1852
+ cancelRequested: requested.success ? requested.data : null,
1853
+ retiredBy: textOf$1(entry.retiredBy),
1854
+ configured: configured.success ? configured.data : null,
1855
+ recipients: recipients.success ? recipients.data : null,
1856
+ requestedBy: textOf$1(entry.requestedBy)
1857
+ };
1858
+ }
1859
+ /** The most rows one read takes; a submission or a page needs far fewer. */
1860
+ const MOST_ROWS = 500;
1861
+ /** A row read against where core says its queued email stands. */
1862
+ function reconciled(send, status) {
1863
+ if (!sendInProgress(send)) return send;
1864
+ if (status === void 0) return {
1865
+ ...send,
1866
+ state: "cancelled",
1867
+ cancel: { by: "erasure" }
1868
+ };
1869
+ if (status.state === "cancelled") return {
1870
+ ...send,
1871
+ state: "cancelled",
1872
+ cancel: { by: "erasure" }
1873
+ };
1874
+ if (status.state === "sent" || status.state === "failed") return {
1875
+ ...send,
1876
+ state: status.state
1877
+ };
1878
+ return send;
1879
+ }
1880
+ /** `rows` read against core's state of each one still on its way. */
1881
+ async function upToDate(ctx, rows) {
1882
+ const unsettled = rows.filter(sendInProgress).map((row) => row.queuedId);
1883
+ if (unsettled.length === 0) return [...rows];
1884
+ const statuses = new Map((await ctx.services.email.readQueued(unsettled)).map((status) => [status.id, status]));
1885
+ return rows.map((row) => reconciled(row, statuses.get(row.queuedId)));
1886
+ }
1887
+ /**
1888
+ * Sends in the order they were made. Rows stored in the same second sort
1889
+ * alike by their time on some dialects, so an original comes before a send
1890
+ * Resend made, which always follows it.
1891
+ */
1892
+ function orderedSends(rows, options) {
1893
+ const sign = options.newestFirst ? -1 : 1;
1894
+ return rows.map((row, index) => ({
1895
+ row,
1896
+ index
1897
+ })).sort((a, b) => {
1898
+ const time = (a.row.createdAt ?? "").localeCompare(b.row.createdAt ?? "");
1899
+ if (time !== 0) return sign * time;
1900
+ const resent = Number(Boolean(a.row.resend)) - Number(Boolean(b.row.resend));
1901
+ return resent !== 0 ? sign * resent : a.index - b.index;
1902
+ }).map(({ row }) => row);
1903
+ }
1904
+ /** The sends rows `where` selects, oldest first. */
1905
+ async function sendRows(ctx, where, options = {}) {
1906
+ const read = (page, limit) => ctx.services.ownCollections.listEntries(sendsSlugOf(ctx), {
1907
+ where,
1908
+ sort: {
1909
+ field: "createdAt",
1910
+ direction: options.newestFirst ? "desc" : "asc"
1911
+ },
1912
+ pagination: {
1913
+ limit,
1914
+ page
1915
+ },
1916
+ depth: 0
1917
+ });
1918
+ return orderedSends((options.limit === void 0 ? await everyPage(read) : (await read(1, options.limit)).data).map((entry) => sendRowOf(entry)), options);
1919
+ }
1920
+ /**
1921
+ * Every row `read` pages through, a page at a time: a history read whole,
1922
+ * since a result left on a later page would be taken as never having
1923
+ * happened.
1924
+ */
1925
+ async function everyPage(read) {
1926
+ const rows = [];
1927
+ for (let page = 1;; page += 1) {
1928
+ const { data } = await read(page, MOST_ROWS);
1929
+ rows.push(...data);
1930
+ if (data.length < MOST_ROWS) return rows;
1931
+ }
1932
+ }
1933
+ /** The name a group of sends reads under: its latest one's. */
1934
+ function nameOf(sends) {
1935
+ for (let index = sends.length - 1; index >= 0; index -= 1) {
1936
+ const name = sends[index]?.notificationName;
1937
+ if (name) return name;
1938
+ }
1939
+ return "Notification";
1940
+ }
1941
+ /** A submission's sends, up to date, by notification in the order sent. */
1942
+ function groupByNotification(rows) {
1943
+ const groups = /* @__PURE__ */ new Map();
1944
+ for (const row of rows) {
1945
+ const key = row.notification ?? `queued:${row.queuedId}`;
1946
+ groups.set(key, [...groups.get(key) ?? [], row]);
1947
+ }
1948
+ return [...groups.values()].map((sends) => ({
1949
+ notification: sends[0]?.notification ?? null,
1950
+ name: nameOf(sends),
1951
+ recipients: latestByRecipient(sends),
1952
+ inProgress: sends.some(sendInProgress),
1953
+ sends: sends.map((send) => ({
1954
+ queuedId: send.queuedId,
1955
+ state: send.state ?? "waiting",
1956
+ attempts: send.attempts ?? 0,
1957
+ at: send.changedAt ?? send.createdAt ?? null,
1958
+ requestedBy: send.requestedBy ?? null,
1959
+ retired: Boolean(send.retiredBy)
1960
+ }))
1961
+ }));
1962
+ }
1963
+ /** A submission's sends, up to date and grouped by notification. */
1964
+ async function submissionSends(ctx, submissionId) {
1965
+ return groupByNotification(await upToDate(ctx, (await sendRows(ctx, { submission: { equals: submissionId } })).filter((row) => row.notification)));
1966
+ }
1967
+ /** A notification's latest sends across its form's submissions, newest first. */
1968
+ async function recentSends(ctx, formId, notificationId, limit) {
1969
+ return (await upToDate(ctx, await sendRows(ctx, {
1970
+ form: { equals: formId },
1971
+ notification: { equals: notificationId }
1972
+ }, {
1973
+ newestFirst: true,
1974
+ limit
1975
+ }))).map((send) => ({
1976
+ submission: send.submission,
1977
+ recipients: latestByRecipient([send]),
1978
+ at: send.changedAt ?? send.createdAt ?? null
1979
+ }));
1980
+ }
1981
+
1982
+ // src/handlers/send-history.ts
1983
+ /** A core cancel cause, as the send's row keeps it. */
1984
+ function causeOf(cause) {
1985
+ return cause.by === "person" ? {
1986
+ by: "person",
1987
+ userId: cause.userId
1988
+ } : { by: cause.by };
1989
+ }
1990
+ /** Where a final recipient came from: the line it was configured on, else a filter. */
1991
+ function sourceOf(configured, digest) {
1992
+ return configured?.find((entry) => entry.digest === digest)?.source ?? "added";
1993
+ }
1994
+ /**
1995
+ * The change's recipients as the row keeps them, each with where it came
1996
+ * from, and one accepted with the retries recorded before it was: as an
1997
+ * earlier change recorded it, else the row's count so far. Counted from the
1998
+ * retries recorded rather than the attempt's number, so a send whose record
1999
+ * of acceptance was undone, and is made again by the next attempt, reads as
2000
+ * sent the first time, as it was.
2001
+ */
2002
+ function foldedRecipients(stored, change) {
2003
+ const before = new Map((stored.recipients ?? []).map((recipient) => [recipient.digest, recipient]));
2004
+ return (change.recipients ?? []).map((recipient) => {
2005
+ const accepted = recipient.outcome === "accepted";
2006
+ const earlier = before.get(recipient.digest);
2007
+ return {
2008
+ digest: recipient.digest,
2009
+ hint: recipient.hint,
2010
+ kind: recipient.kind,
2011
+ outcome: recipient.outcome,
2012
+ source: sourceOf(stored.configured, recipient.digest),
2013
+ ...accepted ? { retries: earlier?.outcome === "accepted" && earlier.retries !== void 0 ? earlier.retries : stored.retries ?? 0 } : {}
2014
+ };
2015
+ });
2016
+ }
2017
+ /** What a cancelled change makes of the row. */
2018
+ function cancelledPatch(stored, change) {
2019
+ if (stored.state === "failed" && stored.retiredBy && change.cancel?.by === "sender") return {};
2020
+ return {
2021
+ state: "cancelled",
2022
+ cancel: change.cancel?.by === "request" || change.cancel === void 0 ? stored.cancelRequested ?? { by: "request" } : causeOf(change.cancel)
2023
+ };
2024
+ }
2025
+ /**
2026
+ * The row's fields a change sets: where the send stands, its attempts, when
2027
+ * it changed, a cancel's cause, and each final recipient's outcome. Empty
2028
+ * when the change leaves the row as it is.
2029
+ */
2030
+ function foldChange(stored, change) {
2031
+ const state = change.state === "cancelled" ? cancelledPatch(stored, change) : { state: change.state };
2032
+ if (Object.keys(state).length === 0) return {};
2033
+ return {
2034
+ ...state,
2035
+ attempts: Math.max(stored.attempts ?? 0, change.attempt),
2036
+ ...change.state === "retrying" ? { retries: (stored.retries ?? 0) + 1 } : {},
2037
+ changedAt: change.at,
2038
+ ...change.cancelRequested ? { cancelRequested: causeOf(change.cancelRequested) } : {},
2039
+ ...change.recipients && change.recipients.length > 0 ? { recipients: foldedRecipients(stored, change) } : {}
2040
+ };
2041
+ }
2042
+ /**
2043
+ * The send `queuedId` names, or undefined. Read with no lock: the change
2044
+ * that asks holds its queued email's row (core changes it, or cancels it,
2045
+ * under that row's lock before telling the plugin), so two changes of one
2046
+ * send never fold at once. A row lock here, beside a deletion's own reads of
2047
+ * a submission's sends, deadlocks MySQL when several submissions are deleted
2048
+ * together.
2049
+ */
2050
+ async function sendRow(ctx, transaction, queuedId) {
2051
+ const { data } = await ctx.services.ownCollections.listEntriesInTransaction(transaction, sendsSlugOf(ctx), {
2052
+ where: { queuedId: { equals: queuedId } },
2053
+ pagination: { limit: 1 },
2054
+ depth: 0
2055
+ }, { lock: "none" });
2056
+ const [entry] = data;
2057
+ return entry ? sendRowOf(entry) : void 0;
2058
+ }
2059
+ /** Keep each change of a submission's notifications with its send. */
2060
+ function registerSendHistory(ctx) {
2061
+ ctx.services.email.onQueuedEmailChange(async (change, { transaction }) => {
2062
+ const row = await sendRow(ctx, transaction, change.queuedId);
2063
+ if (!row) return;
2064
+ const patch = foldChange(row, change);
2065
+ if (Object.keys(patch).length === 0) return;
2066
+ await ctx.services.collections.updateEntryInTransaction(transaction, sendsSlugOf(ctx), String(row.id), {
2067
+ ...patch,
2068
+ _overwrite: true
2069
+ });
2070
+ });
2071
+ }
2072
+ /**
2073
+ * Name `cause` as why the sends among `queuedIds` were cancelled, where the
2074
+ * plugin's own forgetting cancelled them (`sender`), in `transaction`: what
2075
+ * marking a submission Spam calls it.
2076
+ */
2077
+ async function nameCancels(ctx, transaction, queuedIds, cause) {
2078
+ for (const queuedId of queuedIds) {
2079
+ const row = await sendRow(ctx, transaction, queuedId);
2080
+ if (!row) continue;
2081
+ const patch = {};
2082
+ if (row.cancel?.by === "sender") patch.cancel = cause;
2083
+ if (row.cancelRequested?.by === "sender") patch.cancelRequested = cause;
2084
+ if (Object.keys(patch).length === 0) continue;
2085
+ await ctx.services.collections.updateEntryInTransaction(transaction, sendsSlugOf(ctx), String(row.id), {
2086
+ ...patch,
2087
+ _overwrite: true
2088
+ });
2089
+ }
2090
+ }
2091
+
2092
+ // src/handlers/forget-sends.ts
2093
+ /**
2094
+ * How many rows a deletion reads at a time: a form's submissions, or a
2095
+ * submission's sends. Below the services' cap on one page, and drained until
2096
+ * none is left, so a large form or a submission with many notifications is
2097
+ * forgotten whole.
2098
+ */
2099
+ const DELETE_PAGE = 200;
2100
+ /**
2101
+ * How a submission's sends and requests are read: with no row lock. The
2102
+ * write deleting the submission or marking it Spam holds the submission's
2103
+ * row, and core changes or cancels each queued email under that email's own
2104
+ * row lock before telling the plugin, so nothing else changes them
2105
+ * meanwhile, and each is then written by its id. A locking read would take
2106
+ * MySQL's next-key locks on the `submission` index, which deadlock when
2107
+ * several submissions are deleted or marked Spam at once, each in its own
2108
+ * transaction.
2109
+ */
2110
+ const UNLOCKED = { lock: "none" };
2111
+ /**
2112
+ * The forms each transaction is deleting, by the transaction's key. A
2113
+ * submission deleted with its form need not hand its one-response key to
2114
+ * the next submission, which is about to go too.
2115
+ */
2116
+ const formsBeingDeleted = /* @__PURE__ */ new WeakMap();
2117
+ /** Whether the transaction `write` runs in is deleting the form `formId`. */
2118
+ function deletingForm(write, formId) {
2119
+ return formsBeingDeleted.get(write.transactionKey)?.has(formId) ?? false;
2120
+ }
2121
+ /**
2122
+ * Record that `submissionId` queued a notification, in `transaction`: the
2123
+ * one storing the submission and queuing the notification, so the record,
2124
+ * the submission and the queued email commit or roll back together.
2125
+ */
2126
+ async function recordSend(ctx, transaction, submissionId, send) {
2127
+ await ctx.services.collections.createEntryInTransaction(transaction, sendsSlugOf(ctx), {
2128
+ submission: submissionId,
2129
+ ...send
2130
+ });
2131
+ }
2132
+ /** A submission's status as a write describes it, or undefined. */
2133
+ function statusOf(row) {
2134
+ return row && typeof row === "object" ? row.status : void 0;
2135
+ }
2136
+ /** Whether `write` is the update that marks its submission Spam. */
2137
+ function marksSpam(write) {
2138
+ return write.operation === "update" && statusOf(write.data) === "spam" && statusOf(write.originalData) !== "spam";
2139
+ }
2140
+ /**
2141
+ * The queued emails a submission's notifications went out as, read on the
2142
+ * write's transaction. Paged by number: nothing here deletes the records.
2143
+ */
2144
+ async function queuedIdsOf(ctx, write) {
2145
+ const ids = [];
2146
+ for (let page = 1;; page += 1) {
2147
+ const { data } = await ctx.services.ownCollections.listEntriesInTransaction(write.transaction, sendsSlugOf(ctx), {
2148
+ where: { submission: { equals: String(write.id) } },
2149
+ pagination: {
2150
+ limit: 200,
2151
+ page
2152
+ },
2153
+ depth: 0
2154
+ }, UNLOCKED);
2155
+ const sends = data;
2156
+ for (const send of sends) if (send.queuedId) ids.push(send.queuedId);
2157
+ if (sends.length < 200) return ids;
2158
+ }
2159
+ }
2160
+ /**
2161
+ * Cancel and clear what a submission queued as it is marked Spam (see the
2162
+ * module). Only the queued emails are named, not the keys they were sent
2163
+ * with, so the delivery records of what already went out are kept.
2164
+ */
2165
+ async function cancelSpamSends(ctx, write) {
2166
+ if (!marksSpam(write)) return;
2167
+ const queuedIds = await queuedIdsOf(ctx, write);
2168
+ if (queuedIds.length === 0) return;
2169
+ await ctx.services.email.forgetSends({ queuedIds }, { tx: write.db });
2170
+ await nameCancels(ctx, write.transaction, queuedIds, { by: "spam" });
2171
+ }
2172
+ /** Forget what the submission a delete removes sent, and its records of it. */
2173
+ async function forgetSubmissionSends(ctx, write) {
2174
+ if (write.operation !== "delete" || write.id === void 0) return;
2175
+ const slug = sendsSlugOf(ctx);
2176
+ await inBatches(ctx, write, slug, (sends) => forgetBatch(ctx, write, slug, sends));
2177
+ await inBatches(ctx, write, resendsSlugOf(ctx), async (requests) => {
2178
+ for (const row of requests) await ctx.services.collections.deleteEntryInTransaction(write.transaction, resendsSlugOf(ctx), String(row.id));
2179
+ });
2180
+ }
2181
+ /**
2182
+ * Hand `remove` each batch of the rows in `slug` that name the submission a
2183
+ * delete removes, until none is left. `remove` deletes the batch it is
2184
+ * given, so the first page is always the next batch.
2185
+ */
2186
+ async function inBatches(ctx, write, slug, remove) {
2187
+ for (;;) {
2188
+ const { data } = await ctx.services.ownCollections.listEntriesInTransaction(write.transaction, slug, {
2189
+ where: { submission: { equals: String(write.id) } },
2190
+ pagination: { limit: 200 },
2191
+ depth: 0
2192
+ }, UNLOCKED);
2193
+ const rows = data;
2194
+ if (rows.length > 0) await remove(rows);
2195
+ if (rows.length < 200) return;
2196
+ }
2197
+ }
2198
+ /**
2199
+ * Delete a batch of a submission's send records, then forget the sends.
2200
+ * Records first: the cancel each forget causes then finds no record to fold
2201
+ * into (`send-history`), so a deletion writes nothing it is about to delete.
2202
+ * An update and a delete of one record in one transaction, beside another
2203
+ * deletion doing the same, deadlocks MySQL on the content-events table.
2204
+ */
2205
+ async function forgetBatch(ctx, write, slug, sends) {
2206
+ for (const send of sends) await ctx.services.collections.deleteEntryInTransaction(write.transaction, slug, String(send.id));
2207
+ await ctx.services.email.forgetSends({
2208
+ queuedIds: sends.flatMap((send) => send.queuedId ? [send.queuedId] : []),
2209
+ idempotencyKeys: sends.flatMap((send) => send.sendKey ? [send.sendKey] : [])
2210
+ }, { tx: write.db });
2211
+ }
2212
+ /** Delete a form's submissions with the form, each forgetting what it sent. */
2213
+ async function deleteFormSubmissions(ctx, write, submissionsSlug) {
2214
+ if (write.operation !== "delete" || write.id === void 0) return;
2215
+ const formId = String(write.id);
2216
+ const deleting = formsBeingDeleted.get(write.transactionKey) ?? /* @__PURE__ */ new Set();
2217
+ deleting.add(formId);
2218
+ formsBeingDeleted.set(write.transactionKey, deleting);
2219
+ for (;;) {
2220
+ const { data } = await ctx.services.ownCollections.listEntriesInTransaction(write.transaction, submissionsSlug, {
2221
+ where: { form: { equals: formId } },
2222
+ pagination: { limit: 200 },
2223
+ depth: 0
2224
+ });
2225
+ for (const row of data) await ctx.services.collections.deleteEntryInTransaction(write.transaction, submissionsSlug, String(row.id));
2226
+ if (data.length < 200) return;
2227
+ }
2228
+ }
2229
+ /**
2230
+ * Forget what submissions sent on every path that deletes them, and call
2231
+ * off what one queued as it is marked Spam.
2232
+ */
2233
+ function registerForgettingSends(ctx, slugs) {
2234
+ ctx.hooks.onInTransaction(slugs.submissions, async (write) => {
2235
+ await forgetSubmissionSends(ctx, write);
2236
+ await cancelSpamSends(ctx, write);
2237
+ });
2238
+ ctx.hooks.onInTransaction(slugs.forms, (write) => deleteFormSubmissions(ctx, write, slugs.submissions));
2239
+ }
2240
+
1423
2241
  // src/utils/form-closing.ts
1424
2242
  /**
1425
2243
  * Whether a form is taking submissions now: its status, as core reads it for
@@ -1537,7 +2355,7 @@ function oneResponseIdentityOf(settings) {
1537
2355
  const BROWSER_TOKEN = z.string().trim().regex(/^[A-Za-z0-9_-]{16,128}$/);
1538
2356
  /** The browser token a submission's answers carry, if a well-formed one. */
1539
2357
  function browserTokenOf(payload) {
1540
- const parsed = BROWSER_TOKEN.safeParse(answersOf(payload)?.[BROWSER_TOKEN_FIELD]);
2358
+ const parsed = BROWSER_TOKEN.safeParse(answersOf$1(payload)?.[BROWSER_TOKEN_FIELD]);
1541
2359
  return parsed.success ? parsed.data : void 0;
1542
2360
  }
1543
2361
  /**
@@ -1565,7 +2383,7 @@ function textOf(value) {
1565
2383
  * A submission's answers as an object: as written, or as a stored row holds
1566
2384
  * them before a read decodes them, a JSON string.
1567
2385
  */
1568
- function answersOf(data) {
2386
+ function answersOf$1(data) {
1569
2387
  let value = data;
1570
2388
  if (typeof value === "string") try {
1571
2389
  value = JSON.parse(value);
@@ -1576,7 +2394,7 @@ function answersOf(data) {
1576
2394
  }
1577
2395
  /** The answer to `field` in a submission's data. */
1578
2396
  function answerOf(data, field) {
1579
- return field === void 0 ? void 0 : answersOf(data)?.[field];
2397
+ return field === void 0 ? void 0 : answersOf$1(data)?.[field];
1580
2398
  }
1581
2399
  /**
1582
2400
  * The submission's key under the form's one-response setting, or null when
@@ -1803,6 +2621,7 @@ function sameValue$1(left, right) {
1803
2621
  if (Object.is(left, right)) return true;
1804
2622
  if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
1805
2623
  if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => sameValue$1(value, right[index]));
2624
+ if (isConsentAnswer(left) && isConsentAnswer(right)) return left.agreed === right.agreed && left.text === right.text;
1806
2625
  return false;
1807
2626
  }
1808
2627
  /** Whether two submitted payloads say the same thing. */
@@ -1831,16 +2650,25 @@ function sameSubmittedPayload(granted, incoming) {
1831
2650
  * handler has already prepared: transforming a projected payload projects it
1832
2651
  * again, and stripping tags from text that has none returns it unchanged.
1833
2652
  */
1834
- function prepareSubmission({ data, fields, validate }) {
2653
+ function prepareSubmission({ data, fields, validate, fresh = true }) {
1835
2654
  const transformed = transformFormData(data, fields);
1836
2655
  sanitizeSubmissionData(transformed, fields);
1837
2656
  if (!validate) return { data: transformed };
1838
2657
  const hidden = fieldsHiddenByCondition(fields, transformed);
1839
- const result = generateZodSchema(fields.filter((field) => !hidden.has(field.name))).safeParse(transformed);
1840
- if (!result.success) return {
2658
+ const shown = fields.filter((field) => !hidden.has(field.name));
2659
+ const result = generateZodSchema(shown).safeParse(transformed);
2660
+ const stale = fresh ? staleConsentNames(shown, data) : [];
2661
+ if (!result.success || stale.length > 0) return {
1841
2662
  data: transformed,
1842
- validationErrors: getValidationErrors(result),
1843
- validationIssues: getValidationIssues(result, data)
2663
+ validationErrors: {
2664
+ ...result.success ? {} : getValidationErrors(result),
2665
+ ...Object.fromEntries(stale.map((name) => [name, CONSENT_TEXT_CHANGED]))
2666
+ },
2667
+ validationIssues: [...result.success ? [] : getValidationIssues(result, data), ...stale.map((path) => ({
2668
+ path,
2669
+ code: "INVALID",
2670
+ message: CONSENT_TEXT_CHANGED
2671
+ }))]
1844
2672
  };
1845
2673
  return { data: {
1846
2674
  ...transformed,
@@ -2125,6 +2953,7 @@ async function handOffKey(deps, write, slugs) {
2125
2953
  const released = releasedKeyOf(write);
2126
2954
  if (released === void 0) return;
2127
2955
  const { key, formId, ipAddress } = released;
2956
+ if (deletingForm(write, formId)) return;
2128
2957
  const { settings } = await formOf(deps.own, write, slugs, formId, "update");
2129
2958
  if (keyVersionOf(key) !== (settings.oneResponseVersion ?? 0)) return;
2130
2959
  const next = await nextHolderOf(deps, write, slugs, {
@@ -2320,7 +3149,7 @@ function passCutoff() {
2320
3149
  return new Date(Date.now() + CUTOFF_MARGIN_MS);
2321
3150
  }
2322
3151
  /** Rows read at a time: forms, then one form's submissions. */
2323
- const PAGE = 100;
3152
+ const PAGE$2 = 100;
2324
3153
  /**
2325
3154
  * The mark on the sweep's own write of a form, saying which version's keys
2326
3155
  * it finished. Only the plugin sets it; `stampOneResponseVersion` takes the
@@ -2429,7 +3258,7 @@ async function rebuildInSave(deps, write) {
2429
3258
  field: "id",
2430
3259
  direction: "asc"
2431
3260
  },
2432
- pagination: { limit: PAGE },
3261
+ pagination: { limit: PAGE$2 },
2433
3262
  depth: 0
2434
3263
  }, { lock: "none" })).data, {
2435
3264
  form: { equals: write.id },
@@ -2471,17 +3300,17 @@ async function rebuildResponseKeys(deps, deadline) {
2471
3300
  for (let page = 1;; page++) {
2472
3301
  const { data } = await deps.collections.listEntries(deps.formsSlug, {
2473
3302
  pagination: {
2474
- limit: PAGE,
3303
+ limit: PAGE$2,
2475
3304
  page
2476
3305
  },
2477
3306
  depth: 0
2478
3307
  }, { as: "system" });
2479
3308
  const forms = data;
2480
3309
  for (const form of forms) {
2481
- if (pastDeadline(deadline)) return;
3310
+ if (pastDeadline$2(deadline)) return;
2482
3311
  await bringUpToDate(deps, form, deadline);
2483
3312
  }
2484
- if (forms.length < PAGE) return;
3313
+ if (forms.length < PAGE$2) return;
2485
3314
  }
2486
3315
  }
2487
3316
  /** One form: its keys made for its current version, if they are behind. */
@@ -2490,7 +3319,7 @@ async function bringUpToDate(deps, form, deadline) {
2490
3319
  if (!keysBehind(settings)) return;
2491
3320
  if (await rebuildForm(deps, form, settings, deadline) === "done") await markKeyed(deps, form, settings.oneResponseVersion ?? 0);
2492
3321
  }
2493
- function pastDeadline(deadline) {
3322
+ function pastDeadline$2(deadline) {
2494
3323
  return Date.now() >= deadline.getTime();
2495
3324
  }
2496
3325
  /**
@@ -2523,7 +3352,7 @@ async function rebuildPass(deps, formId, settings, deadline) {
2523
3352
  field: "id",
2524
3353
  direction: "asc"
2525
3354
  },
2526
- pagination: { limit: PAGE },
3355
+ pagination: { limit: PAGE$2 },
2527
3356
  depth: 0
2528
3357
  }, { as: "system" })).data, {
2529
3358
  form: { equals: formId },
@@ -2532,7 +3361,7 @@ async function rebuildPass(deps, formId, settings, deadline) {
2532
3361
  });
2533
3362
  let retries = 0;
2534
3363
  for (const row of rows) {
2535
- if (pastDeadline(deadline)) return "stop";
3364
+ if (pastDeadline$2(deadline)) return "stop";
2536
3365
  const outcome = await keyRow(deps, formId, settings, row);
2537
3366
  if (outcome === "stale-form") return "stop";
2538
3367
  if (outcome === "retry") retries += 1;
@@ -2553,7 +3382,7 @@ async function snapshotOf(page, where) {
2553
3382
  id: { greater_than: after }
2554
3383
  });
2555
3384
  rows.push(...read);
2556
- if (read.length < PAGE) break;
3385
+ if (read.length < PAGE$2) break;
2557
3386
  after = String(read[read.length - 1]?.id);
2558
3387
  }
2559
3388
  return rows.sort(storedFirst);
@@ -2635,20 +3464,677 @@ async function markKeyed(deps, form, version) {
2635
3464
  }
2636
3465
  }
2637
3466
 
2638
- // src/handlers/spam-detection.ts
3467
+ // src/handlers/send-record.ts
2639
3468
  /**
2640
- * Spam Detection
2641
- *
2642
- * Honeypot and rate limiting for form submissions.
2643
- *
2644
- * The honeypot is a pure read of the submitted payload. The rate limit is not:
2645
- * it counts, and where the count lives decides whether the limit means
2646
- * anything. It lives in the deployment's own {@link RateLimitStore}, the one
2647
- * the REST and auth limiters already share, so `rateLimit.store` is configured
2648
- * once and all three agree about what they are counting. A store private to
2649
- * this module would count per process, and on a deployment that spans several
2650
- * the effective limit becomes `configured x instances` -- a number the operator
2651
- * never chose, that fails open, and only under load.
3469
+ * A mailbox masked for a screen: its first character, then `•••`, then its
3470
+ * domain (`j•••@example.com`), as core masks the recipients it reports.
3471
+ */
3472
+ function maskedAddress(address) {
3473
+ const mailbox = (/<([^<>]*)>\s*$/.exec(address.trim())?.[1] ?? address).trim();
3474
+ const at = mailbox.lastIndexOf("@");
3475
+ if (at <= 0) return "•••";
3476
+ return `${mailbox.slice(0, 1)}•••${mailbox.slice(at)}`;
3477
+ }
3478
+ /** Every address on a line of an outgoing email. */
3479
+ function addressesOf(value) {
3480
+ if (value === void 0) return [];
3481
+ return (typeof value === "string" ? [value] : [...value]).filter((address) => address.trim() !== "");
3482
+ }
3483
+ /** The configured recipients of `email`, digested and masked. */
3484
+ async function configuredRecipients(email, toSource, digest) {
3485
+ const lines = [
3486
+ {
3487
+ kind: "to",
3488
+ source: toSource,
3489
+ addresses: addressesOf(email.to)
3490
+ },
3491
+ {
3492
+ kind: "cc",
3493
+ source: "cc",
3494
+ addresses: addressesOf(email.cc)
3495
+ },
3496
+ {
3497
+ kind: "bcc",
3498
+ source: "bcc",
3499
+ addresses: addressesOf(email.bcc)
3500
+ }
3501
+ ];
3502
+ const listed = [];
3503
+ const seen = /* @__PURE__ */ new Set();
3504
+ for (const line of lines) for (const address of line.addresses) {
3505
+ const recipient = await digest(address);
3506
+ if (seen.has(recipient)) continue;
3507
+ seen.add(recipient);
3508
+ listed.push({
3509
+ digest: recipient,
3510
+ hint: maskedAddress(address),
3511
+ kind: line.kind,
3512
+ source: line.source
3513
+ });
3514
+ }
3515
+ return listed;
3516
+ }
3517
+ /**
3518
+ * What a notification's queued send records beside its queued email: the
3519
+ * form and notification, the notification's name then, and its configured
3520
+ * recipients, waiting for their first attempt.
3521
+ */
3522
+ async function notificationSendDetails(ctx, input) {
3523
+ const notification = input.notifications.find((candidate) => candidate.id === input.email.notificationId);
3524
+ return {
3525
+ form: input.formId,
3526
+ ...input.email.notificationId ? { notification: input.email.notificationId } : {},
3527
+ ...notification?.name ? { notificationName: notification.name } : {},
3528
+ state: "waiting",
3529
+ configured: await configuredRecipients(input.email, notification?.recipientType === "field" ? "visitor" : "team", (address) => ctx.services.email.recipientDigest(address))
3530
+ };
3531
+ }
3532
+
3533
+ // src/handlers/resend.ts
3534
+ const BODY = z.object({
3535
+ notification: z.string().min(1).max(200),
3536
+ tick: z.array(z.string().min(1).max(128)).max(100).default([])
3537
+ });
3538
+ /** A stored request, as its handler reads it. */
3539
+ const REQUEST = z.object({
3540
+ submission: z.string(),
3541
+ notification: z.string(),
3542
+ requestedBy: z.string().nullish(),
3543
+ ticked: z.array(z.string()).default([]),
3544
+ retire: z.array(z.string()).default([]),
3545
+ settled: z.array(z.string()).default([])
3546
+ });
3547
+ /** The form a notification belongs to, and the notification itself. */
3548
+ function notificationOf(form, id) {
3549
+ return (Array.isArray(form.notifications) ? form.notifications : []).find((notification) => notification.id === id);
3550
+ }
3551
+ /** An entry's id as text: a string or a number by dialect. */
3552
+ function idText$1(id) {
3553
+ if (typeof id === "number") return `${id}`;
3554
+ return typeof id === "string" ? id : "";
3555
+ }
3556
+ /** `configured`, with each recipient among `exclude` marked skipped. */
3557
+ function skipping(configured, exclude) {
3558
+ return configured?.map((recipient) => exclude.includes(recipient.digest) ? {
3559
+ ...recipient,
3560
+ outcome: "skipped"
3561
+ } : recipient);
3562
+ }
3563
+ /** The refusal of a resend of a submission marked Spam. */
3564
+ function markedSpam() {
3565
+ return NextlyError.conflict({
3566
+ reason: "state",
3567
+ message: "Mark this submission Not spam before sending its emails."
3568
+ });
3569
+ }
3570
+ /**
3571
+ * Who a resend of `notification` can send to now, each marked as reached
3572
+ * when `history` (its latest result per recipient) shows it received it:
3573
+ * what the route plans with and the submission's page offers to tick.
3574
+ */
3575
+ async function addressableFor(ctx, email, notification, history) {
3576
+ const configured = await configuredRecipients(email, notification.recipientType === "field" ? "visitor" : "team", (address) => ctx.services.email.recipientDigest(address));
3577
+ return addressableNow(configured, history);
3578
+ }
3579
+ /**
3580
+ * The recipients a resend leaves out, as `sends` show them: each one that
3581
+ * received it and is not ticked to send again. Refused when that leaves
3582
+ * nobody it can address now: what the route plans with, and what the
3583
+ * request's own transaction works out again from the sends as it locked
3584
+ * them.
3585
+ */
3586
+ function leftOut(addressNow, sends, tick) {
3587
+ const exclude = reachedExcept(latestByRecipient(sends), tick);
3588
+ if (addressNow.every((recipient) => exclude.includes(recipient.digest))) throw NextlyError.conflict({
3589
+ reason: "state",
3590
+ message: "Everyone this notification can address has received it. Tick a recipient to send it to them again."
3591
+ });
3592
+ return exclude;
3593
+ }
3594
+ /**
3595
+ * Work out what a resend does, refusing one beside a send in progress, one
3596
+ * ticking a recipient it cannot address now, and one with nobody to send to.
3597
+ */
3598
+ async function planResend(ctx, sends, addressNow, tick) {
3599
+ if (sends.some(sendInProgress)) throw NextlyError.conflict({
3600
+ reason: "state",
3601
+ message: "A send of this notification is in progress. Wait for it to finish before sending it again."
3602
+ });
3603
+ const now = new Set(addressNow.map((recipient) => recipient.digest));
3604
+ if (tick.some((digest) => !now.has(digest))) throw NextlyError.validation({ errors: [{
3605
+ path: "tick",
3606
+ code: "INVALID",
3607
+ message: "Only a recipient this notification can address now can be ticked."
3608
+ }] });
3609
+ const exclude = leftOut(addressNow, sends, tick);
3610
+ const failed = sends.filter((send) => send.state === "failed" && !send.retiredBy);
3611
+ return {
3612
+ exclude,
3613
+ retire: (await ctx.services.email.readQueued(failed.map((send) => send.queuedId))).filter((status) => status.messageHeld).map((status) => status.id),
3614
+ settled: sends.map((send) => send.queuedId)
3615
+ };
3616
+ }
3617
+ /**
3618
+ * `POST /submissions/:id/resend`: send `notification` again. The route's own
3619
+ * permission is updating submissions; managing email deliveries is asked
3620
+ * here, before anything else.
3621
+ */
3622
+ async function resendRoute(req, ctx, deps) {
3623
+ if (!ctx.caller || !await ctx.caller.can("manage", "email-deliveries")) throw NextlyError.forbidden({ logContext: { reason: "resend needs manage-email-deliveries" } });
3624
+ const parsed = BODY.safeParse(await req.json().catch(() => void 0));
3625
+ if (!parsed.success) throw NextlyError.validation({ errors: [{
3626
+ path: "notification",
3627
+ code: "INVALID",
3628
+ message: "Say which notification to send again."
3629
+ }] });
3630
+ const submissionId = ctx.params.id ?? "";
3631
+ const submission = await ctx.services.collections.findEntryById(deps.submissionsSlug, submissionId, {
3632
+ as: "user",
3633
+ user: ctx.user ?? void 0
3634
+ });
3635
+ if (submission.status === "spam") throw markedSpam();
3636
+ const form = await ctx.services.ownCollections.findEntryById(deps.formsSlug, formIdOf(submission.form) ?? "");
3637
+ const notification = notificationOf(form, parsed.data.notification);
3638
+ const email = notification ? deps.build(form, submission, notification) : void 0;
3639
+ if (!notification || !email) throw NextlyError.notFound({ message: "This notification has no one to send to now." });
3640
+ const sends = await upToDate(ctx, await sendRows(ctx, {
3641
+ submission: { equals: String(submission.id) },
3642
+ notification: { equals: notification.id }
3643
+ }));
3644
+ const plan = await planResend(ctx, sends, await addressableFor(ctx, email, notification, latestByRecipient(sends)), parsed.data.tick);
3645
+ const created = await ctx.services.collections.createEntry(resendsSlugOf(ctx), {
3646
+ submission: String(submission.id),
3647
+ notification: notification.id,
3648
+ requestedBy: ctx.user?.id ?? null,
3649
+ ticked: parsed.data.tick,
3650
+ ...plan
3651
+ });
3652
+ return respondMutation("Sent again to the recipients who had not received it.", { id: idText$1(created.id) }, { status: 201 });
3653
+ }
3654
+ /** The submission's sends of a notification, locked for the resend. */
3655
+ async function lockedSends(ctx, write, submission, notification) {
3656
+ const data = await everyPage((page, limit) => ctx.services.ownCollections.listEntriesInTransaction(write.transaction, sendsSlugOf(ctx), {
3657
+ where: {
3658
+ submission: { equals: submission },
3659
+ notification: { equals: notification }
3660
+ },
3661
+ sort: {
3662
+ field: "createdAt",
3663
+ direction: "asc"
3664
+ },
3665
+ pagination: {
3666
+ limit,
3667
+ page
3668
+ },
3669
+ depth: 0
3670
+ }, { lock: "update" }));
3671
+ return orderedSends(data.map((entry) => sendRowOf(entry)), {});
3672
+ }
3673
+ /**
3674
+ * Retire the failed sends among `retire`: forgotten by their plugin, so their
3675
+ * messages are cleared and nothing retries them, and marked as retired by
3676
+ * this request, keeping their failure.
3677
+ */
3678
+ async function retireFailed(ctx, write, sends, retire) {
3679
+ if (retire.length === 0) return;
3680
+ for (const send of sends.filter((row) => retire.includes(row.queuedId))) await ctx.services.collections.updateEntryInTransaction(write.transaction, sendsSlugOf(ctx), send.id, {
3681
+ retiredBy: String(write.id),
3682
+ _overwrite: true
3683
+ });
3684
+ await ctx.services.email.forgetSends({ queuedIds: [...retire] }, { tx: write.db });
3685
+ }
3686
+ /** Queue a stored resend request, in the transaction that stores it. */
3687
+ async function resendInTransaction(ctx, deps, write) {
3688
+ if (write.operation !== "create") return;
3689
+ const request = REQUEST.parse(write.data);
3690
+ const submission = await ctx.services.ownCollections.findEntryByIdInTransaction(write.transaction, deps.submissionsSlug, request.submission, { lock: "update" });
3691
+ if (submission.status === "spam") throw markedSpam();
3692
+ const sends = await lockedSends(ctx, write, request.submission, request.notification);
3693
+ const settled = new Set(request.settled);
3694
+ if (sends.some((send) => sendInProgress(send) && !settled.has(send.queuedId))) throw NextlyError.conflict({
3695
+ reason: "state",
3696
+ message: "A send of this notification is in progress. Wait for it to finish before sending it again."
3697
+ });
3698
+ await retireFailed(ctx, write, sends, request.retire);
3699
+ const form = await ctx.services.ownCollections.findEntryByIdInTransaction(write.transaction, deps.formsSlug, formIdOf(submission.form) ?? "", { lock: "none" });
3700
+ const notification = notificationOf(form, request.notification);
3701
+ const email = notification ? deps.build(form, submission, notification) : void 0;
3702
+ if (!notification || !email) throw NextlyError.notFound({ message: "This notification has no one to send to now." });
3703
+ const exclude = leftOut(await addressableFor(ctx, email, notification, latestByRecipient(sends)), sends, request.ticked);
3704
+ let recorded = 0;
3705
+ await deps.queue({
3706
+ emails: await deps.filter([email], form, submission),
3707
+ key: `${request.submission}:resend:${String(write.id)}`,
3708
+ tx: write.db,
3709
+ excludeRecipients: exclude,
3710
+ record: async (sent, queued) => {
3711
+ recorded += 1;
3712
+ const details = await notificationSendDetails(ctx, {
3713
+ formId: formIdOf(submission.form) ?? "",
3714
+ notifications: [notification],
3715
+ email: queued
3716
+ });
3717
+ await recordSend(ctx, write.transaction, request.submission, {
3718
+ ...sent,
3719
+ ...details,
3720
+ configured: skipping(details.configured, exclude),
3721
+ ...request.requestedBy ? { requestedBy: request.requestedBy } : {},
3722
+ resend: String(write.id)
3723
+ });
3724
+ }
3725
+ });
3726
+ if (recorded === 0) throw NextlyError.validation({ errors: [{
3727
+ path: "notification",
3728
+ code: "INVALID",
3729
+ message: "The notification could not be sent again."
3730
+ }] });
3731
+ }
3732
+ /** Queue each stored resend request in the transaction that stores it. */
3733
+ function registerResend(ctx, deps) {
3734
+ ctx.hooks.onInTransaction(resendsSlugOf(ctx), (write) => resendInTransaction(ctx, deps, write));
3735
+ }
3736
+
3737
+ // src/handlers/upload-cleanup.ts
3738
+ /**
3739
+ * The files of uploads no submission holds any more, deleted once that is
3740
+ * durable.
3741
+ *
3742
+ * An upload is released in the transaction that deletes its submission (one
3743
+ * deletion, a bulk one, the retention pass, or deleting the whole form), or
3744
+ * when a visitor uploaded it and never submitted (`releaseStaleUploads`,
3745
+ * after {@link UNCLAIMED_KEEP_MS}). Once released, its file is deleted after
3746
+ * the release commits, never inside it: a file cannot be put back by a
3747
+ * rollback. Before deleting, each is checked again: no other record of an
3748
+ * upload holds the same file, and nothing else on the site uses it
3749
+ * (`ctx.services.media.isInUse`: a document's field, an avatar, a logo). A
3750
+ * file in use is kept, with its record, and asked about again on the next
3751
+ * nightly pass, so it goes once nothing uses it.
3752
+ *
3753
+ * The file goes through `ctx.services.media.delete`, which records the
3754
+ * item's stored files as left to remove in the delete's own transaction and
3755
+ * has core's jobs queue remove them until they are gone. So once it returns
3756
+ * the bytes are core's to finish, even when storage refused them this time,
3757
+ * and the record here can go.
3758
+ *
3759
+ * Every step can run twice: a file or record already gone is what the step
3760
+ * wanted. A deletion that could not finish (a database error, a crash between
3761
+ * the commit and the delete) leaves the record released, and the nightly
3762
+ * retention pass finishes it, carrying on from where its last run stopped.
3763
+ *
3764
+ * @module handlers/upload-cleanup
3765
+ */
3766
+ /** How long an upload no submission claimed is kept: a day. */
3767
+ const UNCLAIMED_KEEP_MS = 864e5;
3768
+ /** Rows read at a time. */
3769
+ const PAGE$1 = 100;
3770
+ function pastDeadline$1(deadline) {
3771
+ return deadline !== void 0 && Date.now() >= deadline.getTime();
3772
+ }
3773
+ /** `work`, with a `NOT_FOUND` read as done: what it removes is gone. */
3774
+ async function goneIsDone(work) {
3775
+ try {
3776
+ await work();
3777
+ } catch (error) {
3778
+ if (!NextlyError.isNotFound(error)) throw error;
3779
+ }
3780
+ }
3781
+ /** Whether another record that is not released holds the same file. */
3782
+ async function heldElsewhere(deps, upload) {
3783
+ return await deps.own.count(deps.uploadsSlug, { where: {
3784
+ media: { equals: String(upload.media) },
3785
+ id: { not_equals: String(upload.id) },
3786
+ releasedAt: { exists: false }
3787
+ } }) > 0;
3788
+ }
3789
+ /** Whether the media item is still there. */
3790
+ async function mediaPresent(deps, mediaId) {
3791
+ try {
3792
+ await deps.media.findById(mediaId, {});
3793
+ return true;
3794
+ } catch (error) {
3795
+ if (NextlyError.isNotFound(error)) return false;
3796
+ throw error;
3797
+ }
3798
+ }
3799
+ /**
3800
+ * Free one released upload: delete its file unless another record holds it
3801
+ * or something else uses it, then its record. A file in use keeps its
3802
+ * record, for the next pass. Answers whether the record is gone. Its stored
3803
+ * bytes are removed durably by the media delete itself, so the record need
3804
+ * not outlive it.
3805
+ */
3806
+ async function freeUpload(deps, upload) {
3807
+ const mediaId = String(upload.media);
3808
+ if (!await heldElsewhere(deps, upload) && await mediaPresent(deps, mediaId)) {
3809
+ if (await deps.media.isInUse(mediaId)) return false;
3810
+ await goneIsDone(() => deps.media.delete(mediaId, { as: "system" }));
3811
+ }
3812
+ await goneIsDone(() => deps.collections.deleteEntry(deps.uploadsSlug, String(upload.id), { as: "system" }));
3813
+ return true;
3814
+ }
3815
+ /** The next page of released uploads `where` narrows to, after `after`. */
3816
+ async function releasedAfter(deps, where, after) {
3817
+ const { data } = await deps.own.listEntries(deps.uploadsSlug, {
3818
+ where: {
3819
+ ...where,
3820
+ releasedAt: { exists: true },
3821
+ ...after === void 0 ? {} : { id: { greater_than: after } }
3822
+ },
3823
+ sort: {
3824
+ field: "id",
3825
+ direction: "asc"
3826
+ },
3827
+ pagination: { limit: PAGE$1 },
3828
+ depth: 0
3829
+ });
3830
+ return data;
3831
+ }
3832
+ /**
3833
+ * Free the released uploads `where` selects (every one, by default), in id
3834
+ * order from just after `from`, until `deadline`. Answers "done", or where
3835
+ * it stopped.
3836
+ */
3837
+ async function freeReleasedUploads(deps, where = {}, deadline, from) {
3838
+ let after = from;
3839
+ for (;;) {
3840
+ const page = await releasedAfter(deps, where, after);
3841
+ for (const upload of page) {
3842
+ if (pastDeadline$1(deadline)) return after === void 0 ? {} : { afterUpload: after };
3843
+ await freeUpload(deps, upload);
3844
+ after = String(upload.id);
3845
+ }
3846
+ if (page.length < PAGE$1) return "done";
3847
+ }
3848
+ }
3849
+ /**
3850
+ * Release an upload nobody claimed, if nobody has since: read for update,
3851
+ * so a submission claiming it at this moment either commits first and it
3852
+ * stays, or waits and is refused. Answers the released row, or none.
3853
+ */
3854
+ async function giveUp(deps, id) {
3855
+ return deps.collections.withTransaction(async (tx) => {
3856
+ let upload;
3857
+ try {
3858
+ upload = await deps.own.findEntryByIdInTransaction(tx, deps.uploadsSlug, id, { lock: "update" });
3859
+ } catch (error) {
3860
+ if (NextlyError.isNotFound(error)) return void 0;
3861
+ throw error;
3862
+ }
3863
+ if (!upload || upload.submission || upload.releasedAt) return void 0;
3864
+ const releasedAt = /* @__PURE__ */ new Date();
3865
+ await deps.collections.updateEntryInTransaction(tx, deps.uploadsSlug, id, {
3866
+ releasedAt,
3867
+ _overwrite: true
3868
+ });
3869
+ return {
3870
+ ...upload,
3871
+ releasedAt
3872
+ };
3873
+ });
3874
+ }
3875
+ /**
3876
+ * Release and free the uploads no submission claimed within a day of being
3877
+ * uploaded: a visitor who uploaded and never submitted. Answers false when
3878
+ * the deadline stopped it first. Each released row stops matching, so the
3879
+ * first page is always the next batch.
3880
+ */
3881
+ async function releaseStaleUploads(deps, now, deadline) {
3882
+ const where = {
3883
+ submission: { exists: false },
3884
+ releasedAt: { exists: false },
3885
+ createdAt: { less_than: /* @__PURE__ */ new Date(now - UNCLAIMED_KEEP_MS) }
3886
+ };
3887
+ for (;;) {
3888
+ const { data } = await deps.own.listEntries(deps.uploadsSlug, {
3889
+ where,
3890
+ pagination: { limit: PAGE$1 },
3891
+ depth: 0
3892
+ });
3893
+ for (const row of data) {
3894
+ if (pastDeadline$1(deadline)) return false;
3895
+ const released = await giveUp(deps, String(row.id));
3896
+ if (released) await freeUpload(deps, released);
3897
+ }
3898
+ if (data.length < PAGE$1) return true;
3899
+ }
3900
+ }
3901
+ /**
3902
+ * The nightly part, from `cursor`: retry the released uploads an earlier
3903
+ * deletion could not free, then give up on the ones never claimed. Answers
3904
+ * "done", or where the deadline stopped it.
3905
+ */
3906
+ async function sweepUploads(deps, now, deadline, cursor = {}) {
3907
+ if (!cursor.releasedDone) {
3908
+ const stopped = await freeReleasedUploads(deps, {}, deadline, cursor.afterUpload);
3909
+ if (stopped !== "done") return stopped;
3910
+ }
3911
+ return await releaseStaleUploads(deps, now, deadline) ? "done" : { releasedDone: true };
3912
+ }
3913
+ /**
3914
+ * Once the deletion `context` describes is durable, free the uploads it
3915
+ * released. With no way to wait for the commit, it leaves them to the
3916
+ * nightly pass rather than delete a file a rollback would still need.
3917
+ */
3918
+ function freeAfterCommit(context, deps, whereFor) {
3919
+ const hook = context;
3920
+ const id = hook.data?.id;
3921
+ if (typeof id !== "string" && typeof id !== "number" || !hook.afterCommit) return;
3922
+ return hook.afterCommit(() => freeReleasedUploads(deps, whereFor(String(id))));
3923
+ }
3924
+ /**
3925
+ * Free the files of every deletion's released uploads after it commits: one
3926
+ * submission's, and a form's (its submissions' and those nobody claimed).
3927
+ */
3928
+ function registerUploadCleanup(ctx, deps, slugs) {
3929
+ ctx.hooks.on("afterDelete", slugs.submissions, (context) => freeAfterCommit(context, deps, (id) => ({ submission: { equals: id } })));
3930
+ ctx.hooks.on("afterDelete", slugs.forms, (context) => freeAfterCommit(context, deps, (id) => ({ form: { equals: id } })));
3931
+ }
3932
+
3933
+ // src/handlers/retention.ts
3934
+ /**
3935
+ * Submissions are not kept forever. Each night a pass deletes:
3936
+ *
3937
+ * - each form's submissions older than the form keeps them for
3938
+ * (`settings.keepDays`, set under Settings › Privacy; none keeps them), and
3939
+ * - every form's spam older than {@link SPAM_KEEP_DAYS} days, whatever the
3940
+ * form's setting.
3941
+ *
3942
+ * Each submission is deleted through the collection, one transaction each,
3943
+ * so every deletion path runs the same handlers: what it sent is forgotten
3944
+ * (`forget-sends`) and its one-response key is handed on. Age is read from
3945
+ * `createdAt`, which the system sets, never from what a caller sent.
3946
+ *
3947
+ * Deleting a submission releases the uploads it held, and their files are
3948
+ * deleted once the deletion commits (`upload-claims`, `upload-cleanup`).
3949
+ * Last, the pass frees what an earlier deletion could not (a storage error,
3950
+ * a file then in use) and the uploads no submission claimed within a day.
3951
+ *
3952
+ * A pass stops at the deadline its jobs run gives it and queues a
3953
+ * continuation, so a large backlog is worked through on the next runs rather
3954
+ * than waiting a day.
3955
+ *
3956
+ * @module handlers/retention
3957
+ */
3958
+ /** The retention job's slug. */
3959
+ const RETENTION_JOB = "form-builder:retention";
3960
+ /** The schedule that runs it each night. */
3961
+ const RETENTION_SCHEDULE = {
3962
+ slug: "form-builder-retention-nightly",
3963
+ cron: "@daily",
3964
+ job: RETENTION_JOB
3965
+ };
3966
+ /** How long spam is kept, whatever a form's setting. */
3967
+ const SPAM_KEEP_DAYS = 30;
3968
+ const DAY_MS = 864e5;
3969
+ /** Rows read at a time: forms, or one form's submissions to delete. */
3970
+ const PAGE = 100;
3971
+ /** The retention job, run by `run` until the deadline the runner gives it. */
3972
+ function retentionJob(run) {
3973
+ return defineJob({
3974
+ slug: RETENTION_JOB,
3975
+ handler: async (input, context) => {
3976
+ await run(context.deadline, retentionCursorOf(input));
3977
+ }
3978
+ });
3979
+ }
3980
+ /** A form's settings as stored: an object, or a JSON string of one. */
3981
+ function settingsOf(value) {
3982
+ if (typeof value !== "string") return value;
3983
+ try {
3984
+ return JSON.parse(value);
3985
+ } catch {
3986
+ return;
3987
+ }
3988
+ }
3989
+ function pastDeadline(deadline) {
3990
+ return Date.now() >= deadline.getTime();
3991
+ }
3992
+ /** The cursor's yes-or-no parts, and its by-id parts. */
3993
+ const CURSOR_FLAGS = [
3994
+ "spamDone",
3995
+ "formsDone",
3996
+ "releasedDone"
3997
+ ];
3998
+ const CURSOR_IDS = ["afterForm", "afterUpload"];
3999
+ /** A continuation's cursor, read from its job input; none starts afresh. */
4000
+ function retentionCursorOf(input) {
4001
+ if (!input || typeof input !== "object") return {};
4002
+ const raw = input;
4003
+ const cursor = {};
4004
+ for (const flag of CURSOR_FLAGS) if (raw[flag] === true) cursor[flag] = true;
4005
+ for (const key of CURSOR_IDS) {
4006
+ const value = raw[key];
4007
+ if (typeof value === "string") cursor[key] = value;
4008
+ }
4009
+ return cursor;
4010
+ }
4011
+ /**
4012
+ * Delete what is past keeping, until `deadline`, from `cursor`. Answers
4013
+ * whether it finished; one that did not has queued its continuation, with
4014
+ * where it stopped.
4015
+ */
4016
+ async function runRetention(deps, deadline, cursor = {}) {
4017
+ const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime();
4018
+ if (!cursor.spamDone) {
4019
+ if (!await deleteWhere(deps, {
4020
+ status: { equals: "spam" },
4021
+ createdAt: { less_than: /* @__PURE__ */ new Date(now - SPAM_KEEP_DAYS * DAY_MS) }
4022
+ }, deadline)) return continueFrom(deps, {});
4023
+ }
4024
+ if (!cursor.formsDone) {
4025
+ const stoppedAt = await deleteAgedPerForm(deps, now, deadline, cursor);
4026
+ if (stoppedAt !== "done") return continueFrom(deps, {
4027
+ spamDone: true,
4028
+ ...stoppedAt
4029
+ });
4030
+ }
4031
+ if (!deps.uploads) return "done";
4032
+ const swept = await sweepUploads(deps.uploads, now, deadline, cursor);
4033
+ if (swept === "done") return "done";
4034
+ return continueFrom(deps, {
4035
+ spamDone: true,
4036
+ formsDone: true,
4037
+ ...swept
4038
+ });
4039
+ }
4040
+ /** Queue the continuation, with where this run stopped. */
4041
+ async function continueFrom(deps, cursor) {
4042
+ await deps.jobs.queue({
4043
+ task: RETENTION_JOB,
4044
+ input: Object.keys(cursor).length > 0 ? { ...cursor } : null
4045
+ });
4046
+ return "continued";
4047
+ }
4048
+ /**
4049
+ * Each form that keeps submissions for a while, in id order from the cursor:
4050
+ * those older than that. Answers "done", or where to carry on from: just
4051
+ * before the form it stopped in.
4052
+ */
4053
+ async function deleteAgedPerForm(deps, now, deadline, cursor) {
4054
+ let after = cursor.afterForm;
4055
+ for (;;) {
4056
+ const forms = await formsAfter(deps, after);
4057
+ for (const form of forms) {
4058
+ if (!await retainForm(deps, form, now, deadline)) return after === void 0 ? {} : { afterForm: after };
4059
+ after = String(form.id);
4060
+ }
4061
+ if (forms.length < PAGE) return "done";
4062
+ }
4063
+ }
4064
+ /** The next page of forms after `after`, by id. */
4065
+ async function formsAfter(deps, after) {
4066
+ const { data } = await deps.collections.listEntries(deps.formsSlug, {
4067
+ ...after === void 0 ? {} : { where: { id: { greater_than: after } } },
4068
+ sort: {
4069
+ field: "id",
4070
+ direction: "asc"
4071
+ },
4072
+ pagination: { limit: PAGE },
4073
+ depth: 0
4074
+ }, { as: "system" });
4075
+ return data;
4076
+ }
4077
+ /**
4078
+ * One form: its submissions past keeping, if it keeps them for a while.
4079
+ * Answers false when the deadline stopped it first.
4080
+ */
4081
+ async function retainForm(deps, form, now, deadline) {
4082
+ if (pastDeadline(deadline)) return false;
4083
+ const { keepDays } = normalizeFormSettings(settingsOf(form.settings));
4084
+ if (keepDays === void 0) return true;
4085
+ return deleteWhere(deps, {
4086
+ form: { equals: String(form.id) },
4087
+ createdAt: { less_than: /* @__PURE__ */ new Date(now - keepDays * DAY_MS) }
4088
+ }, deadline);
4089
+ }
4090
+ /**
4091
+ * Delete the submissions `where` selects, a page at a time. Answers false
4092
+ * when the deadline stopped it with some left. Each page is read afresh,
4093
+ * since what was deleted no longer matches.
4094
+ */
4095
+ async function deleteWhere(deps, where, deadline) {
4096
+ for (;;) {
4097
+ if (pastDeadline(deadline)) return false;
4098
+ const { data } = await deps.collections.listEntries(deps.submissionsSlug, {
4099
+ where,
4100
+ pagination: { limit: PAGE },
4101
+ depth: 0
4102
+ }, { as: "system" });
4103
+ const rows = data;
4104
+ for (const row of rows) {
4105
+ if (pastDeadline(deadline)) return false;
4106
+ await deleteOne(deps, String(row.id));
4107
+ }
4108
+ if (rows.length < PAGE) return true;
4109
+ }
4110
+ }
4111
+ /**
4112
+ * Delete one submission. One another runner, or a person, deleted first is
4113
+ * already gone, which is what this wanted.
4114
+ */
4115
+ async function deleteOne(deps, id) {
4116
+ try {
4117
+ await deps.collections.deleteEntry(deps.submissionsSlug, id, { as: "system" });
4118
+ } catch (error) {
4119
+ if (NextlyError.isCode(error, "NOT_FOUND")) return;
4120
+ throw error;
4121
+ }
4122
+ }
4123
+
4124
+ // src/handlers/spam-detection.ts
4125
+ /**
4126
+ * Spam Detection
4127
+ *
4128
+ * Honeypot and rate limiting for form submissions.
4129
+ *
4130
+ * The honeypot is a pure read of the submitted payload. The rate limit is not:
4131
+ * it counts, and where the count lives decides whether the limit means
4132
+ * anything. It lives in the deployment's own {@link RateLimitStore}, the one
4133
+ * the REST and auth limiters already share, so `rateLimit.store` is configured
4134
+ * once and all three agree about what they are counting. A store private to
4135
+ * this module would count per process, and on a deployment that spans several
4136
+ * the effective limit becomes `configured x instances` -- a number the operator
4137
+ * never chose, that fails open, and only under load.
2652
4138
  *
2653
4139
  * @module handlers/spam-detection
2654
4140
  * @since 0.1.0
@@ -2747,8 +4233,17 @@ function checkHoneypot(data) {
2747
4233
  * would lock out every visitor behind an untrusted proxy. The caller decides
2748
4234
  * what to do about an unidentifiable client; this counts only identified ones.
2749
4235
  */
4236
+ /**
4237
+ * Count one request under `key` against `max` per `windowMs`, in the
4238
+ * deployment's shared rate-limit store: whether it is allowed, and when the
4239
+ * window resets. The plugin's one door to the limiter, for submissions and
4240
+ * uploads alike.
4241
+ */
4242
+ async function countAgainstLimit(key, max, windowMs) {
4243
+ return new RateLimiter(resolveRateLimitStore()).check(key, max, windowMs);
4244
+ }
2750
4245
  async function checkRateLimit(ipAddress, formSlug, config) {
2751
- if ((await new RateLimiter(resolveRateLimitStore()).check(`form-submit:${formSlug}:${ipAddress}`, config.maxSubmissions, config.windowMs)).allowed) return { isSpam: false };
4246
+ if ((await countAgainstLimit(`form-submit:${formSlug}:${ipAddress}`, config.maxSubmissions, config.windowMs)).allowed) return { isSpam: false };
2752
4247
  return {
2753
4248
  isSpam: true,
2754
4249
  reason: "rate_limit",
@@ -2756,6 +4251,321 @@ async function checkRateLimit(ipAddress, formSlug, config) {
2756
4251
  };
2757
4252
  }
2758
4253
 
4254
+ // src/utils/upload-rules.ts
4255
+ /** The longest name a stored file keeps, before its extension is lost. */
4256
+ const MAX_NAME_LENGTH = 200;
4257
+ /**
4258
+ * The patterns a file has to match: the field's own `accept` (the HTML
4259
+ * attribute's spelling: `.pdf`, `image/*`, `application/pdf`), or the
4260
+ * plugin's allowed types when the field names none.
4261
+ */
4262
+ function acceptPatternsOf(field, settings) {
4263
+ const own = typeof field.accept === "string" ? field.accept.split(",").map((pattern) => pattern.trim().toLowerCase()).filter((pattern) => pattern !== "") : [];
4264
+ return own.length > 0 ? own : settings.allowedMimeTypes.map((type) => type.toLowerCase());
4265
+ }
4266
+ /** A media type without its parameters, in lower case. */
4267
+ function bareType(type) {
4268
+ return (type.split(";")[0] ?? "").trim().toLowerCase();
4269
+ }
4270
+ /** Whether one `accept` pattern takes a file of this name and type. */
4271
+ function patternTakes(pattern, name, type) {
4272
+ if (pattern === "*" || pattern === "*/*") return true;
4273
+ if (pattern.startsWith(".")) return name.toLowerCase().endsWith(pattern);
4274
+ if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1)) && type.length > pattern.length - 1;
4275
+ return type === pattern;
4276
+ }
4277
+ /**
4278
+ * Whether a file of this name and media type matches any of `patterns`, as
4279
+ * a browser reads an `accept` attribute: an extension matches the name, a
4280
+ * type or `type/*` matches the media type.
4281
+ */
4282
+ function fileMatchesAccept(file, patterns) {
4283
+ const type = bareType(file.type);
4284
+ return patterns.some((pattern) => patternTakes(pattern, file.name, type));
4285
+ }
4286
+ /**
4287
+ * The largest file, in bytes, a field takes: its own limit, never more than
4288
+ * the plugin's, and the plugin's when the field sets none. A form's author
4289
+ * chooses a field's limit; the site's developer chooses how much any
4290
+ * visitor may store, and that bound holds whatever a form says.
4291
+ */
4292
+ function uploadLimitOf(field, settings) {
4293
+ const own = field.maxFileSize;
4294
+ return typeof own === "number" && Number.isFinite(own) && own > 0 ? Math.min(own, settings.maxFileSize) : settings.maxFileSize;
4295
+ }
4296
+ /** Whether a character is a control character (C0, or DEL). */
4297
+ function isControl(char) {
4298
+ const code = char.codePointAt(0) ?? 0;
4299
+ return code < 32 || code === 127;
4300
+ }
4301
+ /**
4302
+ * The name a visitor's file is stored under: its last path segment (some
4303
+ * browsers send `C:\fakepath\cv.pdf`), without control characters, and no
4304
+ * longer than the storage keeps. A name with nothing left is `upload`.
4305
+ */
4306
+ function safeUploadName(raw) {
4307
+ const lastSegment = raw.split(/[\\/]/).pop() ?? "";
4308
+ const printable = Array.from(lastSegment).filter((char) => !isControl(char)).join("").trim();
4309
+ const named = /^\.*$/.test(printable) ? "upload" : printable;
4310
+ if (named.length <= MAX_NAME_LENGTH) return named;
4311
+ const dot = named.lastIndexOf(".");
4312
+ const extension = dot > 0 && named.length - dot <= 16 ? named.slice(dot) : "";
4313
+ return named.slice(0, MAX_NAME_LENGTH - extension.length) + extension;
4314
+ }
4315
+ /** A byte count as a person reads it: `5 MB`, `512 KB`. */
4316
+ function readableBytes(bytes) {
4317
+ if (bytes >= 1048576) return `${Math.round(bytes / 1048576 * 10) / 10} MB`;
4318
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
4319
+ return `${bytes} bytes`;
4320
+ }
4321
+ /** The media ids a file answer names: one id, or a list of them. */
4322
+ function mediaIdsOf(answer) {
4323
+ if (typeof answer === "string") return answer === "" ? [] : [answer];
4324
+ if (!Array.isArray(answer)) return [];
4325
+ return answer.filter((item) => typeof item === "string" && item !== "");
4326
+ }
4327
+
4328
+ // src/handlers/upload-claims.ts
4329
+ /**
4330
+ * Which files a submission holds, decided inside the transaction that stores
4331
+ * it, whatever stored it.
4332
+ *
4333
+ * A file answer names media ids. A submission a request writes (a visitor
4334
+ * submitting, or staff over the API) may only name uploads the plugin's
4335
+ * upload route issued for the same form and field, that no other submission
4336
+ * has claimed; anything else (another form's upload, one already claimed, a
4337
+ * site's own logo, an id made up) is refused, and the write rolls back. The
4338
+ * uploads it names are claimed for it in the same transaction, each read for
4339
+ * update first, so two submissions naming one upload take turns and the
4340
+ * second is refused.
4341
+ *
4342
+ * An update keeps the ids the submission already stores in a field: staff
4343
+ * editing other answers send the file answer back as it was, and that is not
4344
+ * a new claim. A submission stored as spam is evidence, judged as a write
4345
+ * with no request is, so a bot is not told which of its ids were refused.
4346
+ *
4347
+ * A write no request produced (the Direct API from site code, an import, a
4348
+ * job) is trusted, as it was before uploads were recorded: nothing it names
4349
+ * is refused, and an issued upload it names for the same form and field that
4350
+ * no submission has claimed is claimed for it, so the file is kept with the
4351
+ * submission and deleted with it. One it names that another submission
4352
+ * already holds is recorded again, as this submission's too: a file stays
4353
+ * while any submission holding it is left.
4354
+ *
4355
+ * Deleting a submission releases its uploads in the deleting transaction,
4356
+ * and deleting a form releases the uploads no submission claimed: a released
4357
+ * upload can never be claimed again, and its file is deleted once the
4358
+ * deletion commits (`upload-cleanup`).
4359
+ *
4360
+ * @module handlers/upload-claims
4361
+ */
4362
+ /** The shared hook context key the write seam marks a write under. */
4363
+ const UPLOAD_CLAIMS_CONTEXT = "formBuilder.uploadClaims";
4364
+ /** What a visitor, or staff, is told about a file answer it may not name. */
4365
+ const UPLOAD_NOT_CLAIMABLE = "This file was not uploaded for this field. Please upload it again.";
4366
+ /** The names of a form's file fields. */
4367
+ function fileFieldNamesOf(fields) {
4368
+ if (!Array.isArray(fields)) return [];
4369
+ return fields.flatMap((field) => {
4370
+ const { type, name } = field ?? {};
4371
+ return type === "file" && typeof name === "string" ? [name] : [];
4372
+ });
4373
+ }
4374
+ /**
4375
+ * Mark a submission's write for the transaction that stores it: whether a
4376
+ * request produced it and, once the form is read, its file fields. Written
4377
+ * over whatever the shared context held, so no caller decides it.
4378
+ */
4379
+ function markUploadClaims(hook, form) {
4380
+ if (!hook.context) return;
4381
+ const marker = {
4382
+ request: Boolean(hook.req?.http),
4383
+ ...form ? { fileFields: fileFieldNamesOf(form.fields) } : {}
4384
+ };
4385
+ hook.context[UPLOAD_CLAIMS_CONTEXT] = marker;
4386
+ }
4387
+ /** The mark the write seam left, if it ran. */
4388
+ function markerOf(context) {
4389
+ const raw = context[UPLOAD_CLAIMS_CONTEXT];
4390
+ if (!raw || typeof raw !== "object") return void 0;
4391
+ const fileFields = Array.isArray(raw.fileFields) ? raw.fileFields.filter((name) => typeof name === "string") : void 0;
4392
+ return {
4393
+ request: raw.request === true,
4394
+ ...fileFields ? { fileFields } : {}
4395
+ };
4396
+ }
4397
+ /** A submission's answers, as stored: an object, or a JSON string of one. */
4398
+ function answersOf(value) {
4399
+ let parsed = value;
4400
+ if (typeof value === "string") try {
4401
+ parsed = JSON.parse(value);
4402
+ } catch {
4403
+ return {};
4404
+ }
4405
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4406
+ }
4407
+ /** The form's file fields, read on the write's transaction. */
4408
+ async function fileFieldsInTransaction(deps, write, formsSlug, formId) {
4409
+ try {
4410
+ return fileFieldNamesOf((await deps.own.findEntryByIdInTransaction(write.transaction, formsSlug, formId, { lock: "none" }))?.fields);
4411
+ } catch (error) {
4412
+ if (NextlyError.isNotFound(error)) return [];
4413
+ throw error;
4414
+ }
4415
+ }
4416
+ /** Whether an upload record still counts: not released for deletion. */
4417
+ function live(upload) {
4418
+ return !upload.releasedAt;
4419
+ }
4420
+ /** Whether `upload` was issued for `field` of the form and nobody holds it. */
4421
+ function claimable(upload, terms, field) {
4422
+ return live(upload) && !upload.submission && upload.form === terms.formId && upload.field === field;
4423
+ }
4424
+ /**
4425
+ * Whether the submission already holds `upload`: a request write only for
4426
+ * the field it names, a trusted one however it came to hold it.
4427
+ */
4428
+ function heldBy(upload, terms, field) {
4429
+ if (!live(upload) || upload.submission !== terms.submissionId) return false;
4430
+ return !terms.strict || upload.form === terms.formId && upload.field === field;
4431
+ }
4432
+ /** The refusal of a file answer naming what it may not. */
4433
+ function notClaimable(field, mediaId, terms) {
4434
+ return NextlyError.validation({
4435
+ errors: [{
4436
+ path: field,
4437
+ code: "INVALID",
4438
+ message: UPLOAD_NOT_CLAIMABLE
4439
+ }],
4440
+ logContext: {
4441
+ mediaId,
4442
+ formId: terms.formId,
4443
+ reason: "upload-not-claimable"
4444
+ }
4445
+ });
4446
+ }
4447
+ /**
4448
+ * Hold the upload of `mediaId` for the submission: claim the record the
4449
+ * route issued, refuse a request write anything else, and record a trusted
4450
+ * write as one more holder of a file another submission holds.
4451
+ */
4452
+ async function claimOne(deps, write, uploadsSlug, terms, field, mediaId) {
4453
+ const { data } = await deps.own.listEntriesInTransaction(write.transaction, uploadsSlug, {
4454
+ where: { media: { equals: mediaId } },
4455
+ pagination: { limit: 200 },
4456
+ depth: 0
4457
+ }, { lock: "update" });
4458
+ const uploads = data;
4459
+ if (uploads.some((upload) => heldBy(upload, terms, field))) return;
4460
+ const issued = uploads.find((upload) => claimable(upload, terms, field));
4461
+ if (issued) {
4462
+ await deps.collections.updateEntryInTransaction(write.transaction, uploadsSlug, String(issued.id), {
4463
+ submission: terms.submissionId,
4464
+ _overwrite: true
4465
+ });
4466
+ return;
4467
+ }
4468
+ if (terms.strict) throw notClaimable(field, mediaId, terms);
4469
+ if (uploads.some(live)) await deps.collections.createEntryInTransaction(write.transaction, uploadsSlug, {
4470
+ media: mediaId,
4471
+ form: terms.formId,
4472
+ field,
4473
+ submission: terms.submissionId
4474
+ });
4475
+ }
4476
+ /** The ids `field` names now that it did not name before this write. */
4477
+ function newIdsIn(field, answers, stored) {
4478
+ const kept = new Set(mediaIdsOf(stored[field]));
4479
+ return [...new Set(mediaIdsOf(answers[field]))].filter((id) => !kept.has(id));
4480
+ }
4481
+ /**
4482
+ * A write's answers as it leaves them, and as the submission stored them
4483
+ * before it (none for a create). `undefined` when the write changes no
4484
+ * answer, so nothing new can be named.
4485
+ */
4486
+ function answersOfWrite(write, row) {
4487
+ const answers = answersOf(row.data);
4488
+ if (write.operation !== "update") return {
4489
+ answers,
4490
+ stored: {}
4491
+ };
4492
+ const before = write.originalData;
4493
+ const stored = answersOf(before?.data);
4494
+ return JSON.stringify(answers) === JSON.stringify(stored) ? void 0 : {
4495
+ answers,
4496
+ stored
4497
+ };
4498
+ }
4499
+ /**
4500
+ * How strictly a write is judged: one a request produced is held to the
4501
+ * rules, unless it is stored as spam, which is evidence.
4502
+ */
4503
+ function termsOf(write, row, formId, marker) {
4504
+ return {
4505
+ formId,
4506
+ submissionId: String(write.id),
4507
+ strict: marker?.request === true && row.status !== "spam"
4508
+ };
4509
+ }
4510
+ /** Claim the uploads a created or updated submission newly names. */
4511
+ async function claimUploads(deps, write, slugs) {
4512
+ const row = write.data;
4513
+ const formId = formIdOf(row?.form);
4514
+ if (!row || formId === void 0) return;
4515
+ const changed = answersOfWrite(write, row);
4516
+ if (changed === void 0) return;
4517
+ const { answers, stored } = changed;
4518
+ const marker = markerOf(write.context);
4519
+ const fields = marker?.fileFields ?? await fileFieldsInTransaction(deps, write, slugs.forms, formId);
4520
+ const terms = termsOf(write, row, formId, marker);
4521
+ for (const field of fields) for (const mediaId of newIdsIn(field, answers, stored)) await claimOne(deps, write, slugs.uploads, terms, field, mediaId);
4522
+ }
4523
+ /** Release every upload `where` selects, a page at a time. */
4524
+ async function releaseWhere(deps, write, uploadsSlug, where) {
4525
+ const releasedAt = /* @__PURE__ */ new Date();
4526
+ for (;;) {
4527
+ const { data } = await deps.own.listEntriesInTransaction(write.transaction, uploadsSlug, {
4528
+ where: {
4529
+ ...where,
4530
+ releasedAt: { exists: false }
4531
+ },
4532
+ pagination: { limit: 200 },
4533
+ depth: 0
4534
+ }, { lock: "update" });
4535
+ for (const upload of data) await deps.collections.updateEntryInTransaction(write.transaction, uploadsSlug, String(upload.id), {
4536
+ releasedAt,
4537
+ _overwrite: true
4538
+ });
4539
+ if (data.length < 200) return;
4540
+ }
4541
+ }
4542
+ /** A submission's write: claim what it names, or release what it held. */
4543
+ async function judgeSubmissionWrite(deps, write, slugs) {
4544
+ if (write.operation !== "delete") return claimUploads(deps, write, slugs);
4545
+ await releaseWhere(deps, write, slugs.uploads, { submission: { equals: String(write.id) } });
4546
+ }
4547
+ /**
4548
+ * A form's deletion: release the uploads made for it that no submission
4549
+ * claimed. Claimed ones go with their submissions, which the deletion
4550
+ * deletes, or stay with one moved to another form.
4551
+ */
4552
+ async function releaseFormUploads(deps, write, uploadsSlug) {
4553
+ if (write.operation !== "delete") return;
4554
+ await releaseWhere(deps, write, uploadsSlug, {
4555
+ form: { equals: String(write.id) },
4556
+ submission: { exists: false }
4557
+ });
4558
+ }
4559
+ /** Claim and release uploads in every write of a submission or a form. */
4560
+ function registerUploadClaims(ctx, slugs) {
4561
+ const deps = {
4562
+ own: ctx.services.ownCollections,
4563
+ collections: ctx.services.collections
4564
+ };
4565
+ ctx.hooks.onInTransaction(slugs.submissions, (write) => judgeSubmissionWrite(deps, write, slugs));
4566
+ ctx.hooks.onInTransaction(slugs.forms, (write) => releaseFormUploads(deps, write, slugs.uploads));
4567
+ }
4568
+
2759
4569
  // src/handlers/submit-form.ts
2760
4570
  /**
2761
4571
  * Form Submission Handler
@@ -2996,6 +4806,7 @@ async function submitForm(options, context) {
2996
4806
  spamReason: isContentSpam ? spamResult.reason ?? null : null,
2997
4807
  ipAddress: metadata?.ipAddress || null,
2998
4808
  userAgent: metadata?.userAgent || null,
4809
+ page: pageFromReferer(metadata?.page) ?? null,
2999
4810
  submittedAt: /* @__PURE__ */ new Date()
3000
4811
  };
3001
4812
  const browserToken = browserTokenOf(data);
@@ -3005,6 +4816,7 @@ async function submitForm(options, context) {
3005
4816
  keepAsEvidence: isContentSpam,
3006
4817
  form: {
3007
4818
  id: form.id,
4819
+ slug: form.slug,
3008
4820
  fields: form.fields,
3009
4821
  settings: form.settings
3010
4822
  },
@@ -3354,6 +5166,19 @@ async function getFormSubmissionStats(formSlug, context) {
3354
5166
  * @module routes/public-forms
3355
5167
  */
3356
5168
  /**
5169
+ * The refusal for a form that is absent, not published, or not open: one
5170
+ * message whatever the reason, so the answer does not say which forms exist.
5171
+ */
5172
+ function noSuchForm(slug) {
5173
+ return NextlyError$1.notFound({
5174
+ message: NO_SUCH_FORM,
5175
+ logContext: {
5176
+ entity: "form",
5177
+ slug
5178
+ }
5179
+ });
5180
+ }
5181
+ /**
3357
5182
  * These routes read and write as the anonymous visitor on the other end.
3358
5183
  *
3359
5184
  * `public: true` waives the ROUTE's authentication and says nothing about the
@@ -3447,7 +5272,7 @@ async function listPublishedForms(req, ctx, config) {
3447
5272
  page: positiveInt(url.searchParams.get("page")) ?? 1
3448
5273
  }
3449
5274
  }, PUBLIC);
3450
- return respondList(result.data ?? [], toPageMeta(result.pagination));
5275
+ return respondList$1(result.data ?? [], toPageMeta(result.pagination));
3451
5276
  }
3452
5277
  /**
3453
5278
  * A found form's answer now: its status, then its closing time, then its
@@ -3467,13 +5292,25 @@ async function availabilityNow(form, ctx, slugs) {
3467
5292
  taken
3468
5293
  });
3469
5294
  }
3470
- async function readFormBySlug(slug, ctx, config) {
5295
+ /**
5296
+ * The form at `slug`, read as the visitor, and what a visitor is told about
5297
+ * it now: absent, closed (with its message), or open. One reading for every
5298
+ * public endpoint that names a form by its slug, so they cannot come to
5299
+ * disagree about which forms are open.
5300
+ */
5301
+ async function formAt(slug, ctx, config) {
3471
5302
  const slugs = installedSlugs(config, ctx.self);
3472
5303
  const doc = (await ctx.services.collections.listEntries(slugs.forms, {
3473
5304
  where: { slug: { equals: slug } },
3474
5305
  pagination: { limit: 1 }
3475
5306
  }, PUBLIC)).data?.[0];
3476
- const availability = doc ? await availabilityNow(doc, ctx, slugs) : formAvailability(doc);
5307
+ return {
5308
+ doc,
5309
+ availability: doc ? await availabilityNow(doc, ctx, slugs) : formAvailability(doc)
5310
+ };
5311
+ }
5312
+ async function readFormBySlug(slug, ctx, config) {
5313
+ const { doc, availability } = await formAt(slug, ctx, config);
3477
5314
  if (availability.kind === "absent") throw NextlyError$1.notFound({
3478
5315
  message: NO_SUCH_FORM,
3479
5316
  logContext: {
@@ -3577,8 +5414,9 @@ async function acceptSubmission(req, ctx, config, slug) {
3577
5414
  locale: new URL(req.url).searchParams.get("locale") || void 0,
3578
5415
  request: req,
3579
5416
  metadata: {
3580
- ipAddress: trustedClientIp(req) ?? void 0,
3581
- userAgent: req.headers.get("user-agent") ?? void 0
5417
+ ipAddress: trustedClientIp$1(req) ?? void 0,
5418
+ userAgent: req.headers.get("user-agent") ?? void 0,
5419
+ page: req.headers.get("referer") ?? void 0
3582
5420
  },
3583
5421
  access: PUBLIC
3584
5422
  }, {
@@ -3586,7 +5424,7 @@ async function acceptSubmission(req, ctx, config, slug) {
3586
5424
  pluginConfig: configForInstall(config, ctx.self)
3587
5425
  });
3588
5426
  if (result.outcome !== "accepted") throw refusalFor(result, slug);
3589
- return respondAction(result.successMessage ?? DEFAULT_SUCCESS_MESSAGE, {
5427
+ return respondAction$1(result.successMessage ?? DEFAULT_SUCCESS_MESSAGE, {
3590
5428
  ...result.submission ? { submissionId: result.submission.id } : {},
3591
5429
  ...result.redirect ? { redirect: result.redirect } : {}
3592
5430
  }, { status: 201 });
@@ -3625,6 +5463,462 @@ function publicFormRoutes(config) {
3625
5463
  ];
3626
5464
  }
3627
5465
 
5466
+ // src/routes/form-upload.ts
5467
+ /**
5468
+ * `POST /api/forms/:slug/upload`: a visitor's file, for one file field of an
5469
+ * open form, before the form is submitted.
5470
+ *
5471
+ * A file answer in a submission names media ids. The route stores the file
5472
+ * in the media library (as the plugin itself; a visitor holds no media
5473
+ * permission) and records the upload (`form-uploads`): which form and field
5474
+ * it was made for, and, once a submission names it, that submission. A
5475
+ * submission a request writes may then name only uploads recorded here for
5476
+ * its own form and field that no other submission claimed
5477
+ * (`handlers/upload-claims`), so the files a submission holds are its own,
5478
+ * and are deleted with it.
5479
+ *
5480
+ * Refused unless the form exists and is open now (the reading `GET
5481
+ * /api/forms/:slug` and submitting make), the named field is one of its file
5482
+ * fields, and the file is one the field takes: its types (`accept`, or the
5483
+ * plugin's `uploads.allowedMimeTypes`), and its size (`maxFileSize`, never
5484
+ * above the plugin's `uploads.maxFileSize`). The body is read only up to that
5485
+ * ceiling, so an oversized request is refused without being held in memory.
5486
+ * The media library then judges the file as it judges every upload: its
5487
+ * contents against its claimed type, blocked types, and its name. Its type
5488
+ * is checked against the field again on what the library stored.
5489
+ *
5490
+ * Limited per visitor and form, in the window submissions are, at
5491
+ * {@link UPLOADS_PER_SUBMISSION} times their number: a form may have several
5492
+ * file fields, each taking several files. An upload no submission claims is
5493
+ * deleted after a day.
5494
+ *
5495
+ * @module routes/form-upload
5496
+ */
5497
+ /** How many uploads a visitor may make per submission the limit allows. */
5498
+ const UPLOADS_PER_SUBMISSION = 5;
5499
+ /** Room in a request body for the multipart framing and the field's name. */
5500
+ const MULTIPART_OVERHEAD = 65536;
5501
+ /** What a visitor is told about a file of a type the field does not take. */
5502
+ const TYPE_REFUSED = "This type of file is not accepted here.";
5503
+ /** The multipart body's text part: which field the file is for. */
5504
+ const UPLOAD_PARTS = z.object({ field: z.string().trim().min(1).max(200) });
5505
+ /** A refusal of the request's `path` part, as a validation error. */
5506
+ function refusal$1(path, code, message, log = {}) {
5507
+ return NextlyError.validation({
5508
+ errors: [{
5509
+ path,
5510
+ code,
5511
+ message
5512
+ }],
5513
+ logContext: log
5514
+ });
5515
+ }
5516
+ function tooLarge(limit) {
5517
+ return refusal$1("file", "SIZE_EXCEEDED", `This file is too large. The limit is ${readableBytes(limit)}.`);
5518
+ }
5519
+ /** The form at `slug` if it takes submissions now, refused as submit is. */
5520
+ async function openFormAt(slug, ctx, config) {
5521
+ const { doc, availability } = await formAt(slug, ctx, config);
5522
+ if (availability.kind === "closed") throw NextlyError.conflict({
5523
+ reason: "state",
5524
+ message: availability.message,
5525
+ logContext: {
5526
+ entity: "form",
5527
+ slug,
5528
+ reason: "closed"
5529
+ }
5530
+ });
5531
+ if (availability.kind !== "open" || typeof doc?.id !== "string") throw noSuchForm(slug);
5532
+ return doc;
5533
+ }
5534
+ /**
5535
+ * Count this upload against the visitor's limit for the form, refusing one
5536
+ * over it. A visitor whose address cannot be trusted is not limited, as
5537
+ * their submissions are not: one shared window for every such visitor would
5538
+ * let the first bot lock out everyone behind an untrusted proxy.
5539
+ */
5540
+ async function holdUploadRate(req, formId, config) {
5541
+ const limit = config.spamProtection.rateLimit;
5542
+ const ip = trustedClientIp(req);
5543
+ if (!limit || !ip) return;
5544
+ const result = await countAgainstLimit(`form-upload:${formId}:${ip}`, limit.maxSubmissions * UPLOADS_PER_SUBMISSION, limit.windowMs);
5545
+ if (result.allowed) return;
5546
+ throw NextlyError.rateLimited({
5547
+ retryAfterSeconds: Math.max(1, Math.ceil((result.resetAt.getTime() - Date.now()) / 1e3)),
5548
+ logContext: {
5549
+ formId,
5550
+ reason: "form-upload-rate"
5551
+ }
5552
+ });
5553
+ }
5554
+ /**
5555
+ * The request body, read to `max` bytes and no further: a body past it is
5556
+ * refused, and its stream cancelled, before it is held whole.
5557
+ */
5558
+ async function readCapped(req, max) {
5559
+ if (!req.body) return Buffer.alloc(0);
5560
+ const reader = req.body.getReader();
5561
+ const chunks = [];
5562
+ let total = 0;
5563
+ for (;;) {
5564
+ const { done, value } = await reader.read();
5565
+ if (done) return Buffer.concat(chunks);
5566
+ total += value.byteLength;
5567
+ if (total > max) {
5568
+ await reader.cancel();
5569
+ throw tooLarge(max - MULTIPART_OVERHEAD);
5570
+ }
5571
+ chunks.push(value);
5572
+ }
5573
+ }
5574
+ /** The multipart body, refused when it is not one or is larger than `limit`. */
5575
+ async function readUploadBody(req, limit) {
5576
+ const max = limit + MULTIPART_OVERHEAD;
5577
+ const declared = Number(req.headers.get("content-length") ?? NaN);
5578
+ if (Number.isFinite(declared) && declared > max) throw tooLarge(limit);
5579
+ const type = req.headers.get("content-type") ?? "";
5580
+ if (!type.toLowerCase().startsWith("multipart/form-data")) throw refusal$1("file", "INVALID", "Send the file as multipart form data.");
5581
+ const bytes = await readCapped(req, max);
5582
+ try {
5583
+ return await new Response(new Uint8Array(bytes), { headers: { "content-type": type } }).formData();
5584
+ } catch {
5585
+ throw refusal$1("file", "INVALID", "The upload could not be read.");
5586
+ }
5587
+ }
5588
+ /** The form's file field named `name`, or a refusal naming `field`. */
5589
+ function fileFieldOf(form, name) {
5590
+ const parsed = UPLOAD_PARTS.safeParse({ field: name });
5591
+ const fields = Array.isArray(form.fields) ? form.fields : [];
5592
+ const found = parsed.success ? fields.find((field) => {
5593
+ const candidate = field ?? {};
5594
+ return candidate.type === "file" && candidate.name === parsed.data.field;
5595
+ }) : void 0;
5596
+ if (!found) throw refusal$1("field", "INVALID", "This form has no file field by that name.");
5597
+ return found;
5598
+ }
5599
+ /** The one file the body carries, or a refusal. */
5600
+ function theFile(body) {
5601
+ const parts = body.getAll("file");
5602
+ const [file] = parts;
5603
+ if (parts.length !== 1 || typeof file === "string" || !file) throw refusal$1("file", "MISSING_FIELD", "Choose one file to upload.");
5604
+ if (file.size === 0) throw refusal$1("file", "INVALID", "This file is empty.");
5605
+ return file;
5606
+ }
5607
+ /** Whether this install lets visitors upload files at all. */
5608
+ function uploadsEnabled(config) {
5609
+ return config.features.fileUploads !== false && config.fields.file !== false;
5610
+ }
5611
+ /** Delete a stored file the upload then refused; one already gone is fine. */
5612
+ async function discard(ctx, mediaId) {
5613
+ try {
5614
+ await ctx.services.media.delete(mediaId, { as: "system" });
5615
+ } catch (error) {
5616
+ if (!NextlyError.isNotFound(error)) throw error;
5617
+ }
5618
+ }
5619
+ /** Store the file, judged by the field again on the type the library kept. */
5620
+ async function storeFile(ctx, file, patterns) {
5621
+ const buffer = Buffer.from(await file.arrayBuffer());
5622
+ const stored = await ctx.services.media.upload({
5623
+ buffer,
5624
+ filename: safeUploadName(file.name),
5625
+ mimeType: file.type,
5626
+ size: buffer.length
5627
+ }, { as: "system" });
5628
+ const kept = {
5629
+ name: stored.originalFilename,
5630
+ type: stored.mimeType
5631
+ };
5632
+ if (!fileMatchesAccept(kept, patterns)) {
5633
+ await discard(ctx, stored.id);
5634
+ throw refusal$1("file", "INVALID_TYPE", TYPE_REFUSED, { mimeType: stored.mimeType });
5635
+ }
5636
+ return stored;
5637
+ }
5638
+ /** Record the upload, or take its file back when it cannot be recorded. */
5639
+ async function recordUpload(ctx, upload) {
5640
+ const slug = ctx.self.collections["form-uploads"] ?? "form-uploads";
5641
+ try {
5642
+ await ctx.services.collections.createEntry(slug, {
5643
+ ...upload,
5644
+ submission: null
5645
+ });
5646
+ } catch (error) {
5647
+ await discard(ctx, upload.media);
5648
+ throw error;
5649
+ }
5650
+ }
5651
+ async function acceptUpload(req, ctx, config, slug) {
5652
+ const form = await openFormAt(slug, ctx, config);
5653
+ if (!uploadsEnabled(config)) throw refusal$1("field", "INVALID", "This form takes no files.");
5654
+ await holdUploadRate(req, form.id, config);
5655
+ const body = await readUploadBody(req, config.uploads.maxFileSize);
5656
+ const field = fileFieldOf(form, body.get("field"));
5657
+ const file = theFile(body);
5658
+ const limit = uploadLimitOf(field, config.uploads);
5659
+ if (file.size > limit) throw tooLarge(limit);
5660
+ const patterns = acceptPatternsOf(field, config.uploads);
5661
+ if (!fileMatchesAccept({
5662
+ name: file.name,
5663
+ type: file.type
5664
+ }, patterns)) throw refusal$1("file", "INVALID_TYPE", TYPE_REFUSED);
5665
+ const stored = await storeFile(ctx, file, patterns);
5666
+ await recordUpload(ctx, {
5667
+ media: stored.id,
5668
+ form: form.id,
5669
+ field: field.name
5670
+ });
5671
+ return respondAction("File uploaded.", {
5672
+ mediaId: stored.id,
5673
+ field: field.name,
5674
+ filename: stored.originalFilename,
5675
+ mimeType: stored.mimeType,
5676
+ size: stored.size
5677
+ }, { status: 201 });
5678
+ }
5679
+ /** The upload route, at the root mount beside the other form endpoints. */
5680
+ function formUploadRoute(config) {
5681
+ return {
5682
+ method: "POST",
5683
+ path: "/forms/:slug/upload",
5684
+ mount: "root",
5685
+ public: true,
5686
+ handler: (req, ctx) => acceptUpload(req, ctx, config, ctx.params.slug)
5687
+ };
5688
+ }
5689
+
5690
+ // src/routes/submission-sends.ts
5691
+ /**
5692
+ * The routes a submission's emails are read and sent again through:
5693
+ *
5694
+ * - `GET /submissions/:id/sends`: each notification the submission sent,
5695
+ * every recipient with its latest result, whether a send is in progress,
5696
+ * and the recipients it can address now, for Resend. Asks reading
5697
+ * submissions, and reads the submission as the person.
5698
+ * - `GET /notification-sends?form=&notification=&limit=`: a notification's
5699
+ * recent sends across its form's submissions, newest first. Asks reading
5700
+ * submissions.
5701
+ * - `POST /submissions/:id/resend`: Resend (`handlers/resend`). Asks updating
5702
+ * submissions, and managing email deliveries before anything else.
5703
+ *
5704
+ * Lists answer `{ items, meta }`, Resend `{ message, item }`.
5705
+ *
5706
+ * @module routes/submission-sends
5707
+ */
5708
+ /** A list that is whole: one page holding every item. */
5709
+ function wholeList(total) {
5710
+ return toPageMeta({
5711
+ total,
5712
+ limit: Math.max(total, 1),
5713
+ offset: 0
5714
+ });
5715
+ }
5716
+ /**
5717
+ * The recipients each notification can address now, from the form and the
5718
+ * answers as they are, each marked as reached when a send of it has.
5719
+ */
5720
+ async function withAddressable(ctx, deps, submission, groups) {
5721
+ const form = await ctx.services.ownCollections.findEntryById(deps.formsSlug, formIdOf(submission.form) ?? "");
5722
+ const notifications = Array.isArray(form.notifications) ? form.notifications : [];
5723
+ return Promise.all(groups.map(async (group) => {
5724
+ const notification = notifications.find((candidate) => candidate.id === group.notification);
5725
+ const email = notification ? deps.build(form, submission, notification) : void 0;
5726
+ if (!notification || !email) return {
5727
+ ...group,
5728
+ addressable: []
5729
+ };
5730
+ return {
5731
+ ...group,
5732
+ addressable: await addressableFor(ctx, email, notification, group.recipients)
5733
+ };
5734
+ }));
5735
+ }
5736
+ /** `GET /submissions/:id/sends`. */
5737
+ async function listSubmissionSends(ctx, deps) {
5738
+ const submission = await ctx.services.collections.findEntryById(deps.submissionsSlug, ctx.params.id ?? "", {
5739
+ as: "user",
5740
+ user: ctx.user ?? void 0
5741
+ });
5742
+ const groups = await withAddressable(ctx, deps, submission, await submissionSends(ctx, String(submission.id)));
5743
+ return respondList(groups, wholeList(groups.length));
5744
+ }
5745
+ const RECENT = z.object({
5746
+ form: z.string().min(1).max(200),
5747
+ notification: z.string().min(1).max(200),
5748
+ limit: z.coerce.number().int().min(1).max(50).default(10)
5749
+ });
5750
+ /** `GET /notification-sends`. */
5751
+ async function listRecentSends(req, ctx, deps) {
5752
+ const query = RECENT.parse(Object.fromEntries(new URL(req.url).searchParams.entries()));
5753
+ const sends = await recentSends(ctx, query.form, query.notification, query.limit);
5754
+ const ids = [...new Set(sends.map((send) => send.submission))];
5755
+ const { data } = await ctx.services.collections.listEntries(deps.submissionsSlug, {
5756
+ where: { id: { in: ids } },
5757
+ pagination: { limit: Math.max(ids.length, 1) },
5758
+ depth: 0
5759
+ }, {
5760
+ as: "user",
5761
+ user: ctx.user ?? void 0
5762
+ });
5763
+ const titles = new Map(data.map((row) => [String(row.id), typeof row.title === "string" ? row.title : ""]));
5764
+ const items = sends.filter((send) => titles.has(send.submission)).map((send) => ({
5765
+ ...send,
5766
+ title: titles.get(send.submission) ?? ""
5767
+ }));
5768
+ return respondList(items, wholeList(items.length));
5769
+ }
5770
+ /** The routes, asked of the submissions collection as installed. */
5771
+ function submissionSendsRoutes(options) {
5772
+ const may = (action) => ({ collection }) => collection(options.submissionsDeclared, action);
5773
+ return [
5774
+ {
5775
+ method: "GET",
5776
+ path: "/submissions/:id/sends",
5777
+ requiredPermission: may("read"),
5778
+ handler: (_req, ctx) => listSubmissionSends(ctx, options.deps(ctx))
5779
+ },
5780
+ {
5781
+ method: "GET",
5782
+ path: "/notification-sends",
5783
+ requiredPermission: may("read"),
5784
+ handler: (req, ctx) => listRecentSends(req, ctx, options.deps(ctx))
5785
+ },
5786
+ {
5787
+ method: "POST",
5788
+ path: "/submissions/:id/resend",
5789
+ requiredPermission: may("update"),
5790
+ handler: (req, ctx) => resendRoute(req, ctx, options.deps(ctx))
5791
+ }
5792
+ ];
5793
+ }
5794
+
5795
+ // src/routes/submissions-export.ts
5796
+ /**
5797
+ * The most rows one export holds. Past it the export is cut short and says
5798
+ * so in a header, never silently: an unbounded read of a busy form's table
5799
+ * would hold all of it in memory.
5800
+ */
5801
+ const MAX_EXPORT_ROWS = 5e4;
5802
+ /** How many rows each read of the export asks for. */
5803
+ const READ_PAGE_SIZE = 200;
5804
+ /** The most submissions one export may name: a list's page shows far fewer. */
5805
+ const MAX_IDS = 500;
5806
+ /** The ticked rows an export names, as the list sends them. */
5807
+ const IDS = z.array(z.string().trim().min(1).max(128)).min(1).max(MAX_IDS);
5808
+ /** The request read from its address, or why it cannot be answered. */
5809
+ function readRequest(url) {
5810
+ const params = url.searchParams;
5811
+ const named = params.get("ids");
5812
+ const ids = named === null ? void 0 : IDS.safeParse(named.split(","));
5813
+ if (ids !== void 0 && !ids.success) return `"ids" names 1 to ${MAX_IDS} submissions, separated by commas.`;
5814
+ return {
5815
+ format: params.get("format") === "csv" ? "csv" : "json",
5816
+ scope: scopeFromSearch(url.search),
5817
+ ...ids === void 0 ? {} : { ids: ids.data }
5818
+ };
5819
+ }
5820
+ /**
5821
+ * Which submissions the export reads. Ticked rows are exported as ticked,
5822
+ * whatever their status is by now, but only from the form named, so a CSV
5823
+ * headed by one form's labels never holds another form's row.
5824
+ */
5825
+ function exportWhere(request) {
5826
+ if (request.ids === void 0) return listWhere(request.scope);
5827
+ return {
5828
+ id: { in: request.ids },
5829
+ ...request.scope.form ? { form: { equals: request.scope.form } } : {}
5830
+ };
5831
+ }
5832
+ /** A refused export, in the canonical error envelope. */
5833
+ function refusal(status, code, message) {
5834
+ return Response.json({ error: {
5835
+ code,
5836
+ message
5837
+ } }, { status });
5838
+ }
5839
+ /** How the export reads: as the signed-in person, under their read rules. */
5840
+ function readerOf(ctx) {
5841
+ return {
5842
+ as: "user",
5843
+ user: ctx.user ?? void 0
5844
+ };
5845
+ }
5846
+ /** The form a CSV is headed by, read as the person; none if they cannot. */
5847
+ async function readForm(ctx, formsSlug, formId) {
5848
+ return (await ctx.services.collections.listEntries(formsSlug, { where: { id: { equals: formId } } }, readerOf(ctx))).data[0];
5849
+ }
5850
+ /**
5851
+ * Every submission `where` matches, newest first as the list orders them,
5852
+ * page by page up to MAX_EXPORT_ROWS, and whether rows were left out.
5853
+ */
5854
+ async function readRows(ctx, slug, where) {
5855
+ const items = [];
5856
+ for (let page = 1;; page += 1) {
5857
+ const result = await ctx.services.collections.listEntries(slug, {
5858
+ where,
5859
+ sort: {
5860
+ field: "submittedAt",
5861
+ direction: "desc"
5862
+ },
5863
+ pagination: {
5864
+ limit: READ_PAGE_SIZE,
5865
+ page
5866
+ }
5867
+ }, readerOf(ctx));
5868
+ items.push(...result.data);
5869
+ const pageWasFull = result.data.length === READ_PAGE_SIZE;
5870
+ if (items.length >= MAX_EXPORT_ROWS) {
5871
+ const truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
5872
+ return {
5873
+ items: items.slice(0, MAX_EXPORT_ROWS),
5874
+ truncated
5875
+ };
5876
+ }
5877
+ if (!pageWasFull) return {
5878
+ items,
5879
+ truncated: false
5880
+ };
5881
+ }
5882
+ }
5883
+ /** The headers every export answers with: the file's name, and any cut. */
5884
+ function fileHeaders(filename, truncated) {
5885
+ return {
5886
+ "Content-Disposition": `attachment; filename="${filename}"`,
5887
+ ...truncated ? { "X-Export-Truncated": "true" } : {}
5888
+ };
5889
+ }
5890
+ /** The rows as a CSV headed by `form`'s labels. */
5891
+ function csvResponse(items, form, truncated) {
5892
+ const csv = exportToCSV(asSubmissionDocuments(items), asFormDocument(form));
5893
+ const filename = generateExportFilename(typeof form.slug === "string" ? form.slug : "form", "csv");
5894
+ return new Response(csv, { headers: {
5895
+ "Content-Type": "text/csv; charset=utf-8",
5896
+ ...fileHeaders(filename, truncated)
5897
+ } });
5898
+ }
5899
+ /** The export route, reading this install's collections. */
5900
+ function submissionsExportRoute(config) {
5901
+ return {
5902
+ method: "GET",
5903
+ path: "/submissions/export",
5904
+ requiredPermission: "export-submissions",
5905
+ handler: async (req, ctx) => {
5906
+ const request = readRequest(new URL(req.url));
5907
+ if (typeof request === "string") return refusal(400, "VALIDATION_ERROR", request);
5908
+ const slugs = installedSlugs(config, ctx.self);
5909
+ let form;
5910
+ if (request.format === "csv") {
5911
+ if (!request.scope.form) return refusal(400, "VALIDATION_ERROR", "CSV export requires a form parameter.");
5912
+ form = await readForm(ctx, slugs.forms, request.scope.form);
5913
+ if (!form) return refusal(404, "NOT_FOUND", "Form not found.");
5914
+ }
5915
+ const { items, truncated } = await readRows(ctx, slugs.submissions, exportWhere(request));
5916
+ if (form) return csvResponse(items, form, truncated);
5917
+ return Response.json({ items }, { headers: fileHeaders(generateExportFilename("form", "json"), truncated) });
5918
+ }
5919
+ };
5920
+ }
5921
+
3628
5922
  // src/plugin.ts
3629
5923
  /**
3630
5924
  * Form Builder Plugin
@@ -3672,7 +5966,8 @@ function resolveConfig(options) {
3672
5966
  file: options.fields?.file ?? true,
3673
5967
  date: options.fields?.date ?? true,
3674
5968
  time: options.fields?.time ?? true,
3675
- hidden: options.fields?.hidden ?? true
5969
+ hidden: options.fields?.hidden ?? true,
5970
+ consent: options.fields?.consent ?? true
3676
5971
  },
3677
5972
  redirectRelationships: normalizeRedirectRelationships(options.redirectRelationships),
3678
5973
  redirectTargetLocalization: {},
@@ -3738,6 +6033,10 @@ function formBuilder(options = {}) {
3738
6033
  const resolvedConfig = resolveConfig(options);
3739
6034
  const formsCol = formsCollection(resolvedConfig);
3740
6035
  const submissionsCol = submissionsCollection(resolvedConfig);
6036
+ const submissionFields = submissionExtensionFields(resolvedConfig);
6037
+ const sendsCol = submissionSendsCollection();
6038
+ const uploadsCol = formUploadsCollection();
6039
+ const resendsCol = submissionResendsCollection();
3741
6040
  let installedSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
3742
6041
  const newSubmissions = newSubmissionsWidget({
3743
6042
  forms: resolvedConfig.formOverrides.slug,
@@ -3745,6 +6044,7 @@ function formBuilder(options = {}) {
3745
6044
  }, () => installedSubmissionsSlug);
3746
6045
  const outbound = botCheckHosts(resolvedConfig.spamProtection);
3747
6046
  let rebuildDeps;
6047
+ let retentionDeps;
3748
6048
  return {
3749
6049
  plugin: definePlugin({
3750
6050
  name: "@nextlyhq/plugin-form-builder",
@@ -3762,10 +6062,19 @@ function formBuilder(options = {}) {
3762
6062
  "email-notifications"
3763
6063
  ],
3764
6064
  contributes: {
3765
- collections: [formsCol, submissionsCol],
6065
+ collections: [
6066
+ formsCol,
6067
+ submissionsCol,
6068
+ sendsCol,
6069
+ uploadsCol,
6070
+ resendsCol
6071
+ ],
3766
6072
  jobs: [rebuildKeysJob(async (deadline) => {
3767
6073
  if (rebuildDeps) await rebuildResponseKeys(rebuildDeps, deadline);
6074
+ }), retentionJob(async (deadline, cursor) => {
6075
+ if (retentionDeps) await runRetention(retentionDeps, deadline, cursor);
3768
6076
  })],
6077
+ schedules: [RETENTION_SCHEDULE],
3769
6078
  dashboard: { widgets: [newSubmissions] },
3770
6079
  admin: { views: { [resolvedConfig.formOverrides.slug]: { documentViews: addressedFormPages().map((page) => ({
3771
6080
  ...page,
@@ -3785,71 +6094,8 @@ function formBuilder(options = {}) {
3785
6094
  }],
3786
6095
  routes: [
3787
6096
  ...publicFormRoutes(resolvedConfig),
3788
- {
3789
- method: "GET",
3790
- path: "/submissions/export",
3791
- requiredPermission: "export-submissions",
3792
- handler: async (req, ctx) => {
3793
- const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
3794
- const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
3795
- const url = new URL(req.url);
3796
- const format = url.searchParams.get("format") ?? "json";
3797
- const formId = url.searchParams.get("form");
3798
- const status = url.searchParams.get("status");
3799
- const where = {};
3800
- if (formId) where.form = { equals: formId };
3801
- if (status) where.status = { equals: status };
3802
- else where.status = { not_equals: "spam" };
3803
- const opts = {
3804
- as: "user",
3805
- user: ctx.user ?? void 0
3806
- };
3807
- let form;
3808
- if (format === "csv") {
3809
- if (!formId) return Response.json({ error: {
3810
- code: "VALIDATION_ERROR",
3811
- message: "CSV export requires a form parameter."
3812
- } }, { status: 400 });
3813
- const formsSlugDeclared = resolvedConfig.formOverrides.slug;
3814
- const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
3815
- form = (await ctx.services.collections.listEntries(formsSlug, { where: { id: { equals: formId } } }, opts)).data[0];
3816
- if (!form) return Response.json({ error: {
3817
- code: "NOT_FOUND",
3818
- message: "Form not found."
3819
- } }, { status: 404 });
3820
- }
3821
- const MAX_EXPORT_ROWS = 5e4;
3822
- const items = [];
3823
- const pageSize = 200;
3824
- let truncated = false;
3825
- for (let page = 1;; page += 1) {
3826
- const result = await ctx.services.collections.listEntries(slug, {
3827
- where,
3828
- pagination: {
3829
- limit: pageSize,
3830
- page
3831
- }
3832
- }, opts);
3833
- items.push(...result.data);
3834
- const pageWasFull = result.data.length === pageSize;
3835
- if (items.length >= MAX_EXPORT_ROWS) {
3836
- truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
3837
- items.length = MAX_EXPORT_ROWS;
3838
- break;
3839
- }
3840
- if (!pageWasFull) break;
3841
- }
3842
- const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
3843
- if (format !== "csv" || !form) return Response.json({ items }, { headers: truncationHeaders });
3844
- const csv = exportToCSV(asSubmissionDocuments(items), asFormDocument(form));
3845
- const filename = generateExportFilename(typeof form.slug === "string" ? form.slug : "form", "csv");
3846
- return new Response(csv, { headers: {
3847
- "Content-Type": "text/csv; charset=utf-8",
3848
- "Content-Disposition": `attachment; filename="${filename}"`,
3849
- ...truncationHeaders
3850
- } });
3851
- }
3852
- },
6097
+ formUploadRoute(resolvedConfig),
6098
+ submissionsExportRoute(resolvedConfig),
3853
6099
  {
3854
6100
  method: "GET",
3855
6101
  path: "/builder-config",
@@ -3871,7 +6117,17 @@ function formBuilder(options = {}) {
3871
6117
  submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
3872
6118
  }
3873
6119
  }))
3874
- }
6120
+ },
6121
+ {
6122
+ method: "GET",
6123
+ path: "/submission-fields",
6124
+ requiredPermission: ({ collection }) => collection(resolvedConfig.formSubmissionOverrides.slug, "read"),
6125
+ handler: () => Promise.resolve(Response.json({ fields: submissionFields }))
6126
+ },
6127
+ ...submissionSendsRoutes({
6128
+ submissionsDeclared: resolvedConfig.formSubmissionOverrides.slug,
6129
+ deps: (ctx) => resendDeps(ctx, resolvedConfig)
6130
+ })
3875
6131
  ]
3876
6132
  },
3877
6133
  admin: {
@@ -3895,8 +6151,14 @@ function formBuilder(options = {}) {
3895
6151
  enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
3896
6152
  });
3897
6153
  for (const slug of Object.keys(resolvedConfig.redirectTargetLocalization)) delete resolvedConfig.redirectTargetLocalization[slug];
6154
+ const declaredCollections = nextly.config?.collections ?? [];
3898
6155
  await Promise.all(Object.keys(resolvedConfig.redirectRelationships).map(async (declared) => {
3899
6156
  const slug = nextly.self.collections[declared] ?? declared;
6157
+ const declaration = declaredCollections.find((entry) => typeof entry === "object" && entry !== null && entry.slug === slug);
6158
+ if (declaration !== void 0) {
6159
+ resolvedConfig.redirectTargetLocalization[declared] = declaration.localized === true;
6160
+ return;
6161
+ }
3900
6162
  try {
3901
6163
  const localized = (await nextly.services.collections.getCollection(slug, {}))?.localized;
3902
6164
  if (typeof localized === "boolean") resolvedConfig.redirectTargetLocalization[declared] = localized;
@@ -3913,9 +6175,6 @@ function formBuilder(options = {}) {
3913
6175
  const declaredFormsSlug = resolvedConfig.formOverrides.slug;
3914
6176
  const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
3915
6177
  nextly.hooks.on("beforeChange", submissionSlug, async (context) => prepareSubmissionForWrite(context, formsSlug, nextly));
3916
- nextly.hooks.on("afterCreate", submissionSlug, async (context) => {
3917
- await handleSubmissionCreated(context, resolvedConfig, nextly);
3918
- });
3919
6178
  nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
3920
6179
  const ctx = context;
3921
6180
  if (ctx.data && ctx.data.data !== void 0) {
@@ -3924,14 +6183,12 @@ function formBuilder(options = {}) {
3924
6183
  }
3925
6184
  return ctx.data;
3926
6185
  });
3927
- warnUncheckedBotChecks({
3928
- spam: resolvedConfig.spamProtection,
3929
- forms: async () => (await nextly.services.ownCollections.listEntries(formsSlug, { pagination: { limit: 500 } })).data ?? [],
3930
- readSettings: normalizeFormSettings,
3931
- warn: (message, context) => nextly.logger.warn?.(message, context)
3932
- });
6186
+ const warnLog = (message, context) => nextly.logger.warn?.(message, context);
6187
+ warnUncheckedSiteDefault(resolvedConfig.spamProtection, warnLog);
3933
6188
  nextly.hooks.on("beforeChange", formsSlug, (context) => {
3934
6189
  refuseOneResponseFieldProblem(context);
6190
+ refuseBlankConsentText(context);
6191
+ warnSavedFormCheck(context, resolvedConfig.spamProtection, warnLog);
3935
6192
  return context.data;
3936
6193
  });
3937
6194
  rebuildDeps = {
@@ -3940,6 +6197,20 @@ function formBuilder(options = {}) {
3940
6197
  formsSlug,
3941
6198
  submissionsSlug: submissionSlug
3942
6199
  };
6200
+ const uploadsSlug = nextly.self.collections["form-uploads"] ?? "form-uploads";
6201
+ const uploadCleanup = {
6202
+ own: nextly.services.ownCollections,
6203
+ collections: nextly.services.collections,
6204
+ media: nextly.services.media,
6205
+ uploadsSlug
6206
+ };
6207
+ retentionDeps = {
6208
+ collections: nextly.services.collections,
6209
+ jobs: nextly.services.jobs,
6210
+ formsSlug,
6211
+ submissionsSlug: submissionSlug,
6212
+ uploads: uploadCleanup
6213
+ };
3943
6214
  nextly.hooks.on("beforeChange", formsSlug, async (context) => {
3944
6215
  await stampOneResponseVersion(context, async (formId) => keysFitInSave(nextly, submissionSlug, formId, resolvedConfig.oneResponse.keysInSave));
3945
6216
  return context.data;
@@ -3955,10 +6226,32 @@ function formBuilder(options = {}) {
3955
6226
  forms: formsSlug,
3956
6227
  submissions: submissionSlug
3957
6228
  });
6229
+ nextly.hooks.onInTransaction(submissionSlug, (write) => queueSubmissionNotifications(write, resolvedConfig, nextly, formsSlug));
6230
+ registerForgettingSends(nextly, {
6231
+ forms: formsSlug,
6232
+ submissions: submissionSlug
6233
+ });
6234
+ registerSendHistory(nextly);
6235
+ registerResend(nextly, resendDeps(nextly, resolvedConfig));
6236
+ registerUploadClaims(nextly, {
6237
+ forms: formsSlug,
6238
+ submissions: submissionSlug,
6239
+ uploads: uploadsSlug
6240
+ });
6241
+ registerUploadCleanup(nextly, uploadCleanup, {
6242
+ forms: formsSlug,
6243
+ submissions: submissionSlug
6244
+ });
3958
6245
  nextly.hooks.on("afterRead", formsSlug, (context) => injectSubmissionCount(context, submissionSlug, nextly));
3959
6246
  }
3960
6247
  }),
3961
- collections: [formsCol, submissionsCol],
6248
+ collections: [
6249
+ formsCol,
6250
+ submissionsCol,
6251
+ sendsCol,
6252
+ uploadsCol,
6253
+ resendsCol
6254
+ ],
3962
6255
  config: resolvedConfig
3963
6256
  };
3964
6257
  }
@@ -3980,29 +6273,6 @@ function resolveFieldRef(ref, data) {
3980
6273
  return typeof value === "string" ? value : "";
3981
6274
  }
3982
6275
  /**
3983
- * Scan form fields for file-upload fields marked `attachToEmail` and
3984
- * collect the corresponding mediaIds from the submitted data. Returns
3985
- * an array of `{ mediaId }` objects ready to pass as `attachments` to
3986
- * `emailService.sendWithTemplate()`.
3987
- *
3988
- * Skips empty/missing values silently (optional file fields). Handles
3989
- * both single-value and `multiple: true` file fields.
3990
- *
3991
- * @internal Exported for testing — not part of the public plugin API.
3992
- */
3993
- function collectAttachmentInputs(fields, submittedData) {
3994
- const attachments = [];
3995
- for (const field of fields) {
3996
- if (field.type !== "file" || !field.attachToEmail) continue;
3997
- const value = submittedData[field.name];
3998
- if (value == null) continue;
3999
- if (Array.isArray(value)) {
4000
- for (const item of value) if (typeof item === "string" && item) attachments.push({ mediaId: item });
4001
- } else if (typeof value === "string" && value) attachments.push({ mediaId: value });
4002
- }
4003
- return attachments;
4004
- }
4005
- /**
4006
6276
  * Normalize a JSON column value from a raw DB row into an object. Hook
4007
6277
  * contexts carry the row as stored, so the value may be an already-parsed
4008
6278
  * object (jsonb dialects) or a serialized string (text-storage dialects).
@@ -4052,15 +6322,15 @@ function buildNotificationEmails(input) {
4052
6322
  const key = notification.id || JSON.stringify(notification);
4053
6323
  if (seen.has(key)) continue;
4054
6324
  seen.add(key);
4055
- if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
6325
+ if (!conditionsMet(conditionsOf(notification), submittedData)) {
4056
6326
  skipped.push({
4057
6327
  notificationId: notification.id,
4058
6328
  reason: "condition-unmet"
4059
6329
  });
4060
6330
  continue;
4061
6331
  }
4062
- const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
4063
- if (!to) {
6332
+ const to = recipientsFor(notification, submittedData);
6333
+ if (to === void 0) {
4064
6334
  skipped.push({
4065
6335
  notificationId: notification.id,
4066
6336
  reason: "empty-recipient"
@@ -4088,7 +6358,8 @@ function buildNotificationEmails(input) {
4088
6358
  replyTo,
4089
6359
  cc,
4090
6360
  bcc,
4091
- notificationId: notification.id
6361
+ notificationId: notification.id,
6362
+ attachments: attachmentsFrom(attachFieldsOf(notification, input.fields ?? []), submittedData)
4092
6363
  });
4093
6364
  }
4094
6365
  return {
@@ -4097,6 +6368,19 @@ function buildNotificationEmails(input) {
4097
6368
  };
4098
6369
  }
4099
6370
  /**
6371
+ * Who a notification goes to for these answers: the visitor's address from
6372
+ * the field it names, or the team's addresses, one as a string as before and
6373
+ * several as a list. Every address is trimmed on the way out, so
6374
+ * whitespace-only values count as empty and stray spaces never reach the
6375
+ * provider as headers. Undefined when nobody is left to send to.
6376
+ */
6377
+ function recipientsFor(notification, data) {
6378
+ if (notification.recipientType === "field") return resolveFieldRef(notification.to, data).trim() || void 0;
6379
+ const addresses = teamAddressesOf(notification);
6380
+ if (addresses.length === 0) return void 0;
6381
+ return addresses.length === 1 ? addresses[0] : addresses;
6382
+ }
6383
+ /**
4100
6384
  * Send email notifications after a form submission is created.
4101
6385
  */
4102
6386
  /**
@@ -4256,8 +6540,11 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
4256
6540
  const ctx = context;
4257
6541
  const submission = ctx.data;
4258
6542
  if (!submission || typeof submission !== "object") return ctx.data;
6543
+ markUploadClaims(ctx);
4259
6544
  if (pluginKeyWrite(ctx.operation, submission, ctx.originalData)) return submission;
4260
6545
  delete submission.responseKey;
6546
+ delete submission.asked;
6547
+ delete submission.title;
4261
6548
  const stored = ctx.originalData;
4262
6549
  const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
4263
6550
  if (!formId) return ctx.data;
@@ -4268,12 +6555,15 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
4268
6555
  const incoming = submittedPayload(submission.data !== void 0 ? submission.data : stored?.data);
4269
6556
  const marks = submissionMarks(submission);
4270
6557
  const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);
4271
- const fields = form.fields;
6558
+ markUploadClaims(ctx, form);
6559
+ const storedAnswers = ctx.operation === "create" || stored?.data === void 0 ? void 0 : submittedPayload(stored.data);
6560
+ const fields = consentsKept(form.fields, storedAnswers, incoming);
4272
6561
  const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly, marks) : void 0;
4273
6562
  if (spam?.reason === "rate_limit") throw NextlyError.rateLimited();
4274
6563
  const prepared = prepareSubmission({
4275
6564
  data: incoming,
4276
6565
  fields,
6566
+ fresh: ctx.operation === "create",
4277
6567
  validate: marks?.keepAsEvidence !== true && spam === void 0
4278
6568
  });
4279
6569
  if (prepared.validationErrors) throw NextlyError.validation({ errors: Object.entries(prepared.validationErrors).map(([path, message]) => ({
@@ -4283,14 +6573,18 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
4283
6573
  })) });
4284
6574
  ctx.data = {
4285
6575
  ...submission,
4286
- data: prepared.data,
6576
+ data: {
6577
+ ...prepared.data,
6578
+ ...storedConsents(form, storedAnswers)
6579
+ },
4287
6580
  ...startingStatus(ctx, marks)
4288
6581
  };
4289
6582
  if (spam) {
4290
6583
  ctx.data.status = "spam";
4291
6584
  ctx.data.spamReason = spam.reason ?? null;
4292
6585
  }
4293
- if (ctx.operation !== "create" && submission.data === void 0) stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
6586
+ if (ctx.operation !== "create" && submission.data === void 0) stampDerivedEdit(ctx.data, ctx.user, incoming, ctx.data.data);
6587
+ recordAsked(ctx, form.fields, formId);
4294
6588
  stampRequestAddress(ctx, ctx.data);
4295
6589
  if (ctx.operation === "create") {
4296
6590
  const settings = normalizeFormSettings(form.settings);
@@ -4306,6 +6600,62 @@ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
4306
6600
  return ctx.data;
4307
6601
  }
4308
6602
  /**
6603
+ * Keep with the submission what it was asked (`asked-fields`): the form's
6604
+ * fields as the visitor saw them on create, or as staff edited an answer to
6605
+ * on update. A submission moved to another form is read with that form's
6606
+ * fields from then on; one stored before submissions kept this keeps none.
6607
+ */
6608
+ function recordAsked(ctx, fields, formId) {
6609
+ const row = ctx.data;
6610
+ if (!row) return;
6611
+ const data = submittedPayload(row.data);
6612
+ const stored = ctx.originalData;
6613
+ const movedFrom = stored ? parentFormId(stored) : null;
6614
+ if (ctx.operation === "create" || movedFrom !== null && movedFrom !== formId) {
6615
+ row.asked = askedSnapshotOf(fields, data);
6616
+ titleFrom(row, fields, data);
6617
+ return;
6618
+ }
6619
+ const after = askedAfterEdit(readAskedSnapshot(stored?.asked), fields, stored?.data === void 0 ? {} : submittedPayload(stored.data), data);
6620
+ if (after !== void 0) row.asked = after;
6621
+ titleFrom(row, fields, data);
6622
+ }
6623
+ /** The submission's title: who sent it, from its answers as asked. */
6624
+ function titleFrom(row, fields, data) {
6625
+ const asked = readAskedSnapshot(row.asked);
6626
+ row.title = submissionTitleOf(asked?.fields ?? fields, data) ?? null;
6627
+ }
6628
+ /**
6629
+ * The fields an update of a submission is checked against, refusing one that
6630
+ * would change a consent. A consent keeps what the visitor agreed to and the
6631
+ * text they were shown, so no edit, by staff or through the API, may change
6632
+ * it. Refused rather than quietly kept, so the editor learns their change did
6633
+ * not happen. A consent with no stored consent (the field was added after this
6634
+ * submission, or was another kind of field when it was made) is left out of
6635
+ * the checks: nobody but the visitor can give one, and they were not asked.
6636
+ */
6637
+ function consentsKept(fields, stored, incoming) {
6638
+ if (stored === void 0) return fields;
6639
+ const edited = editedConsents(fields, stored, incoming);
6640
+ if (edited.length > 0) throw NextlyError.validation({ errors: edited.map((path) => ({
6641
+ path,
6642
+ code: "INVALID",
6643
+ message: CONSENT_EDIT_REFUSED
6644
+ })) });
6645
+ return fields.filter((field) => field.type === "consent" === isConsentAnswer(stored[field.name]));
6646
+ }
6647
+ /**
6648
+ * The stored consent answers an update carries over as they were. Checking an
6649
+ * update re-reads a consent with the form's current text, and drops one whose
6650
+ * field the form no longer has; this puts back what the visitor agreed to.
6651
+ */
6652
+ function storedConsents(form, stored) {
6653
+ if (stored === void 0) return {};
6654
+ const kept = {};
6655
+ for (const name of storedConsentNames(form.fields, stored)) if (stored[name] !== void 0) kept[name] = stored[name];
6656
+ return kept;
6657
+ }
6658
+ /**
4309
6659
  * Say so when a visitor's submission reaches a form taking one response per
4310
6660
  * browser without the browser's token: it is accepted, as a browser that
4311
6661
  * cleared its data is, and nothing stops that visitor sending another. Left
@@ -4445,6 +6795,11 @@ function rateLimitKeyFor(form) {
4445
6795
  const { id } = form;
4446
6796
  return typeof id === "string" && id.length > 0 ? id : "unidentified-form";
4447
6797
  }
6798
+ /** How a log line names `form`: its slug, else its id, else not at all. */
6799
+ function formNamed(form) {
6800
+ const name = typeof form.slug === "string" ? form.slug : form.id;
6801
+ return typeof name === "string" ? { form: name } : {};
6802
+ }
4448
6803
  /**
4449
6804
  * Whether this submission looks like a bot's, and why.
4450
6805
  *
@@ -4481,6 +6836,7 @@ async function judgeSubmission(payload, form, http, nextly, marks) {
4481
6836
  return judgeBotCheck({
4482
6837
  settings: normalizeFormSettings(form.settings),
4483
6838
  spam: config.spamProtection,
6839
+ ...formNamed(form),
4484
6840
  tokens: marks?.botTokens ?? botTokensOf(payload),
4485
6841
  ip: http.ip ?? void 0,
4486
6842
  fetch: nextly.fetch,
@@ -4502,54 +6858,157 @@ function stampDerivedEdit(row, user, before, after) {
4502
6858
  row.editedAt = /* @__PURE__ */ new Date();
4503
6859
  row.editedBy = user?.id ?? null;
4504
6860
  }
4505
- async function handleSubmissionCreated(context, config, nextly) {
4506
- if (!config.notifications.enabled) return;
4507
- const submission = context.data;
4508
- if (!submission) return;
4509
- if (submission.status === "spam") return;
4510
- const formId = parentFormId(submission);
4511
- if (!formId) return;
4512
- const declaredFormsSlug = config.formOverrides.slug;
4513
- const form = await fetchParentForm(nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug, formId, nextly);
6861
+ /**
6862
+ * The parent form, read on the submission's own transaction, so a form the
6863
+ * same transaction wrote is seen and no second connection is taken. Read
6864
+ * with no lock: the closing hold takes the one the form needs. Null for a
6865
+ * form that is not there, which sends nothing.
6866
+ */
6867
+ async function formInTransaction(nextly, write, formsSlug, formId) {
6868
+ try {
6869
+ return await nextly.services.ownCollections.findEntryByIdInTransaction(write.transaction, formsSlug, formId, { lock: "none" });
6870
+ } catch (err) {
6871
+ if (!NextlyError.isNotFound(err)) throw err;
6872
+ nextly.logger.error?.("Form Builder: form not found", { formId });
6873
+ return null;
6874
+ }
6875
+ }
6876
+ /**
6877
+ * Queue a new submission's notifications in the transaction that stores it.
6878
+ *
6879
+ * Each active notification whose conditions the answers meet is queued
6880
+ * through the email service's queued send with the write's transaction, and
6881
+ * recorded with the submission in it too, so the submission, its queued
6882
+ * emails and their records commit together or not at all: a stop between
6883
+ * the insert and the commit leaves none of them, and one after it leaves the
6884
+ * emails queued for the jobs trigger to send. Nothing is sent here: each
6885
+ * email's first attempt starts once the outermost transaction commits.
6886
+ *
6887
+ * The `form-builder.beforeEmail` filter runs here, inside the transaction,
6888
+ * so a handler that throws refuses the submission, and a transaction retried
6889
+ * after a version conflict runs it again.
6890
+ */
6891
+ async function queueSubmissionNotifications(write, config, nextly, formsSlug) {
6892
+ const target = submissionToNotify(write, config);
6893
+ if (!target) return;
6894
+ const { submission, formId } = target;
6895
+ const form = await formInTransaction(nextly, write, formsSlug, formId);
4514
6896
  if (!form) return;
4515
6897
  const notifications = Array.isArray(form.notifications) ? form.notifications : [];
4516
- if (notifications.length === 0) return;
4517
6898
  const emailService = nextly.services.email;
4518
- if (!emailService) return;
6899
+ if (notifications.length === 0 || !emailService) return;
4519
6900
  const submittedData = parseJsonColumn(submission.data);
4520
- const fileAttachments = collectAttachmentInputs(Array.isArray(form.fields) ? form.fields : [], submittedData);
6901
+ const fields = Array.isArray(form.fields) ? form.fields : [];
4521
6902
  const { emails, skipped } = buildNotificationEmails({
4522
6903
  notifications,
4523
6904
  submittedData,
4524
6905
  formName: form.name,
4525
- submissionId: submission.id,
6906
+ submissionId: write.id,
4526
6907
  defaultFrom: config.notifications.defaultFrom,
4527
- fields: Array.isArray(form.fields) ? form.fields : []
4528
- });
4529
- for (const skip of skipped) if (skip.reason === "empty-recipient") nextly.logger.warn?.("Form Builder: empty recipient, skipping notification", {
4530
- notificationId: skip.notificationId,
4531
- formSlug: form.slug
4532
- });
4533
- else nextly.logger.debug?.("Form Builder: notification skipped", {
4534
- notificationId: skip.notificationId,
4535
- formSlug: form.slug,
4536
- reason: skip.reason
6908
+ fields
4537
6909
  });
6910
+ logSkippedNotifications(skipped, form.slug, nextly.logger);
4538
6911
  if (emails.length === 0) return;
4539
6912
  const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, {
4540
6913
  form,
4541
6914
  submission
4542
6915
  });
4543
- const send = () => queueNotificationEmails({
6916
+ const submissionId = write.id;
6917
+ await queueNotificationEmails({
4544
6918
  emails: finalEmails,
4545
6919
  emailService,
4546
6920
  logger: nextly.logger,
4547
6921
  formSlug: form.slug,
4548
- submissionId: typeof submission.id === "number" ? `${submission.id}` : typeof submission.id === "string" ? submission.id : "",
4549
- fileAttachments
6922
+ submissionId,
6923
+ fileAttachments: attachmentsFrom(fieldsMarkedToAttach(fields), submittedData),
6924
+ tx: write.db,
6925
+ record: async (sent, email) => recordSend(nextly, write.transaction, submissionId, {
6926
+ ...sent,
6927
+ ...await notificationSendDetails(nextly, {
6928
+ formId,
6929
+ notifications,
6930
+ email
6931
+ })
6932
+ })
4550
6933
  });
4551
- const { afterCommit } = context;
4552
- await (afterCommit ? afterCommit(send) : send());
6934
+ }
6935
+ /**
6936
+ * The new submission a write asks notifications for, and its form's id, or
6937
+ * undefined when it asks for none: not a create, notifications switched off
6938
+ * for the whole plugin, spam, or a row naming no form.
6939
+ */
6940
+ function submissionToNotify(write, config) {
6941
+ if (write.operation !== "create" || !config.notifications.enabled) return;
6942
+ const submission = write.data;
6943
+ if (!submission || submission.status === "spam") return void 0;
6944
+ const formId = parentFormId(submission);
6945
+ return formId ? {
6946
+ submission,
6947
+ formId
6948
+ } : void 0;
6949
+ }
6950
+ /**
6951
+ * Log why each skipped notification was not queued: an empty recipient as a
6952
+ * warning, and unmet conditions or a missing template, which are expected
6953
+ * states rather than faults, at debug level only.
6954
+ */
6955
+ function logSkippedNotifications(skipped, formSlug, logger) {
6956
+ for (const skip of skipped) if (skip.reason === "empty-recipient") logger.warn?.("Form Builder: empty recipient, skipping notification", {
6957
+ notificationId: skip.notificationId,
6958
+ formSlug
6959
+ });
6960
+ else logger.debug?.("Form Builder: notification skipped", {
6961
+ notificationId: skip.notificationId,
6962
+ formSlug,
6963
+ reason: skip.reason
6964
+ });
6965
+ }
6966
+ /**
6967
+ * What Resend builds and queues with, from a context: the notification's
6968
+ * email as `buildNotificationEmails` makes it, whether or not it is active
6969
+ * or its conditions hold (a person chose to send it); the send filters; and
6970
+ * the queue, as a submission's own notifications are queued.
6971
+ */
6972
+ function resendDeps(ctx, config) {
6973
+ const declaredForms = config.formOverrides.slug;
6974
+ const declaredSubmissions = config.formSubmissionOverrides.slug;
6975
+ return {
6976
+ formsSlug: ctx.self.collections[declaredForms] ?? declaredForms,
6977
+ submissionsSlug: ctx.self.collections[declaredSubmissions] ?? declaredSubmissions,
6978
+ build: (form, submission, notification) => buildNotificationEmails({
6979
+ notifications: [{
6980
+ ...notification,
6981
+ enabled: true,
6982
+ condition: void 0,
6983
+ conditions: void 0
6984
+ }],
6985
+ submittedData: parseJsonColumn(submission.data),
6986
+ formName: form.name,
6987
+ submissionId: idText(submission.id),
6988
+ defaultFrom: config.notifications.defaultFrom,
6989
+ fields: Array.isArray(form.fields) ? form.fields : []
6990
+ }).emails[0],
6991
+ filter: (emails, form, submission) => ctx.filters.apply("form-builder.beforeEmail", emails, {
6992
+ form,
6993
+ submission
6994
+ }),
6995
+ queue: (input) => queueNotificationEmails({
6996
+ emails: input.emails,
6997
+ emailService: ctx.services.email,
6998
+ logger: ctx.logger,
6999
+ formSlug: void 0,
7000
+ submissionId: input.key,
7001
+ fileAttachments: [],
7002
+ tx: input.tx,
7003
+ excludeRecipients: input.excludeRecipients,
7004
+ record: input.record
7005
+ })
7006
+ };
7007
+ }
7008
+ /** A submission's id as text: a string or a number by dialect. */
7009
+ function idText(id) {
7010
+ if (typeof id === "number") return `${id}`;
7011
+ return typeof id === "string" ? id : "";
4553
7012
  }
4554
7013
  /**
4555
7014
  * The key a notification is queued under: its submission, its place among
@@ -4565,13 +7024,20 @@ function notificationKey(submissionId, index, notificationId) {
4565
7024
  * Queue each notification, and log where it stands.
4566
7025
  *
4567
7026
  * Queued through the email service's queued send rather than sent once: the
4568
- * queue makes the first attempt at once, and a failure a retry can help is
4569
- * tried again through the job queue, with backoff, until it is sent or fails
4570
- * for good, never dropped. Each carries an idempotency key from its
4571
- * submission and notification (`notificationKey`), so a provider that takes keys delivers it
4572
- * once even if this hook runs again. No log line names a recipient: an
4573
- * address in a log outlives the submission it came from, and the
4574
- * notification's id and the queued email's id are enough to find it.
7027
+ * queue makes the first attempt at once (with `tx`, once that transaction
7028
+ * commits), and a failure a retry can help is tried again through the job
7029
+ * queue, with backoff, until it is sent or fails for good, never dropped.
7030
+ * Each carries an idempotency key from its submission and notification
7031
+ * (`notificationKey`), so a provider that takes keys delivers it once even if
7032
+ * this runs again. No log line names a recipient: an address in a log
7033
+ * outlives the submission it came from, and the notification's id and the
7034
+ * queued email's id are enough to find it.
7035
+ *
7036
+ * A refusal of the email itself (an invalid address) is logged and the
7037
+ * others are still queued. With `tx`, any other failure, and a failure to
7038
+ * record a queued one, is thrown as well as logged: the transaction then
7039
+ * rolls back with the submission, rather than keeping a submission whose
7040
+ * notification was silently never queued.
4575
7041
  */
4576
7042
  async function queueNotificationEmails(input) {
4577
7043
  const { emails, emailService, logger, formSlug, fileAttachments } = input;
@@ -4581,6 +7047,9 @@ async function queueNotificationEmails(input) {
4581
7047
  templateSlug: email.templateSlug,
4582
7048
  notificationId: email.notificationId
4583
7049
  };
7050
+ const files = email.attachments ?? fileAttachments;
7051
+ const sendKey = notificationKey(input.submissionId, index, email.notificationId);
7052
+ let queuedId;
4584
7053
  try {
4585
7054
  const queued = await emailService.queueWithTemplate(email.templateSlug, email.to, email.variables, {
4586
7055
  providerId: email.providerId,
@@ -4588,9 +7057,12 @@ async function queueNotificationEmails(input) {
4588
7057
  replyTo: email.replyTo,
4589
7058
  cc: email.cc,
4590
7059
  bcc: email.bcc,
4591
- attachments: fileAttachments.length > 0 ? fileAttachments : void 0,
4592
- idempotencyKey: notificationKey(input.submissionId, index, email.notificationId)
7060
+ attachments: files.length > 0 ? files : void 0,
7061
+ idempotencyKey: sendKey,
7062
+ ...input.tx !== void 0 ? { tx: input.tx } : {},
7063
+ ...input.excludeRecipients ? { excludeRecipients: input.excludeRecipients } : {}
4593
7064
  });
7065
+ queuedId = queued.id;
4594
7066
  if (queued.state === "failed") logger.error?.("Form Builder: notification failed", {
4595
7067
  ...identity,
4596
7068
  queuedId: queued.id,
@@ -4600,15 +7072,40 @@ async function queueNotificationEmails(input) {
4600
7072
  ...identity,
4601
7073
  queuedId: queued.id,
4602
7074
  state: queued.state,
4603
- attachmentCount: fileAttachments.length
7075
+ attachmentCount: files.length
4604
7076
  });
4605
7077
  } catch (err) {
7078
+ const refused = NextlyError.isCode(err, "VALIDATION_ERROR");
4606
7079
  logger.error?.("Form Builder: notification failed", {
4607
7080
  ...identity,
4608
- retryable: !NextlyError.isCode(err, "VALIDATION_ERROR"),
7081
+ retryable: !refused,
4609
7082
  error: err instanceof Error ? err.message : String(err)
4610
7083
  });
7084
+ if (!refused && input.tx !== void 0) throw err;
4611
7085
  }
7086
+ if (queuedId !== void 0) await recordQueued(input, identity, {
7087
+ queuedId,
7088
+ sendKey
7089
+ }, email);
7090
+ }
7091
+ }
7092
+ /**
7093
+ * Record a queued notification for its submission. A failure is logged; in
7094
+ * a transaction it is thrown too, so the submission and its queued email
7095
+ * roll back with the record. Outside one, the notification is queued either
7096
+ * way, and only its forgetting on deletion would miss it.
7097
+ */
7098
+ async function recordQueued(input, identity, sent, email) {
7099
+ if (!input.record) return;
7100
+ try {
7101
+ await input.record(sent, email);
7102
+ } catch (err) {
7103
+ input.logger.error?.("Form Builder: notification not recorded", {
7104
+ ...identity,
7105
+ queuedId: sent.queuedId,
7106
+ error: err instanceof Error ? err.message : String(err)
7107
+ });
7108
+ if (input.tx !== void 0) throw err;
4612
7109
  }
4613
7110
  }
4614
7111
  /**
@@ -4917,6 +7414,11 @@ function validateFieldByType(field, path, errors) {
4917
7414
  });
4918
7415
  break;
4919
7416
  }
7417
+ case "consent": if (typeof field.agreementText !== "string" || field.agreementText.trim() === "") errors.push({
7418
+ path: `${path}.agreementText`,
7419
+ message: "A consent field needs the text the visitor agrees to",
7420
+ code: "CONSENT_TEXT_REQUIRED"
7421
+ });
4920
7422
  }
4921
7423
  }
4922
7424
  /**
@@ -5538,6 +8040,31 @@ const hidden = (config) => ({
5538
8040
  ...config,
5539
8041
  type: "hidden"
5540
8042
  });
8043
+ /**
8044
+ * Creates a consent field configuration.
8045
+ *
8046
+ * A box the visitor ticks to agree to `agreementText`. It is always required,
8047
+ * and the stored answer keeps the text the visitor was shown, so a later
8048
+ * change to the text leaves what earlier visitors agreed to as it was. Staff
8049
+ * can never edit a submitted consent.
8050
+ *
8051
+ * @param config - Field configuration without the `type` property
8052
+ * @returns Complete consent field configuration
8053
+ *
8054
+ * @example
8055
+ * ```typescript
8056
+ * consent({
8057
+ * name: 'privacy',
8058
+ * label: 'Privacy',
8059
+ * agreementText: 'I agree to the privacy policy',
8060
+ * })
8061
+ * ```
8062
+ */
8063
+ const consent = (config) => ({
8064
+ ...config,
8065
+ type: "consent",
8066
+ required: true
8067
+ });
5541
8068
 
5542
8069
  // src/index.ts
5543
8070
  /**
@@ -5582,5 +8109,5 @@ const hidden = (config) => ({
5582
8109
  */
5583
8110
  const formBuilderPlugin = formBuilder().plugin;
5584
8111
 
5585
- export { BUILT_IN_FORM_FIELD_TYPES, DEFAULT_FORM_SETTINGS, DEFAULT_NOTIFICATION_SETTINGS, NEW_SUBMISSIONS_WIDGET_ID, RESERVED_FORM_SLUGS, applyFieldDefaults, applyFormDefaults, assertValidFormConfig, checkSpam, checkbox, createFormConfig, date, downloadFile, email, evaluateConditions, exportAndDownload, exportToCSV, exportToJSON, file, formBuilder, formBuilderPlugin, formatExportValue, formsCollection, generateExportFilename, generateZodSchema, getFormBuilderConfig, getFormSubmissionStats, getSupportedComparisonOperators, getValidationErrors, hidden, isCheckboxFormField, isEmailFormField, isFileFormField, isFormAcceptingSubmissions, isFormField, isFormFieldType, isKnownFormField, isSelectFormField, isTextFormField, isValidComparisonOperator, number, option, phone, pluralize, radio, select, submissionsCollection, submitForm, text, textarea, time, toTitleCase, transformFormData, url, validateFormConfig, validateFormData, validateSubmission };
8112
+ export { BUILT_IN_FORM_FIELD_TYPES, DEFAULT_FORM_SETTINGS, DEFAULT_NOTIFICATION_SETTINGS, NEW_SUBMISSIONS_WIDGET_ID, RESERVED_FORM_SLUGS, applyFieldDefaults, applyFormDefaults, assertValidFormConfig, checkSpam, checkbox, consent, createFormConfig, date, downloadFile, email, evaluateConditions, exportAndDownload, exportToCSV, exportToJSON, file, formBuilder, formBuilderPlugin, formatExportValue, formsCollection, generateExportFilename, generateZodSchema, getFormBuilderConfig, getFormSubmissionStats, getSupportedComparisonOperators, getValidationErrors, hidden, isCheckboxFormField, isEmailFormField, isFileFormField, isFormAcceptingSubmissions, isFormField, isFormFieldType, isKnownFormField, isSelectFormField, isTextFormField, isValidComparisonOperator, number, option, phone, pluralize, radio, select, submissionsCollection, submitForm, text, textarea, time, toTitleCase, transformFormData, url, validateFormConfig, validateFormData, validateSubmission };
5586
8113
  //# sourceMappingURL=index.js.map