@btst/plugin-form-builder 0.0.0-stage → 1.0.0-beta.0
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/LICENSE.md +17 -0
- package/dist/api/getters.cjs +154 -0
- package/dist/api/getters.mjs +145 -0
- package/dist/api/index.cjs +25 -0
- package/dist/api/index.d.cts +119 -0
- package/dist/api/index.d.mts +119 -0
- package/dist/api/index.d.ts +119 -0
- package/dist/api/index.mjs +6 -0
- package/dist/api/lifecycle-migrations.cjs +15 -0
- package/dist/api/lifecycle-migrations.mjs +13 -0
- package/dist/api/operations.cjs +1093 -0
- package/dist/api/operations.mjs +1082 -0
- package/dist/api/plugin.cjs +201 -0
- package/dist/api/plugin.mjs +192 -0
- package/dist/api/query-key-defs.cjs +43 -0
- package/dist/api/query-key-defs.mjs +39 -0
- package/dist/client/components/forms/form-renderer.cjs +193 -0
- package/dist/client/components/forms/form-renderer.mjs +191 -0
- package/dist/client/components/index.cjs +29 -0
- package/dist/client/components/index.d.cts +97 -0
- package/dist/client/components/index.d.mts +97 -0
- package/dist/client/components/index.d.ts +97 -0
- package/dist/client/components/index.mjs +18 -0
- package/dist/client/components/loading/form-builder-skeleton.cjs +32 -0
- package/dist/client/components/loading/form-builder-skeleton.mjs +30 -0
- package/dist/client/components/loading/form-list-skeleton.cjs +21 -0
- package/dist/client/components/loading/form-list-skeleton.mjs +19 -0
- package/dist/client/components/loading/submissions-skeleton.cjs +34 -0
- package/dist/client/components/loading/submissions-skeleton.mjs +32 -0
- package/dist/client/components/pages/404-page.cjs +28 -0
- package/dist/client/components/pages/404-page.mjs +26 -0
- package/dist/client/components/pages/form-builder-page.cjs +77 -0
- package/dist/client/components/pages/form-builder-page.internal.cjs +217 -0
- package/dist/client/components/pages/form-builder-page.internal.mjs +215 -0
- package/dist/client/components/pages/form-builder-page.mjs +75 -0
- package/dist/client/components/pages/form-list-page.cjs +44 -0
- package/dist/client/components/pages/form-list-page.internal.cjs +282 -0
- package/dist/client/components/pages/form-list-page.internal.mjs +280 -0
- package/dist/client/components/pages/form-list-page.mjs +42 -0
- package/dist/client/components/pages/submissions-page.cjs +64 -0
- package/dist/client/components/pages/submissions-page.internal.cjs +255 -0
- package/dist/client/components/pages/submissions-page.internal.mjs +253 -0
- package/dist/client/components/pages/submissions-page.mjs +62 -0
- package/dist/client/components/shared/default-error.cjs +17 -0
- package/dist/client/components/shared/default-error.mjs +15 -0
- package/dist/client/components/shared/empty-state.cjs +16 -0
- package/dist/client/components/shared/empty-state.mjs +14 -0
- package/dist/client/components/shared/page-wrapper.cjs +29 -0
- package/dist/client/components/shared/page-wrapper.mjs +27 -0
- package/dist/client/components/shared/pagination.cjs +49 -0
- package/dist/client/components/shared/pagination.mjs +47 -0
- package/dist/client/constants.cjs +5 -0
- package/dist/client/constants.mjs +3 -0
- package/dist/client/hooks/form-builder-hooks.cjs +203 -0
- package/dist/client/hooks/form-builder-hooks.mjs +186 -0
- package/dist/client/hooks/form-builder-resource.cjs +13 -0
- package/dist/client/hooks/form-builder-resource.mjs +11 -0
- package/dist/client/hooks/index.cjs +22 -0
- package/dist/client/hooks/index.d.cts +378 -0
- package/dist/client/hooks/index.d.mts +378 -0
- package/dist/client/hooks/index.d.ts +378 -0
- package/dist/client/hooks/index.mjs +1 -0
- package/dist/client/index.cjs +13 -0
- package/dist/client/index.d.cts +410 -0
- package/dist/client/index.d.mts +410 -0
- package/dist/client/index.d.ts +410 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/plugin.cjs +311 -0
- package/dist/client/plugin.mjs +309 -0
- package/dist/client.css +3 -0
- package/dist/db.cjs +99 -0
- package/dist/db.mjs +97 -0
- package/dist/permissions.cjs +95 -0
- package/dist/permissions.d.cts +94 -0
- package/dist/permissions.d.mts +94 -0
- package/dist/permissions.d.ts +94 -0
- package/dist/permissions.mjs +93 -0
- package/dist/query-keys.cjs +140 -0
- package/dist/query-keys.d.cts +392 -0
- package/dist/query-keys.d.mts +392 -0
- package/dist/query-keys.d.ts +392 -0
- package/dist/query-keys.mjs +137 -0
- package/dist/schemas.cjs +91 -0
- package/dist/schemas.mjs +82 -0
- package/dist/shared/plugin-form-builder.BZYGyFgT.d.mts +351 -0
- package/dist/shared/plugin-form-builder.Bbl9NufB.d.cts +408 -0
- package/dist/shared/plugin-form-builder.CSP5HR2O.d.ts +408 -0
- package/dist/shared/plugin-form-builder.Cmk083dA.d.cts +351 -0
- package/dist/shared/plugin-form-builder.D258Qpb8.d.ts +351 -0
- package/dist/shared/plugin-form-builder.DWrQ3lRX.d.cts +118 -0
- package/dist/shared/plugin-form-builder.DWrQ3lRX.d.mts +118 -0
- package/dist/shared/plugin-form-builder.DWrQ3lRX.d.ts +118 -0
- package/dist/shared/plugin-form-builder.lp98rFNl.d.mts +408 -0
- package/dist/style.css +6 -0
- package/dist/types.cjs +2 -0
- package/dist/types.d.cts +6 -0
- package/dist/types.d.mts +6 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.mjs +1 -0
- package/dist/ui/src/components/accordion.cjs +81 -0
- package/dist/ui/src/components/accordion.mjs +62 -0
- package/dist/ui/src/components/alert-dialog.cjs +157 -0
- package/dist/ui/src/components/alert-dialog.mjs +132 -0
- package/dist/ui/src/components/auto-form/common/label.cjs +18 -0
- package/dist/ui/src/components/auto-form/common/label.mjs +16 -0
- package/dist/ui/src/components/auto-form/common/tooltip.cjs +9 -0
- package/dist/ui/src/components/auto-form/common/tooltip.mjs +7 -0
- package/dist/ui/src/components/auto-form/config.cjs +40 -0
- package/dist/ui/src/components/auto-form/config.mjs +37 -0
- package/dist/ui/src/components/auto-form/dependencies.cjs +48 -0
- package/dist/ui/src/components/auto-form/dependencies.mjs +46 -0
- package/dist/ui/src/components/auto-form/fields/array.cjs +266 -0
- package/dist/ui/src/components/auto-form/fields/array.mjs +264 -0
- package/dist/ui/src/components/auto-form/fields/checkbox.cjs +38 -0
- package/dist/ui/src/components/auto-form/fields/checkbox.mjs +36 -0
- package/dist/ui/src/components/auto-form/fields/date.cjs +67 -0
- package/dist/ui/src/components/auto-form/fields/date.mjs +65 -0
- package/dist/ui/src/components/auto-form/fields/enum.cjs +73 -0
- package/dist/ui/src/components/auto-form/fields/enum.mjs +71 -0
- package/dist/ui/src/components/auto-form/fields/input.cjs +32 -0
- package/dist/ui/src/components/auto-form/fields/input.mjs +30 -0
- package/dist/ui/src/components/auto-form/fields/number.cjs +31 -0
- package/dist/ui/src/components/auto-form/fields/number.mjs +29 -0
- package/dist/ui/src/components/auto-form/fields/object.cjs +233 -0
- package/dist/ui/src/components/auto-form/fields/object.mjs +217 -0
- package/dist/ui/src/components/auto-form/fields/radio-group.cjs +67 -0
- package/dist/ui/src/components/auto-form/fields/radio-group.mjs +65 -0
- package/dist/ui/src/components/auto-form/fields/switch.cjs +38 -0
- package/dist/ui/src/components/auto-form/fields/switch.mjs +36 -0
- package/dist/ui/src/components/auto-form/fields/textarea.cjs +31 -0
- package/dist/ui/src/components/auto-form/fields/textarea.mjs +29 -0
- package/dist/ui/src/components/auto-form/helpers.cjs +270 -0
- package/dist/ui/src/components/auto-form/helpers.mjs +259 -0
- package/dist/ui/src/components/auto-form/index.cjs +92 -0
- package/dist/ui/src/components/auto-form/index.mjs +86 -0
- package/dist/ui/src/components/auto-form/stepped-auto-form.cjs +374 -0
- package/dist/ui/src/components/auto-form/stepped-auto-form.mjs +368 -0
- package/dist/ui/src/components/auto-form/types.cjs +11 -0
- package/dist/ui/src/components/auto-form/types.mjs +9 -0
- package/dist/ui/src/components/button.cjs +56 -0
- package/dist/ui/src/components/button.mjs +53 -0
- package/dist/ui/src/components/calendar.cjs +207 -0
- package/dist/ui/src/components/calendar.mjs +190 -0
- package/dist/ui/src/components/checkbox.cjs +48 -0
- package/dist/ui/src/components/checkbox.mjs +32 -0
- package/dist/ui/src/components/date-picker.cjs +41 -0
- package/dist/ui/src/components/date-picker.mjs +39 -0
- package/dist/ui/src/components/dialog.cjs +141 -0
- package/dist/ui/src/components/dialog.mjs +118 -0
- package/dist/ui/src/components/dropdown-menu.cjs +81 -0
- package/dist/ui/src/components/dropdown-menu.mjs +62 -0
- package/dist/ui/src/components/form-builder/canvas.cjs +111 -0
- package/dist/ui/src/components/form-builder/canvas.mjs +109 -0
- package/dist/ui/src/components/form-builder/components/index.cjs +571 -0
- package/dist/ui/src/components/form-builder/components/index.mjs +554 -0
- package/dist/ui/src/components/form-builder/edit-field-dialog.cjs +131 -0
- package/dist/ui/src/components/form-builder/edit-field-dialog.mjs +129 -0
- package/dist/ui/src/components/form-builder/form-preview.cjs +73 -0
- package/dist/ui/src/components/form-builder/form-preview.mjs +71 -0
- package/dist/ui/src/components/form-builder/index.cjs +350 -0
- package/dist/ui/src/components/form-builder/index.mjs +344 -0
- package/dist/ui/src/components/form-builder/nested-field-editor-dialog.cjs +263 -0
- package/dist/ui/src/components/form-builder/nested-field-editor-dialog.mjs +261 -0
- package/dist/ui/src/components/form-builder/palette.cjs +52 -0
- package/dist/ui/src/components/form-builder/palette.mjs +49 -0
- package/dist/ui/src/components/form-builder/schema-utils.cjs +128 -0
- package/dist/ui/src/components/form-builder/schema-utils.mjs +122 -0
- package/dist/ui/src/components/form-builder/sortable-field.cjs +151 -0
- package/dist/ui/src/components/form-builder/sortable-field.mjs +148 -0
- package/dist/ui/src/components/form-builder/step-tabs.cjs +180 -0
- package/dist/ui/src/components/form-builder/step-tabs.mjs +178 -0
- package/dist/ui/src/components/form-builder/types.cjs +7 -0
- package/dist/ui/src/components/form-builder/types.mjs +5 -0
- package/dist/ui/src/components/form-builder/validation-schemas.cjs +67 -0
- package/dist/ui/src/components/form-builder/validation-schemas.mjs +56 -0
- package/dist/ui/src/components/form.cjs +120 -0
- package/dist/ui/src/components/form.mjs +98 -0
- package/dist/ui/src/components/input.cjs +23 -0
- package/dist/ui/src/components/input.mjs +21 -0
- package/dist/ui/src/components/label.cjs +39 -0
- package/dist/ui/src/components/label.mjs +23 -0
- package/dist/ui/src/components/page-layout.cjs +25 -0
- package/dist/ui/src/components/page-layout.mjs +23 -0
- package/dist/ui/src/components/page-wrapper.cjs +20 -0
- package/dist/ui/src/components/page-wrapper.mjs +18 -0
- package/dist/ui/src/components/popover.cjs +60 -0
- package/dist/ui/src/components/popover.mjs +42 -0
- package/dist/ui/src/components/radio-group.cjs +62 -0
- package/dist/ui/src/components/radio-group.mjs +45 -0
- package/dist/ui/src/components/select.cjs +161 -0
- package/dist/ui/src/components/select.mjs +139 -0
- package/dist/ui/src/components/separator.cjs +43 -0
- package/dist/ui/src/components/separator.mjs +27 -0
- package/dist/ui/src/components/skeleton.cjs +17 -0
- package/dist/ui/src/components/skeleton.mjs +15 -0
- package/dist/ui/src/components/stack-attribution.cjs +24 -0
- package/dist/ui/src/components/stack-attribution.mjs +22 -0
- package/dist/ui/src/components/switch.cjs +48 -0
- package/dist/ui/src/components/switch.mjs +32 -0
- package/dist/ui/src/components/table.cjs +89 -0
- package/dist/ui/src/components/table.mjs +82 -0
- package/dist/ui/src/components/tabs.cjs +84 -0
- package/dist/ui/src/components/tabs.mjs +65 -0
- package/dist/ui/src/components/textarea.cjs +20 -0
- package/dist/ui/src/components/textarea.mjs +18 -0
- package/dist/ui/src/lib/schema-converter.cjs +133 -0
- package/dist/ui/src/lib/schema-converter.mjs +127 -0
- package/dist/ui/src/lib/utils.cjs +10 -0
- package/dist/ui/src/lib/utils.mjs +8 -0
- package/dist/utils.cjs +41 -0
- package/dist/utils.mjs +32 -0
- package/package.json +175 -3
- package/README.md +0 -3
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const api = require('@btst/stack/plugins/api');
|
|
4
|
+
const schemaConverter = require('../ui/src/lib/schema-converter.cjs');
|
|
5
|
+
const z = require('zod');
|
|
6
|
+
const permissions = require('../permissions.cjs');
|
|
7
|
+
const schemas = require('../schemas.cjs');
|
|
8
|
+
const utils = require('../utils.cjs');
|
|
9
|
+
const getters = require('./getters.cjs');
|
|
10
|
+
|
|
11
|
+
const SubmissionDataSchema = z.z.record(z.z.string(), z.z.unknown()).transform((value) => value);
|
|
12
|
+
const CreateFormHookResultSchema = schemas.createFormSchema.extend({
|
|
13
|
+
createdBy: z.z.string().min(1).optional()
|
|
14
|
+
});
|
|
15
|
+
const SubmitFormOperationInputSchema = z.z.object({
|
|
16
|
+
slug: z.z.string(),
|
|
17
|
+
data: SubmissionDataSchema
|
|
18
|
+
});
|
|
19
|
+
const UpdateFormOperationInputSchema = z.z.object({
|
|
20
|
+
id: z.z.string(),
|
|
21
|
+
data: schemas.updateFormSchema
|
|
22
|
+
});
|
|
23
|
+
const DeleteFormOperationInputSchema = z.z.object({ id: z.z.string() });
|
|
24
|
+
const GetFormBySlugOperationInputSchema = z.z.object({ slug: z.z.string() });
|
|
25
|
+
const GetFormByIdOperationInputSchema = z.z.object({ id: z.z.string() });
|
|
26
|
+
const GetFormForUpdateOperationInputSchema = z.z.object({
|
|
27
|
+
id: z.z.string()
|
|
28
|
+
});
|
|
29
|
+
const ListSubmissionsOperationInputSchema = z.z.object({
|
|
30
|
+
formId: z.z.string(),
|
|
31
|
+
query: schemas.listSubmissionsQuerySchema
|
|
32
|
+
});
|
|
33
|
+
const GetSubmissionOperationInputSchema = z.z.object({
|
|
34
|
+
formId: z.z.string(),
|
|
35
|
+
submissionId: z.z.string()
|
|
36
|
+
});
|
|
37
|
+
class FormBuilderOperationError extends api.OperationHttpError {
|
|
38
|
+
constructor(statusCode, message, code, issues) {
|
|
39
|
+
super(statusCode, message, code, issues);
|
|
40
|
+
this.name = "FormBuilderOperationError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function snapshotForm(form) {
|
|
44
|
+
return {
|
|
45
|
+
id: form.id,
|
|
46
|
+
slug: form.slug,
|
|
47
|
+
...form.createdBy ? { ownerId: form.createdBy } : {},
|
|
48
|
+
status: form.status,
|
|
49
|
+
updatedAt: form.updatedAt
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function snapshotSubmission(submission) {
|
|
53
|
+
return {
|
|
54
|
+
id: submission.id,
|
|
55
|
+
formId: submission.formId,
|
|
56
|
+
...submission.submittedBy ? { submittedBy: submission.submittedBy } : {},
|
|
57
|
+
submittedAt: submission.submittedAt
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function sameFormSnapshot(form, expected) {
|
|
61
|
+
if (!form || !expected) return form === null && expected === null;
|
|
62
|
+
const current = snapshotForm(form);
|
|
63
|
+
return current.id === expected.id && current.slug === expected.slug && current.ownerId === expected.ownerId && current.status === expected.status && current.updatedAt.getTime() === expected.updatedAt.getTime();
|
|
64
|
+
}
|
|
65
|
+
function sameSubmissionSnapshot(submission, expected) {
|
|
66
|
+
if (!submission) return false;
|
|
67
|
+
const current = snapshotSubmission(submission);
|
|
68
|
+
return current.id === expected.id && current.formId === expected.formId && current.submittedBy === expected.submittedBy && current.submittedAt.getTime() === expected.submittedAt.getTime();
|
|
69
|
+
}
|
|
70
|
+
const AFFECTED_ROW_KEYS = [
|
|
71
|
+
"rowCount",
|
|
72
|
+
"affectedRows",
|
|
73
|
+
"rowsAffected",
|
|
74
|
+
"changes",
|
|
75
|
+
"numUpdatedRows"
|
|
76
|
+
];
|
|
77
|
+
function hasPositiveCount(value) {
|
|
78
|
+
if (typeof value === "number") return Number.isFinite(value) && value > 0;
|
|
79
|
+
if (typeof value === "bigint") return value > 0n;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
function didAffectRow(result, expectedId) {
|
|
83
|
+
if (typeof result === "number" || typeof result === "bigint") {
|
|
84
|
+
return hasPositiveCount(result);
|
|
85
|
+
}
|
|
86
|
+
if (!result || typeof result !== "object") return false;
|
|
87
|
+
const record = result;
|
|
88
|
+
if ("count" in record) return hasPositiveCount(record.count);
|
|
89
|
+
if (Array.isArray(result)) {
|
|
90
|
+
return result.length > 0 && didAffectRow(result[0], expectedId);
|
|
91
|
+
}
|
|
92
|
+
for (const key of AFFECTED_ROW_KEYS) {
|
|
93
|
+
if (key in record) return hasPositiveCount(record[key]);
|
|
94
|
+
}
|
|
95
|
+
if ("meta" in record) {
|
|
96
|
+
const meta = record.meta;
|
|
97
|
+
return Boolean(
|
|
98
|
+
meta && typeof meta === "object" && "changes" in meta && hasPositiveCount(meta.changes)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return record.id === expectedId;
|
|
102
|
+
}
|
|
103
|
+
function formSnapshotWhere(snapshot) {
|
|
104
|
+
return [
|
|
105
|
+
{ field: "id", value: snapshot.id, operator: "eq" },
|
|
106
|
+
{ field: "slug", value: snapshot.slug, operator: "eq" },
|
|
107
|
+
{ field: "status", value: snapshot.status, operator: "eq" },
|
|
108
|
+
{
|
|
109
|
+
field: "createdBy",
|
|
110
|
+
value: snapshot.ownerId ?? null,
|
|
111
|
+
operator: "eq"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
field: "updatedAt",
|
|
115
|
+
value: snapshot.updatedAt,
|
|
116
|
+
operator: "eq"
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
function submissionSnapshotWhere(snapshot) {
|
|
121
|
+
return [
|
|
122
|
+
{ field: "id", value: snapshot.id, operator: "eq" },
|
|
123
|
+
{ field: "formId", value: snapshot.formId, operator: "eq" },
|
|
124
|
+
{
|
|
125
|
+
field: "submittedBy",
|
|
126
|
+
value: snapshot.submittedBy ?? null,
|
|
127
|
+
operator: "eq"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
field: "submittedAt",
|
|
131
|
+
value: snapshot.submittedAt,
|
|
132
|
+
operator: "eq"
|
|
133
|
+
}
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
function nextSnapshotDate(previous) {
|
|
137
|
+
return new Date(Math.max(Date.now(), previous.getTime() + 1));
|
|
138
|
+
}
|
|
139
|
+
function requireAtomicTransactions(adapter) {
|
|
140
|
+
if (adapter.id === "memory" || typeof adapter.options?.adapterConfig.transaction !== "function") {
|
|
141
|
+
throw new FormBuilderOperationError(
|
|
142
|
+
500,
|
|
143
|
+
"Form Builder owner-sensitive writes require an adapter with isolated transaction support.",
|
|
144
|
+
"ATOMIC_TRANSACTION_REQUIRED"
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function claimFormSnapshot(adapter, snapshot, stale) {
|
|
149
|
+
const claimedAt = nextSnapshotDate(snapshot.updatedAt);
|
|
150
|
+
const matched = await adapter.updateMany({
|
|
151
|
+
model: "form",
|
|
152
|
+
where: formSnapshotWhere(snapshot),
|
|
153
|
+
update: { updatedAt: claimedAt }
|
|
154
|
+
});
|
|
155
|
+
if (!didAffectRow(matched, snapshot.id)) throw stale();
|
|
156
|
+
return claimedAt;
|
|
157
|
+
}
|
|
158
|
+
async function restoreFormSnapshot(adapter, snapshot, claimedAt, stale) {
|
|
159
|
+
const restored = await adapter.updateMany({
|
|
160
|
+
model: "form",
|
|
161
|
+
where: formSnapshotWhere({ ...snapshot, updatedAt: claimedAt }),
|
|
162
|
+
update: { updatedAt: snapshot.updatedAt }
|
|
163
|
+
});
|
|
164
|
+
if (!didAffectRow(restored, snapshot.id)) throw stale();
|
|
165
|
+
}
|
|
166
|
+
async function claimSubmissionSnapshot(adapter, snapshot) {
|
|
167
|
+
const claimedAt = nextSnapshotDate(snapshot.submittedAt);
|
|
168
|
+
const matched = await adapter.updateMany({
|
|
169
|
+
model: "formSubmission",
|
|
170
|
+
where: submissionSnapshotWhere(snapshot),
|
|
171
|
+
update: { submittedAt: claimedAt }
|
|
172
|
+
});
|
|
173
|
+
if (!didAffectRow(matched, snapshot.id)) throw staleSubmissionError();
|
|
174
|
+
return claimedAt;
|
|
175
|
+
}
|
|
176
|
+
function staleFormError() {
|
|
177
|
+
return new FormBuilderOperationError(
|
|
178
|
+
409,
|
|
179
|
+
"Form changed while authorization was being evaluated. Retry the operation.",
|
|
180
|
+
"FORM_STATE_CHANGED"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
function staleSubmissionError() {
|
|
184
|
+
return new FormBuilderOperationError(
|
|
185
|
+
409,
|
|
186
|
+
"Submission changed while authorization was being evaluated. Retry the operation.",
|
|
187
|
+
"SUBMISSION_STATE_CHANGED"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
function formNotFoundError() {
|
|
191
|
+
return new FormBuilderOperationError(404, "Form not found", "FORM_NOT_FOUND");
|
|
192
|
+
}
|
|
193
|
+
function submissionNotFoundError() {
|
|
194
|
+
return new FormBuilderOperationError(
|
|
195
|
+
404,
|
|
196
|
+
"Submission not found",
|
|
197
|
+
"SUBMISSION_NOT_FOUND"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
async function findFormById(adapter, id) {
|
|
201
|
+
return await adapter.findOne({
|
|
202
|
+
model: "form",
|
|
203
|
+
where: [{ field: "id", value: id, operator: "eq" }]
|
|
204
|
+
}) ?? null;
|
|
205
|
+
}
|
|
206
|
+
async function findFormBySlug(adapter, slug) {
|
|
207
|
+
return await adapter.findOne({
|
|
208
|
+
model: "form",
|
|
209
|
+
where: [{ field: "slug", value: slug, operator: "eq" }]
|
|
210
|
+
}) ?? null;
|
|
211
|
+
}
|
|
212
|
+
async function findSubmission(adapter, submissionId) {
|
|
213
|
+
return await adapter.findOne({
|
|
214
|
+
model: "formSubmission",
|
|
215
|
+
where: [{ field: "id", value: submissionId, operator: "eq" }]
|
|
216
|
+
}) ?? null;
|
|
217
|
+
}
|
|
218
|
+
function requestFields(request) {
|
|
219
|
+
if (!request) return {};
|
|
220
|
+
return {
|
|
221
|
+
request,
|
|
222
|
+
headers: request.headers,
|
|
223
|
+
...utils.extractIpAddress(request.headers) ? { ipAddress: utils.extractIpAddress(request.headers) } : {},
|
|
224
|
+
...utils.extractUserAgent(request.headers) ? { userAgent: utils.extractUserAgent(request.headers) } : {}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function hookContext(context) {
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
...context,
|
|
230
|
+
...context.identity ? { userId: context.identity.id } : {},
|
|
231
|
+
...requestFields(context.request)
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function submissionHookContext(context, formSlug, formId) {
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
...hookContext(context),
|
|
237
|
+
formSlug,
|
|
238
|
+
formId
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function normalizeError(error, fallback) {
|
|
242
|
+
if (error instanceof Error) return error;
|
|
243
|
+
return new Error(typeof error === "string" ? error : fallback, {
|
|
244
|
+
cause: error
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async function notifyError(hooks, error, operation, context) {
|
|
248
|
+
try {
|
|
249
|
+
await hooks?.onError?.(
|
|
250
|
+
normalizeError(error, "Form Builder operation failed."),
|
|
251
|
+
operation,
|
|
252
|
+
context
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function runDomainHook(run, statusCode, code) {
|
|
258
|
+
try {
|
|
259
|
+
return await run();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw new FormBuilderOperationError(
|
|
262
|
+
statusCode,
|
|
263
|
+
normalizeError(error, "Operation rejected").message,
|
|
264
|
+
code
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function publicReadFacts(slug, form) {
|
|
269
|
+
return {
|
|
270
|
+
slug,
|
|
271
|
+
exists: form !== null,
|
|
272
|
+
...form ? {
|
|
273
|
+
formId: form.id,
|
|
274
|
+
...form.createdBy ? { ownerId: form.createdBy } : {},
|
|
275
|
+
status: form.status
|
|
276
|
+
} : {}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function recordReadFacts(id, form) {
|
|
280
|
+
return {
|
|
281
|
+
scope: "record",
|
|
282
|
+
formId: id,
|
|
283
|
+
exists: form !== null,
|
|
284
|
+
...form ? {
|
|
285
|
+
...form.createdBy ? { ownerId: form.createdBy } : {},
|
|
286
|
+
status: form.status
|
|
287
|
+
} : {}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function resolvedFormFacts(form) {
|
|
291
|
+
return {
|
|
292
|
+
formId: form.id,
|
|
293
|
+
...form.createdBy ? { ownerId: form.createdBy } : {},
|
|
294
|
+
status: form.status
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function submissionRecordFacts(formId, submissionId, form, submission) {
|
|
298
|
+
const exists = submission?.formId === formId && form?.id === formId;
|
|
299
|
+
return {
|
|
300
|
+
scope: "record",
|
|
301
|
+
formId,
|
|
302
|
+
submissionId,
|
|
303
|
+
exists,
|
|
304
|
+
...form?.createdBy ? { ownerId: form.createdBy } : {},
|
|
305
|
+
...exists && submission?.submittedBy ? { submittedBy: submission.submittedBy } : {}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function assertValidJsonSchema(schema) {
|
|
309
|
+
try {
|
|
310
|
+
JSON.parse(schema);
|
|
311
|
+
} catch {
|
|
312
|
+
throw new FormBuilderOperationError(
|
|
313
|
+
400,
|
|
314
|
+
"Invalid JSON Schema",
|
|
315
|
+
"INVALID_FORM_SCHEMA"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function sanitizeSlug(rawSlug) {
|
|
320
|
+
const slug = utils.slugify(rawSlug);
|
|
321
|
+
if (!slug) {
|
|
322
|
+
throw new FormBuilderOperationError(
|
|
323
|
+
400,
|
|
324
|
+
"Invalid slug: must contain at least one alphanumeric character",
|
|
325
|
+
"INVALID_SLUG"
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return slug;
|
|
329
|
+
}
|
|
330
|
+
function duplicateSlugError() {
|
|
331
|
+
return new FormBuilderOperationError(
|
|
332
|
+
409,
|
|
333
|
+
"Form with this slug already exists",
|
|
334
|
+
"DUPLICATE_FORM_SLUG"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
function operationForm(form) {
|
|
338
|
+
const serialized = getters.serializeForm(form);
|
|
339
|
+
return {
|
|
340
|
+
id: serialized.id,
|
|
341
|
+
name: serialized.name,
|
|
342
|
+
slug: serialized.slug,
|
|
343
|
+
description: serialized.description,
|
|
344
|
+
schema: serialized.schema,
|
|
345
|
+
successMessage: serialized.successMessage,
|
|
346
|
+
redirectUrl: serialized.redirectUrl,
|
|
347
|
+
status: serialized.status,
|
|
348
|
+
createdBy: serialized.createdBy,
|
|
349
|
+
createdAt: serialized.createdAt,
|
|
350
|
+
updatedAt: serialized.updatedAt
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function operationFormsResult(result) {
|
|
354
|
+
return {
|
|
355
|
+
items: result.items.map((form) => ({ ...form })),
|
|
356
|
+
total: result.total,
|
|
357
|
+
...result.limit !== void 0 ? { limit: result.limit } : {},
|
|
358
|
+
...result.offset !== void 0 ? { offset: result.offset } : {}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function operationJson(value) {
|
|
362
|
+
if (value === null) return null;
|
|
363
|
+
return JSON.parse(JSON.stringify(value));
|
|
364
|
+
}
|
|
365
|
+
function operationSubmissionWithData(submission) {
|
|
366
|
+
const serialized = getters.serializeFormSubmissionWithData(submission);
|
|
367
|
+
return {
|
|
368
|
+
id: serialized.id,
|
|
369
|
+
formId: serialized.formId,
|
|
370
|
+
data: serialized.data,
|
|
371
|
+
submittedAt: serialized.submittedAt,
|
|
372
|
+
submittedBy: serialized.submittedBy,
|
|
373
|
+
ipAddress: serialized.ipAddress,
|
|
374
|
+
userAgent: serialized.userAgent,
|
|
375
|
+
parsedData: operationJson(serialized.parsedData)
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function operationSubmissionsResult(result) {
|
|
379
|
+
return {
|
|
380
|
+
items: result.items.map(getters.serializeFormSubmissionSummary),
|
|
381
|
+
total: result.total,
|
|
382
|
+
...result.limit !== void 0 ? { limit: result.limit } : {},
|
|
383
|
+
...result.offset !== void 0 ? { offset: result.offset } : {}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function operationSubmissionFormContext(form) {
|
|
387
|
+
return {
|
|
388
|
+
id: form.id,
|
|
389
|
+
name: form.name,
|
|
390
|
+
...form.createdBy ? { createdBy: form.createdBy } : {}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function createFormBuilderOperations(adapter, hooks) {
|
|
394
|
+
const formSnapshots = /* @__PURE__ */ new WeakMap();
|
|
395
|
+
const submissionSnapshots = /* @__PURE__ */ new WeakMap();
|
|
396
|
+
const submittedFormSnapshots = /* @__PURE__ */ new WeakMap();
|
|
397
|
+
const listForms = api.defineOperation({
|
|
398
|
+
input: schemas.listFormsQuerySchema,
|
|
399
|
+
permission: permissions.formBuilderPermissions.form.read,
|
|
400
|
+
facts: () => ({ scope: "collection" }),
|
|
401
|
+
before: async (context) => {
|
|
402
|
+
await runDomainHook(
|
|
403
|
+
() => hooks?.onBeforeListForms?.(hookContext(context)),
|
|
404
|
+
403,
|
|
405
|
+
"LIST_FORMS_REJECTED"
|
|
406
|
+
);
|
|
407
|
+
},
|
|
408
|
+
execute: async ({ input }) => operationFormsResult(await getters.getAllForms(adapter, input)),
|
|
409
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "list", hookContext(context))
|
|
410
|
+
});
|
|
411
|
+
const getFormBySlug = api.defineOperation({
|
|
412
|
+
input: GetFormBySlugOperationInputSchema,
|
|
413
|
+
permission: permissions.formBuilderPermissions.form.render,
|
|
414
|
+
facts: async ({ input }) => {
|
|
415
|
+
const form = await findFormBySlug(adapter, input.slug);
|
|
416
|
+
formSnapshots.set(input, form ? snapshotForm(form) : null);
|
|
417
|
+
return publicReadFacts(input.slug, form);
|
|
418
|
+
},
|
|
419
|
+
before: async (context) => {
|
|
420
|
+
const current = await findFormBySlug(adapter, context.input.slug);
|
|
421
|
+
if (!sameFormSnapshot(
|
|
422
|
+
current,
|
|
423
|
+
formSnapshots.get(context.input) ?? null
|
|
424
|
+
)) {
|
|
425
|
+
throw staleFormError();
|
|
426
|
+
}
|
|
427
|
+
await runDomainHook(
|
|
428
|
+
() => hooks?.onBeforeGetForm?.(context.input.slug, hookContext(context)),
|
|
429
|
+
403,
|
|
430
|
+
"GET_FORM_REJECTED"
|
|
431
|
+
);
|
|
432
|
+
},
|
|
433
|
+
execute: async (context) => {
|
|
434
|
+
const form = await findFormBySlug(adapter, context.input.slug);
|
|
435
|
+
if (!sameFormSnapshot(
|
|
436
|
+
form,
|
|
437
|
+
formSnapshots.get(context.input) ?? null
|
|
438
|
+
)) {
|
|
439
|
+
throw staleFormError();
|
|
440
|
+
}
|
|
441
|
+
if (!form) throw formNotFoundError();
|
|
442
|
+
return operationForm(form);
|
|
443
|
+
},
|
|
444
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "get", hookContext(context))
|
|
445
|
+
});
|
|
446
|
+
const getFormById = api.defineOperation({
|
|
447
|
+
input: GetFormByIdOperationInputSchema,
|
|
448
|
+
permission: permissions.formBuilderPermissions.form.read,
|
|
449
|
+
facts: async ({ input }) => {
|
|
450
|
+
const form = await findFormById(adapter, input.id);
|
|
451
|
+
formSnapshots.set(input, form ? snapshotForm(form) : null);
|
|
452
|
+
return recordReadFacts(input.id, form);
|
|
453
|
+
},
|
|
454
|
+
before: async (context) => {
|
|
455
|
+
const current = await findFormById(adapter, context.input.id);
|
|
456
|
+
if (!sameFormSnapshot(
|
|
457
|
+
current,
|
|
458
|
+
formSnapshots.get(context.input) ?? null
|
|
459
|
+
)) {
|
|
460
|
+
throw staleFormError();
|
|
461
|
+
}
|
|
462
|
+
await runDomainHook(
|
|
463
|
+
() => hooks?.onBeforeGetForm?.(context.input.id, hookContext(context)),
|
|
464
|
+
403,
|
|
465
|
+
"GET_FORM_REJECTED"
|
|
466
|
+
);
|
|
467
|
+
},
|
|
468
|
+
execute: async (context) => {
|
|
469
|
+
const form = await findFormById(adapter, context.input.id);
|
|
470
|
+
if (!sameFormSnapshot(
|
|
471
|
+
form,
|
|
472
|
+
formSnapshots.get(context.input) ?? null
|
|
473
|
+
)) {
|
|
474
|
+
throw staleFormError();
|
|
475
|
+
}
|
|
476
|
+
if (!form) throw formNotFoundError();
|
|
477
|
+
return operationForm(form);
|
|
478
|
+
},
|
|
479
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "get", hookContext(context))
|
|
480
|
+
});
|
|
481
|
+
const getFormForUpdate = api.defineOperation({
|
|
482
|
+
input: GetFormForUpdateOperationInputSchema,
|
|
483
|
+
permission: permissions.formBuilderPermissions.form.update,
|
|
484
|
+
facts: async ({ input }) => {
|
|
485
|
+
const form = await findFormById(adapter, input.id);
|
|
486
|
+
if (!form) throw formNotFoundError();
|
|
487
|
+
formSnapshots.set(input, snapshotForm(form));
|
|
488
|
+
return resolvedFormFacts(form);
|
|
489
|
+
},
|
|
490
|
+
before: async (context) => {
|
|
491
|
+
const current = await findFormById(adapter, context.input.id);
|
|
492
|
+
if (!sameFormSnapshot(
|
|
493
|
+
current,
|
|
494
|
+
formSnapshots.get(context.input) ?? null
|
|
495
|
+
)) {
|
|
496
|
+
throw staleFormError();
|
|
497
|
+
}
|
|
498
|
+
await runDomainHook(
|
|
499
|
+
() => hooks?.onBeforeGetFormForUpdate?.(
|
|
500
|
+
context.input.id,
|
|
501
|
+
hookContext(context)
|
|
502
|
+
),
|
|
503
|
+
403,
|
|
504
|
+
"GET_FORM_REJECTED"
|
|
505
|
+
);
|
|
506
|
+
},
|
|
507
|
+
execute: async (context) => {
|
|
508
|
+
const form = await findFormById(adapter, context.input.id);
|
|
509
|
+
if (!sameFormSnapshot(
|
|
510
|
+
form,
|
|
511
|
+
formSnapshots.get(context.input) ?? null
|
|
512
|
+
)) {
|
|
513
|
+
throw staleFormError();
|
|
514
|
+
}
|
|
515
|
+
if (!form) throw formNotFoundError();
|
|
516
|
+
return operationForm(form);
|
|
517
|
+
},
|
|
518
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "get", hookContext(context))
|
|
519
|
+
});
|
|
520
|
+
const createForm = api.defineOperation({
|
|
521
|
+
input: schemas.createFormSchema,
|
|
522
|
+
permission: permissions.formBuilderPermissions.form.create,
|
|
523
|
+
facts: () => void 0,
|
|
524
|
+
execute: async (context) => {
|
|
525
|
+
const initial = {
|
|
526
|
+
...context.input,
|
|
527
|
+
slug: sanitizeSlug(context.input.slug),
|
|
528
|
+
redirectUrl: context.input.redirectUrl || void 0,
|
|
529
|
+
...context.identity ? { createdBy: context.identity.id } : {}
|
|
530
|
+
};
|
|
531
|
+
assertValidJsonSchema(initial.schema);
|
|
532
|
+
const modified = await runDomainHook(
|
|
533
|
+
async () => await hooks?.onBeforeCreateForm?.(initial, hookContext(context)) ?? initial,
|
|
534
|
+
403,
|
|
535
|
+
"CREATE_FORM_REJECTED"
|
|
536
|
+
);
|
|
537
|
+
const validation = CreateFormHookResultSchema.safeParse(modified);
|
|
538
|
+
if (!validation.success) {
|
|
539
|
+
throw new FormBuilderOperationError(
|
|
540
|
+
400,
|
|
541
|
+
"Invalid form data",
|
|
542
|
+
"FORM_VALIDATION_FAILED",
|
|
543
|
+
validation.error.issues
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
const formInput = {
|
|
547
|
+
...validation.data,
|
|
548
|
+
slug: sanitizeSlug(validation.data.slug),
|
|
549
|
+
...context.identity ? { createdBy: context.identity.id } : {}
|
|
550
|
+
};
|
|
551
|
+
assertValidJsonSchema(formInput.schema);
|
|
552
|
+
return adapter.transaction(async (tx) => {
|
|
553
|
+
const duplicate = await findFormBySlug(tx, formInput.slug);
|
|
554
|
+
if (duplicate) throw duplicateSlugError();
|
|
555
|
+
const now = /* @__PURE__ */ new Date();
|
|
556
|
+
return operationForm(
|
|
557
|
+
await tx.create({
|
|
558
|
+
model: "form",
|
|
559
|
+
data: {
|
|
560
|
+
name: formInput.name,
|
|
561
|
+
slug: formInput.slug,
|
|
562
|
+
description: formInput.description,
|
|
563
|
+
schema: formInput.schema,
|
|
564
|
+
successMessage: formInput.successMessage,
|
|
565
|
+
redirectUrl: formInput.redirectUrl,
|
|
566
|
+
status: formInput.status ?? "active",
|
|
567
|
+
createdBy: formInput.createdBy,
|
|
568
|
+
createdAt: now,
|
|
569
|
+
updatedAt: now
|
|
570
|
+
}
|
|
571
|
+
})
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
},
|
|
575
|
+
after: async (context) => {
|
|
576
|
+
await hooks?.onAfterCreateForm?.(context.result, hookContext(context));
|
|
577
|
+
},
|
|
578
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "create", hookContext(context))
|
|
579
|
+
});
|
|
580
|
+
const updateForm = api.defineOperation({
|
|
581
|
+
input: UpdateFormOperationInputSchema,
|
|
582
|
+
permission: permissions.formBuilderPermissions.form.update,
|
|
583
|
+
facts: async ({ input }) => {
|
|
584
|
+
const form = await findFormById(adapter, input.id);
|
|
585
|
+
if (!form) throw formNotFoundError();
|
|
586
|
+
formSnapshots.set(input, snapshotForm(form));
|
|
587
|
+
return resolvedFormFacts(form);
|
|
588
|
+
},
|
|
589
|
+
execute: async (context) => {
|
|
590
|
+
const authorized = formSnapshots.get(context.input);
|
|
591
|
+
if (!authorized) throw staleFormError();
|
|
592
|
+
requireAtomicTransactions(adapter);
|
|
593
|
+
const initial = {
|
|
594
|
+
...context.input.data,
|
|
595
|
+
...context.input.data.slug !== void 0 ? { slug: sanitizeSlug(context.input.data.slug) } : {}
|
|
596
|
+
};
|
|
597
|
+
if (initial.schema !== void 0) assertValidJsonSchema(initial.schema);
|
|
598
|
+
return adapter.transaction(async (tx) => {
|
|
599
|
+
const latest = await findFormById(tx, context.input.id);
|
|
600
|
+
if (!sameFormSnapshot(latest, authorized)) throw staleFormError();
|
|
601
|
+
const claimedAt = await claimFormSnapshot(
|
|
602
|
+
tx,
|
|
603
|
+
authorized,
|
|
604
|
+
staleFormError
|
|
605
|
+
);
|
|
606
|
+
const modified = await runDomainHook(
|
|
607
|
+
async () => await hooks?.onBeforeUpdateForm?.(
|
|
608
|
+
context.input.id,
|
|
609
|
+
initial,
|
|
610
|
+
hookContext(context)
|
|
611
|
+
) ?? initial,
|
|
612
|
+
403,
|
|
613
|
+
"UPDATE_FORM_REJECTED"
|
|
614
|
+
);
|
|
615
|
+
const validation = schemas.updateFormSchema.safeParse(modified);
|
|
616
|
+
if (!validation.success) {
|
|
617
|
+
throw new FormBuilderOperationError(
|
|
618
|
+
400,
|
|
619
|
+
"Invalid form data",
|
|
620
|
+
"FORM_VALIDATION_FAILED",
|
|
621
|
+
validation.error.issues
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const update = {
|
|
625
|
+
...validation.data,
|
|
626
|
+
...validation.data.slug !== void 0 ? { slug: sanitizeSlug(validation.data.slug) } : {}
|
|
627
|
+
};
|
|
628
|
+
if (update.schema !== void 0) assertValidJsonSchema(update.schema);
|
|
629
|
+
const claimed = await findFormById(tx, authorized.id);
|
|
630
|
+
if (!sameFormSnapshot(claimed, { ...authorized, updatedAt: claimedAt })) {
|
|
631
|
+
throw staleFormError();
|
|
632
|
+
}
|
|
633
|
+
if (update.slug && update.slug !== latest?.slug) {
|
|
634
|
+
const duplicate = await findFormBySlug(tx, update.slug);
|
|
635
|
+
if (duplicate) throw duplicateSlugError();
|
|
636
|
+
}
|
|
637
|
+
const updateData = {
|
|
638
|
+
updatedAt: nextSnapshotDate(claimedAt)
|
|
639
|
+
};
|
|
640
|
+
if (update.name !== void 0) updateData.name = update.name;
|
|
641
|
+
if (update.slug !== void 0) updateData.slug = update.slug;
|
|
642
|
+
if (update.description !== void 0)
|
|
643
|
+
updateData.description = update.description;
|
|
644
|
+
if (update.schema !== void 0) updateData.schema = update.schema;
|
|
645
|
+
if (update.successMessage !== void 0)
|
|
646
|
+
updateData.successMessage = update.successMessage;
|
|
647
|
+
if (update.redirectUrl !== void 0)
|
|
648
|
+
updateData.redirectUrl = update.redirectUrl;
|
|
649
|
+
if (update.status !== void 0) updateData.status = update.status;
|
|
650
|
+
const matched = await tx.updateMany({
|
|
651
|
+
model: "form",
|
|
652
|
+
where: formSnapshotWhere({ ...authorized, updatedAt: claimedAt }),
|
|
653
|
+
update: updateData
|
|
654
|
+
});
|
|
655
|
+
if (!didAffectRow(matched, authorized.id)) throw staleFormError();
|
|
656
|
+
const updated = await findFormById(tx, context.input.id);
|
|
657
|
+
if (!updated) throw formNotFoundError();
|
|
658
|
+
return operationForm(updated);
|
|
659
|
+
});
|
|
660
|
+
},
|
|
661
|
+
after: async (context) => {
|
|
662
|
+
await hooks?.onAfterUpdateForm?.(context.result, hookContext(context));
|
|
663
|
+
},
|
|
664
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "update", hookContext(context))
|
|
665
|
+
});
|
|
666
|
+
const deleteForm = api.defineOperation({
|
|
667
|
+
input: DeleteFormOperationInputSchema,
|
|
668
|
+
permission: permissions.formBuilderPermissions.form.delete,
|
|
669
|
+
facts: async ({ input }) => {
|
|
670
|
+
const form = await findFormById(adapter, input.id);
|
|
671
|
+
if (!form) throw formNotFoundError();
|
|
672
|
+
formSnapshots.set(input, snapshotForm(form));
|
|
673
|
+
return resolvedFormFacts(form);
|
|
674
|
+
},
|
|
675
|
+
execute: async (context) => {
|
|
676
|
+
const authorized = formSnapshots.get(context.input);
|
|
677
|
+
if (!authorized) throw staleFormError();
|
|
678
|
+
requireAtomicTransactions(adapter);
|
|
679
|
+
return adapter.transaction(async (tx) => {
|
|
680
|
+
const current = await findFormById(tx, context.input.id);
|
|
681
|
+
if (!sameFormSnapshot(current, authorized)) throw staleFormError();
|
|
682
|
+
const claimedAt = await claimFormSnapshot(
|
|
683
|
+
tx,
|
|
684
|
+
authorized,
|
|
685
|
+
staleFormError
|
|
686
|
+
);
|
|
687
|
+
await runDomainHook(
|
|
688
|
+
() => hooks?.onBeforeDeleteForm?.(context.input.id, hookContext(context)),
|
|
689
|
+
403,
|
|
690
|
+
"DELETE_FORM_REJECTED"
|
|
691
|
+
);
|
|
692
|
+
const claimed = await findFormById(tx, authorized.id);
|
|
693
|
+
if (!sameFormSnapshot(claimed, { ...authorized, updatedAt: claimedAt })) {
|
|
694
|
+
throw staleFormError();
|
|
695
|
+
}
|
|
696
|
+
await tx.deleteMany({
|
|
697
|
+
model: "formSubmission",
|
|
698
|
+
where: [
|
|
699
|
+
{
|
|
700
|
+
field: "formId",
|
|
701
|
+
value: context.input.id,
|
|
702
|
+
operator: "eq"
|
|
703
|
+
}
|
|
704
|
+
]
|
|
705
|
+
});
|
|
706
|
+
const deleted = await tx.deleteMany({
|
|
707
|
+
model: "form",
|
|
708
|
+
where: [
|
|
709
|
+
{ field: "id", value: authorized.id, operator: "eq" },
|
|
710
|
+
{ field: "updatedAt", value: claimedAt, operator: "eq" }
|
|
711
|
+
]
|
|
712
|
+
});
|
|
713
|
+
if (deleted !== 1) throw staleFormError();
|
|
714
|
+
return { success: true };
|
|
715
|
+
});
|
|
716
|
+
},
|
|
717
|
+
after: async (context) => {
|
|
718
|
+
await hooks?.onAfterDeleteForm?.(context.input.id, hookContext(context));
|
|
719
|
+
},
|
|
720
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "delete", hookContext(context))
|
|
721
|
+
});
|
|
722
|
+
const submitForm = api.defineOperation({
|
|
723
|
+
input: SubmitFormOperationInputSchema,
|
|
724
|
+
permission: permissions.formBuilderPermissions.submission.create,
|
|
725
|
+
facts: async ({ input }) => {
|
|
726
|
+
const form = await findFormBySlug(adapter, input.slug);
|
|
727
|
+
formSnapshots.set(input, form ? snapshotForm(form) : null);
|
|
728
|
+
return {
|
|
729
|
+
slug: input.slug,
|
|
730
|
+
exists: form !== null,
|
|
731
|
+
...form ? {
|
|
732
|
+
formId: form.id,
|
|
733
|
+
...form.createdBy ? { ownerId: form.createdBy } : {},
|
|
734
|
+
status: form.status
|
|
735
|
+
} : {}
|
|
736
|
+
};
|
|
737
|
+
},
|
|
738
|
+
execute: async (context) => {
|
|
739
|
+
const authorized = formSnapshots.get(context.input) ?? null;
|
|
740
|
+
const form = await findFormBySlug(adapter, context.input.slug);
|
|
741
|
+
if (!sameFormSnapshot(form, authorized)) throw staleFormError();
|
|
742
|
+
if (!form) throw formNotFoundError();
|
|
743
|
+
if (!authorized) throw staleFormError();
|
|
744
|
+
requireAtomicTransactions(adapter);
|
|
745
|
+
if (form.status !== "active") {
|
|
746
|
+
throw new FormBuilderOperationError(
|
|
747
|
+
400,
|
|
748
|
+
"Form is not accepting submissions",
|
|
749
|
+
"FORM_NOT_ACTIVE"
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
const jsonSchema = JSON.parse(form.schema);
|
|
754
|
+
const validation = schemaConverter.formSchemaToZod(jsonSchema).safeParse(
|
|
755
|
+
context.input.data
|
|
756
|
+
);
|
|
757
|
+
if (!validation.success) {
|
|
758
|
+
throw new FormBuilderOperationError(
|
|
759
|
+
400,
|
|
760
|
+
"Validation failed",
|
|
761
|
+
"SUBMISSION_VALIDATION_FAILED",
|
|
762
|
+
validation.error.issues
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (error instanceof FormBuilderOperationError) throw error;
|
|
767
|
+
throw new FormBuilderOperationError(
|
|
768
|
+
400,
|
|
769
|
+
"Invalid form data",
|
|
770
|
+
"INVALID_FORM_DATA"
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const lifecycleContext = submissionHookContext(
|
|
774
|
+
context,
|
|
775
|
+
form.slug,
|
|
776
|
+
form.id
|
|
777
|
+
);
|
|
778
|
+
const submittedData = JSON.parse(
|
|
779
|
+
JSON.stringify(context.input.data)
|
|
780
|
+
);
|
|
781
|
+
return adapter.transaction(async (tx) => {
|
|
782
|
+
const current = await findFormBySlug(tx, context.input.slug);
|
|
783
|
+
if (!sameFormSnapshot(current, authorized)) throw staleFormError();
|
|
784
|
+
if (!current || current.status !== "active") throw staleFormError();
|
|
785
|
+
const claimedAt = await claimFormSnapshot(
|
|
786
|
+
tx,
|
|
787
|
+
authorized,
|
|
788
|
+
staleFormError
|
|
789
|
+
);
|
|
790
|
+
const hookResult = await runDomainHook(
|
|
791
|
+
() => hooks?.onBeforeSubmission?.(
|
|
792
|
+
form.slug,
|
|
793
|
+
submittedData,
|
|
794
|
+
lifecycleContext
|
|
795
|
+
),
|
|
796
|
+
400,
|
|
797
|
+
"SUBMISSION_REJECTED"
|
|
798
|
+
);
|
|
799
|
+
const finalData = hookResult ?? submittedData;
|
|
800
|
+
const claimed = await findFormById(tx, authorized.id);
|
|
801
|
+
if (!sameFormSnapshot(claimed, { ...authorized, updatedAt: claimedAt }) || claimed?.status !== "active") {
|
|
802
|
+
throw staleFormError();
|
|
803
|
+
}
|
|
804
|
+
const submission = await tx.create({
|
|
805
|
+
model: "formSubmission",
|
|
806
|
+
data: {
|
|
807
|
+
formId: current.id,
|
|
808
|
+
data: JSON.stringify(finalData),
|
|
809
|
+
submittedAt: /* @__PURE__ */ new Date(),
|
|
810
|
+
...context.identity ? { submittedBy: context.identity.id } : {},
|
|
811
|
+
...lifecycleContext.ipAddress ? { ipAddress: lifecycleContext.ipAddress } : {},
|
|
812
|
+
...lifecycleContext.userAgent ? { userAgent: lifecycleContext.userAgent } : {}
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
await restoreFormSnapshot(tx, authorized, claimedAt, staleFormError);
|
|
816
|
+
const serialized = getters.serializeFormSubmission(submission);
|
|
817
|
+
const result = {
|
|
818
|
+
...serialized,
|
|
819
|
+
form: {
|
|
820
|
+
successMessage: current.successMessage,
|
|
821
|
+
redirectUrl: current.redirectUrl
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
submittedFormSnapshots.set(result, getters.serializeForm(current));
|
|
825
|
+
return result;
|
|
826
|
+
});
|
|
827
|
+
},
|
|
828
|
+
after: async (context) => {
|
|
829
|
+
const form = submittedFormSnapshots.get(context.result);
|
|
830
|
+
if (!form) {
|
|
831
|
+
throw new FormBuilderOperationError(
|
|
832
|
+
500,
|
|
833
|
+
"Submission lifecycle state is unavailable",
|
|
834
|
+
"SUBMISSION_LIFECYCLE_STATE_MISSING"
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
await hooks?.onAfterSubmission?.(
|
|
838
|
+
context.result,
|
|
839
|
+
form,
|
|
840
|
+
submissionHookContext(context, form.slug, form.id)
|
|
841
|
+
);
|
|
842
|
+
},
|
|
843
|
+
onError: async ({ error, ...context }) => {
|
|
844
|
+
const facts = context.facts;
|
|
845
|
+
const formId = facts.formId ?? "";
|
|
846
|
+
const lifecycleContext = submissionHookContext(context, context.input.slug, formId);
|
|
847
|
+
try {
|
|
848
|
+
await hooks?.onErrorSubmission?.(
|
|
849
|
+
normalizeError(error, "Submission failed."),
|
|
850
|
+
context.input.slug,
|
|
851
|
+
JSON.parse(JSON.stringify(context.input.data)),
|
|
852
|
+
lifecycleContext
|
|
853
|
+
);
|
|
854
|
+
} catch {
|
|
855
|
+
}
|
|
856
|
+
await notifyError(hooks, error, "submit", lifecycleContext);
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
const listSubmissions = api.defineOperation({
|
|
860
|
+
input: ListSubmissionsOperationInputSchema,
|
|
861
|
+
permission: permissions.formBuilderPermissions.submission.read,
|
|
862
|
+
facts: async ({ input }) => {
|
|
863
|
+
const form = await findFormById(adapter, input.formId);
|
|
864
|
+
formSnapshots.set(input, form ? snapshotForm(form) : null);
|
|
865
|
+
return {
|
|
866
|
+
scope: "collection",
|
|
867
|
+
formId: input.formId,
|
|
868
|
+
formExists: form !== null,
|
|
869
|
+
...form?.createdBy ? { ownerId: form.createdBy } : {}
|
|
870
|
+
};
|
|
871
|
+
},
|
|
872
|
+
before: async (context) => {
|
|
873
|
+
const expected = formSnapshots.get(context.input) ?? null;
|
|
874
|
+
const current = await findFormById(adapter, context.input.formId);
|
|
875
|
+
if (!sameFormSnapshot(current, expected)) throw staleFormError();
|
|
876
|
+
if (!current) throw formNotFoundError();
|
|
877
|
+
await runDomainHook(
|
|
878
|
+
() => hooks?.onBeforeListSubmissions?.(
|
|
879
|
+
context.input.formId,
|
|
880
|
+
hookContext(context)
|
|
881
|
+
),
|
|
882
|
+
403,
|
|
883
|
+
"LIST_SUBMISSIONS_REJECTED"
|
|
884
|
+
);
|
|
885
|
+
},
|
|
886
|
+
execute: async (context) => {
|
|
887
|
+
const expected = formSnapshots.get(context.input) ?? null;
|
|
888
|
+
return adapter.transaction(async (tx) => {
|
|
889
|
+
const before = await findFormById(tx, context.input.formId);
|
|
890
|
+
if (!sameFormSnapshot(before, expected)) throw staleFormError();
|
|
891
|
+
if (!before) throw formNotFoundError();
|
|
892
|
+
const result = await getters.getFormSubmissions(
|
|
893
|
+
tx,
|
|
894
|
+
context.input.formId,
|
|
895
|
+
context.input.query
|
|
896
|
+
);
|
|
897
|
+
const after = await findFormById(tx, context.input.formId);
|
|
898
|
+
if (!sameFormSnapshot(after, expected)) throw staleFormError();
|
|
899
|
+
if (!after) throw formNotFoundError();
|
|
900
|
+
return {
|
|
901
|
+
form: operationSubmissionFormContext(after),
|
|
902
|
+
...operationSubmissionsResult(result)
|
|
903
|
+
};
|
|
904
|
+
});
|
|
905
|
+
},
|
|
906
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "listSubmissions", hookContext(context))
|
|
907
|
+
});
|
|
908
|
+
const getSubmission = api.defineOperation({
|
|
909
|
+
input: GetSubmissionOperationInputSchema,
|
|
910
|
+
permission: permissions.formBuilderPermissions.submission.read,
|
|
911
|
+
facts: async ({ input }) => {
|
|
912
|
+
const [form, submission] = await Promise.all([
|
|
913
|
+
findFormById(adapter, input.formId),
|
|
914
|
+
findSubmission(adapter, input.submissionId)
|
|
915
|
+
]);
|
|
916
|
+
const matches = form && submission?.formId === form.id;
|
|
917
|
+
submissionSnapshots.set(
|
|
918
|
+
input,
|
|
919
|
+
matches ? {
|
|
920
|
+
form: snapshotForm(form),
|
|
921
|
+
submission: snapshotSubmission(submission)
|
|
922
|
+
} : null
|
|
923
|
+
);
|
|
924
|
+
return submissionRecordFacts(
|
|
925
|
+
input.formId,
|
|
926
|
+
input.submissionId,
|
|
927
|
+
form,
|
|
928
|
+
submission
|
|
929
|
+
);
|
|
930
|
+
},
|
|
931
|
+
before: async (context) => {
|
|
932
|
+
const snapshot = submissionSnapshots.get(context.input);
|
|
933
|
+
if (!snapshot) throw submissionNotFoundError();
|
|
934
|
+
const [form, submission] = await Promise.all([
|
|
935
|
+
findFormById(adapter, context.input.formId),
|
|
936
|
+
findSubmission(adapter, context.input.submissionId)
|
|
937
|
+
]);
|
|
938
|
+
if (!sameFormSnapshot(form, snapshot.form) || !sameSubmissionSnapshot(submission, snapshot.submission)) {
|
|
939
|
+
throw staleSubmissionError();
|
|
940
|
+
}
|
|
941
|
+
await runDomainHook(
|
|
942
|
+
() => hooks?.onBeforeGetSubmission?.(
|
|
943
|
+
context.input.submissionId,
|
|
944
|
+
hookContext(context)
|
|
945
|
+
),
|
|
946
|
+
403,
|
|
947
|
+
"GET_SUBMISSION_REJECTED"
|
|
948
|
+
);
|
|
949
|
+
},
|
|
950
|
+
execute: async (context) => {
|
|
951
|
+
const snapshot = submissionSnapshots.get(context.input);
|
|
952
|
+
if (!snapshot) throw submissionNotFoundError();
|
|
953
|
+
const submission = await adapter.findOne({
|
|
954
|
+
model: "formSubmission",
|
|
955
|
+
where: [
|
|
956
|
+
{
|
|
957
|
+
field: "id",
|
|
958
|
+
value: context.input.submissionId,
|
|
959
|
+
operator: "eq"
|
|
960
|
+
}
|
|
961
|
+
],
|
|
962
|
+
join: { form: true }
|
|
963
|
+
});
|
|
964
|
+
if (!submission || submission.formId !== context.input.formId || !sameSubmissionSnapshot(submission, snapshot.submission) || !sameFormSnapshot(submission.form ?? null, snapshot.form)) {
|
|
965
|
+
throw staleSubmissionError();
|
|
966
|
+
}
|
|
967
|
+
return operationSubmissionWithData(submission);
|
|
968
|
+
},
|
|
969
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "getSubmission", hookContext(context))
|
|
970
|
+
});
|
|
971
|
+
const deleteSubmission = api.defineOperation({
|
|
972
|
+
input: GetSubmissionOperationInputSchema,
|
|
973
|
+
permission: permissions.formBuilderPermissions.submission.delete,
|
|
974
|
+
facts: async ({ input }) => {
|
|
975
|
+
const [form, submission] = await Promise.all([
|
|
976
|
+
findFormById(adapter, input.formId),
|
|
977
|
+
findSubmission(adapter, input.submissionId)
|
|
978
|
+
]);
|
|
979
|
+
if (!form || !submission || submission.formId !== form.id) {
|
|
980
|
+
throw submissionNotFoundError();
|
|
981
|
+
}
|
|
982
|
+
submissionSnapshots.set(input, {
|
|
983
|
+
form: snapshotForm(form),
|
|
984
|
+
submission: snapshotSubmission(submission)
|
|
985
|
+
});
|
|
986
|
+
return {
|
|
987
|
+
formId: form.id,
|
|
988
|
+
submissionId: submission.id,
|
|
989
|
+
exists: true,
|
|
990
|
+
...form.createdBy ? { ownerId: form.createdBy } : {},
|
|
991
|
+
...submission.submittedBy ? { submittedBy: submission.submittedBy } : {}
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
execute: async (context) => {
|
|
995
|
+
const snapshot = submissionSnapshots.get(context.input);
|
|
996
|
+
if (!snapshot) throw staleSubmissionError();
|
|
997
|
+
requireAtomicTransactions(adapter);
|
|
998
|
+
return adapter.transaction(async (tx) => {
|
|
999
|
+
const [form, submission] = await Promise.all([
|
|
1000
|
+
findFormById(tx, context.input.formId),
|
|
1001
|
+
findSubmission(tx, context.input.submissionId)
|
|
1002
|
+
]);
|
|
1003
|
+
if (!sameFormSnapshot(form, snapshot.form) || !sameSubmissionSnapshot(submission, snapshot.submission)) {
|
|
1004
|
+
throw staleSubmissionError();
|
|
1005
|
+
}
|
|
1006
|
+
const claimedAt = await claimFormSnapshot(
|
|
1007
|
+
tx,
|
|
1008
|
+
snapshot.form,
|
|
1009
|
+
staleSubmissionError
|
|
1010
|
+
);
|
|
1011
|
+
const claimedSubmissionAt = await claimSubmissionSnapshot(
|
|
1012
|
+
tx,
|
|
1013
|
+
snapshot.submission
|
|
1014
|
+
);
|
|
1015
|
+
await runDomainHook(
|
|
1016
|
+
() => hooks?.onBeforeDeleteSubmission?.(
|
|
1017
|
+
context.input.submissionId,
|
|
1018
|
+
hookContext(context)
|
|
1019
|
+
),
|
|
1020
|
+
403,
|
|
1021
|
+
"DELETE_SUBMISSION_REJECTED"
|
|
1022
|
+
);
|
|
1023
|
+
const [claimedForm, claimedSubmission] = await Promise.all([
|
|
1024
|
+
findFormById(tx, snapshot.form.id),
|
|
1025
|
+
findSubmission(tx, snapshot.submission.id)
|
|
1026
|
+
]);
|
|
1027
|
+
if (!sameFormSnapshot(claimedForm, {
|
|
1028
|
+
...snapshot.form,
|
|
1029
|
+
updatedAt: claimedAt
|
|
1030
|
+
}) || !sameSubmissionSnapshot(claimedSubmission, {
|
|
1031
|
+
...snapshot.submission,
|
|
1032
|
+
submittedAt: claimedSubmissionAt
|
|
1033
|
+
})) {
|
|
1034
|
+
throw staleSubmissionError();
|
|
1035
|
+
}
|
|
1036
|
+
const deleted = await tx.deleteMany({
|
|
1037
|
+
model: "formSubmission",
|
|
1038
|
+
where: [
|
|
1039
|
+
{
|
|
1040
|
+
field: "id",
|
|
1041
|
+
value: context.input.submissionId,
|
|
1042
|
+
operator: "eq"
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
field: "submittedAt",
|
|
1046
|
+
value: claimedSubmissionAt,
|
|
1047
|
+
operator: "eq"
|
|
1048
|
+
}
|
|
1049
|
+
]
|
|
1050
|
+
});
|
|
1051
|
+
if (deleted !== 1) throw staleSubmissionError();
|
|
1052
|
+
await restoreFormSnapshot(
|
|
1053
|
+
tx,
|
|
1054
|
+
snapshot.form,
|
|
1055
|
+
claimedAt,
|
|
1056
|
+
staleSubmissionError
|
|
1057
|
+
);
|
|
1058
|
+
return { success: true };
|
|
1059
|
+
});
|
|
1060
|
+
},
|
|
1061
|
+
after: async (context) => {
|
|
1062
|
+
await hooks?.onAfterDeleteSubmission?.(
|
|
1063
|
+
context.input.submissionId,
|
|
1064
|
+
hookContext(context)
|
|
1065
|
+
);
|
|
1066
|
+
},
|
|
1067
|
+
onError: ({ error, ...context }) => notifyError(hooks, error, "deleteSubmission", hookContext(context))
|
|
1068
|
+
});
|
|
1069
|
+
return {
|
|
1070
|
+
listForms,
|
|
1071
|
+
getFormBySlug,
|
|
1072
|
+
getFormById,
|
|
1073
|
+
getFormForUpdate,
|
|
1074
|
+
createForm,
|
|
1075
|
+
updateForm,
|
|
1076
|
+
deleteForm,
|
|
1077
|
+
submitForm,
|
|
1078
|
+
listSubmissions,
|
|
1079
|
+
getSubmission,
|
|
1080
|
+
deleteSubmission
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
exports.DeleteFormOperationInputSchema = DeleteFormOperationInputSchema;
|
|
1085
|
+
exports.FormBuilderOperationError = FormBuilderOperationError;
|
|
1086
|
+
exports.GetFormByIdOperationInputSchema = GetFormByIdOperationInputSchema;
|
|
1087
|
+
exports.GetFormBySlugOperationInputSchema = GetFormBySlugOperationInputSchema;
|
|
1088
|
+
exports.GetFormForUpdateOperationInputSchema = GetFormForUpdateOperationInputSchema;
|
|
1089
|
+
exports.GetSubmissionOperationInputSchema = GetSubmissionOperationInputSchema;
|
|
1090
|
+
exports.ListSubmissionsOperationInputSchema = ListSubmissionsOperationInputSchema;
|
|
1091
|
+
exports.SubmitFormOperationInputSchema = SubmitFormOperationInputSchema;
|
|
1092
|
+
exports.UpdateFormOperationInputSchema = UpdateFormOperationInputSchema;
|
|
1093
|
+
exports.createFormBuilderOperations = createFormBuilderOperations;
|