@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/admin/index.d.ts +38 -24
- package/dist/admin/index.js +9155 -6116
- package/dist/admin/index.js.map +1 -1
- package/dist/index.d.ts +35 -5
- package/dist/index.js +2790 -263
- package/dist/index.js.map +1 -1
- package/dist/{field-references-oCuFEwkr.js → submission-title-CVixn15Z.js} +715 -66
- package/dist/submission-title-CVixn15Z.js.map +1 -0
- package/dist/{new-submissions-DGiIhdBF.d.ts → types-XdBrUfi1.d.ts} +108 -41
- package/package.json +10 -10
- package/dist/field-references-oCuFEwkr.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { $ as
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
585
|
+
}), text$2({
|
|
586
586
|
name: "oneResponseField",
|
|
587
587
|
label: "Email field for one response per person"
|
|
588
|
-
}), number$
|
|
588
|
+
}), number$2({
|
|
589
589
|
name: "oneResponseVersion",
|
|
590
590
|
label: "One-response version",
|
|
591
591
|
defaultValue: 0,
|
|
592
592
|
admin: { hidden: true }
|
|
593
|
-
}), number$
|
|
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$
|
|
626
|
+
}), date$2({
|
|
627
627
|
name: "closesAt",
|
|
628
628
|
label: "Closes at"
|
|
629
|
-
}), number$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
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
|
|
818
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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:
|
|
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: "
|
|
1141
|
+
useAsTitle: "title",
|
|
952
1142
|
description: "View and manage form submissions",
|
|
953
1143
|
disableCreate: true,
|
|
954
|
-
components: { views: {
|
|
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
|
|
1196
|
-
*
|
|
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,
|
|
1342
|
-
*
|
|
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)
|
|
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
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1700
|
+
if (said.has(provider)) return;
|
|
1701
|
+
said.add(provider);
|
|
1702
|
+
warn(uncheckedMessage(provider), {
|
|
1386
1703
|
provider,
|
|
1387
|
-
|
|
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
|
|
1840
|
-
|
|
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:
|
|
1843
|
-
|
|
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/
|
|
3467
|
+
// src/handlers/send-record.ts
|
|
2639
3468
|
/**
|
|
2640
|
-
*
|
|
2641
|
-
*
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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=¬ification=&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: [
|
|
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
|
-
|
|
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
|
-
|
|
3928
|
-
|
|
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: [
|
|
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 (
|
|
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
|
|
4063
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
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
|
|
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:
|
|
6906
|
+
submissionId: write.id,
|
|
4526
6907
|
defaultFrom: config.notifications.defaultFrom,
|
|
4527
|
-
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
|
|
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
|
|
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
|
-
|
|
4552
|
-
|
|
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
|
|
4569
|
-
*
|
|
4570
|
-
*
|
|
4571
|
-
*
|
|
4572
|
-
*
|
|
4573
|
-
*
|
|
4574
|
-
*
|
|
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:
|
|
4592
|
-
idempotencyKey:
|
|
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:
|
|
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: !
|
|
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
|