@emdash-cms/plugin-forms 0.0.1
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/package.json +40 -0
- package/src/admin.tsx +1288 -0
- package/src/astro/Form.astro +26 -0
- package/src/astro/FormEmbed.astro +301 -0
- package/src/astro/index.ts +11 -0
- package/src/client/index.ts +536 -0
- package/src/format.ts +160 -0
- package/src/handlers/cron.ts +151 -0
- package/src/handlers/forms.ts +269 -0
- package/src/handlers/submissions.ts +191 -0
- package/src/handlers/submit.ts +297 -0
- package/src/index.ts +230 -0
- package/src/schemas.ts +215 -0
- package/src/storage.ts +41 -0
- package/src/styles/forms.css +200 -0
- package/src/turnstile.ts +51 -0
- package/src/types.ts +164 -0
- package/src/validation.ts +205 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron task handlers.
|
|
3
|
+
*
|
|
4
|
+
* - cleanup: Delete submissions past their retention period
|
|
5
|
+
* - digest: Send daily digest emails for forms with digest enabled
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginContext, StorageCollection } from "emdash";
|
|
9
|
+
|
|
10
|
+
import { formatDigestText } from "../format.js";
|
|
11
|
+
import type { FormDefinition, Submission } from "../types.js";
|
|
12
|
+
|
|
13
|
+
/** Typed access to plugin storage collections */
|
|
14
|
+
function forms(ctx: PluginContext): StorageCollection<FormDefinition> {
|
|
15
|
+
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function submissions(ctx: PluginContext): StorageCollection<Submission> {
|
|
19
|
+
return ctx.storage.submissions as StorageCollection<Submission>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Weekly cleanup: delete submissions past retention period.
|
|
24
|
+
*/
|
|
25
|
+
export async function handleCleanup(ctx: PluginContext) {
|
|
26
|
+
let formsCursor: string | undefined;
|
|
27
|
+
|
|
28
|
+
do {
|
|
29
|
+
const formsBatch = await forms(ctx).query({ limit: 100, cursor: formsCursor });
|
|
30
|
+
|
|
31
|
+
for (const formItem of formsBatch.items) {
|
|
32
|
+
const form = formItem.data;
|
|
33
|
+
if (form.settings.retentionDays === 0) continue;
|
|
34
|
+
|
|
35
|
+
const cutoff = new Date();
|
|
36
|
+
cutoff.setDate(cutoff.getDate() - form.settings.retentionDays);
|
|
37
|
+
const cutoffStr = cutoff.toISOString();
|
|
38
|
+
|
|
39
|
+
let cursor: string | undefined;
|
|
40
|
+
let deletedCount = 0;
|
|
41
|
+
|
|
42
|
+
do {
|
|
43
|
+
const batch = await submissions(ctx).query({
|
|
44
|
+
where: {
|
|
45
|
+
formId: formItem.id,
|
|
46
|
+
createdAt: { lt: cutoffStr },
|
|
47
|
+
},
|
|
48
|
+
limit: 100,
|
|
49
|
+
cursor,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Delete media files
|
|
53
|
+
if (ctx.media && "delete" in ctx.media) {
|
|
54
|
+
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
|
55
|
+
for (const item of batch.items) {
|
|
56
|
+
if (item.data.files) {
|
|
57
|
+
for (const file of item.data.files) {
|
|
58
|
+
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ids = batch.items.map((item) => item.id);
|
|
65
|
+
if (ids.length > 0) {
|
|
66
|
+
await submissions(ctx).deleteMany(ids);
|
|
67
|
+
deletedCount += ids.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cursor = batch.cursor;
|
|
71
|
+
} while (cursor);
|
|
72
|
+
|
|
73
|
+
// Update form counter
|
|
74
|
+
if (deletedCount > 0) {
|
|
75
|
+
const count = await submissions(ctx).count({ formId: formItem.id });
|
|
76
|
+
await forms(ctx).put(formItem.id, {
|
|
77
|
+
...form,
|
|
78
|
+
submissionCount: count,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
ctx.log.info("Cleaned up expired submissions", {
|
|
82
|
+
formId: formItem.id,
|
|
83
|
+
formName: form.name,
|
|
84
|
+
deleted: deletedCount,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
formsCursor = formsBatch.cursor;
|
|
90
|
+
} while (formsCursor);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Daily digest: send summary email for a specific form.
|
|
95
|
+
*
|
|
96
|
+
* The cron task name contains the form ID: "digest:{formId}"
|
|
97
|
+
*/
|
|
98
|
+
export async function handleDigest(formId: string, ctx: PluginContext) {
|
|
99
|
+
const form = await forms(ctx).get(formId);
|
|
100
|
+
if (!form) {
|
|
101
|
+
ctx.log.warn("Digest: form not found, cancelling", { formId });
|
|
102
|
+
if (ctx.cron) {
|
|
103
|
+
await ctx.cron.cancel(`digest:${formId}`).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!form.settings.digestEnabled || form.settings.notifyEmails.length === 0) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!ctx.email) {
|
|
113
|
+
ctx.log.warn("Digest: email not configured", { formId });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get submissions since last 24 hours
|
|
118
|
+
const since = new Date();
|
|
119
|
+
since.setDate(since.getDate() - 1);
|
|
120
|
+
|
|
121
|
+
const recent = await submissions(ctx).query({
|
|
122
|
+
where: {
|
|
123
|
+
formId,
|
|
124
|
+
createdAt: { gte: since.toISOString() },
|
|
125
|
+
},
|
|
126
|
+
orderBy: { createdAt: "desc" },
|
|
127
|
+
limit: 100,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (recent.items.length === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const subs = recent.items.map((item) => item.data);
|
|
135
|
+
const text = formatDigestText(form, formId, subs, ctx.site.url);
|
|
136
|
+
|
|
137
|
+
for (const email of form.settings.notifyEmails) {
|
|
138
|
+
await ctx.email
|
|
139
|
+
.send({
|
|
140
|
+
to: email,
|
|
141
|
+
subject: `Daily digest: ${form.name} (${subs.length} new)`,
|
|
142
|
+
text,
|
|
143
|
+
})
|
|
144
|
+
.catch((err: unknown) => {
|
|
145
|
+
ctx.log.error("Failed to send digest email", {
|
|
146
|
+
error: String(err),
|
|
147
|
+
to: email,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form CRUD route handlers.
|
|
3
|
+
*
|
|
4
|
+
* Admin-only routes for managing form definitions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RouteContext, StorageCollection } from "emdash";
|
|
8
|
+
import { PluginRouteError } from "emdash";
|
|
9
|
+
import { ulid } from "ulidx";
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
FormCreateInput,
|
|
13
|
+
FormDeleteInput,
|
|
14
|
+
FormDuplicateInput,
|
|
15
|
+
FormUpdateInput,
|
|
16
|
+
} from "../schemas.js";
|
|
17
|
+
import type { FormDefinition } from "../types.js";
|
|
18
|
+
|
|
19
|
+
/** Typed access to plugin storage collections */
|
|
20
|
+
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
|
21
|
+
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function submissions(ctx: RouteContext): StorageCollection {
|
|
25
|
+
return ctx.storage.submissions as StorageCollection;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── List Forms ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export async function formsListHandler(ctx: RouteContext) {
|
|
31
|
+
const result = await forms(ctx).query({
|
|
32
|
+
orderBy: { createdAt: "desc" },
|
|
33
|
+
limit: 100,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
|
38
|
+
hasMore: result.hasMore,
|
|
39
|
+
cursor: result.cursor,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Create Form ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export async function formsCreateHandler(ctx: RouteContext<FormCreateInput>) {
|
|
46
|
+
const input = ctx.input;
|
|
47
|
+
|
|
48
|
+
// Check slug uniqueness
|
|
49
|
+
const existing = await forms(ctx).query({
|
|
50
|
+
where: { slug: input.slug },
|
|
51
|
+
limit: 1,
|
|
52
|
+
});
|
|
53
|
+
if (existing.items.length > 0) {
|
|
54
|
+
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Validate field names are unique across all pages
|
|
58
|
+
validateFieldNames(input.pages);
|
|
59
|
+
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const id = ulid();
|
|
62
|
+
const form: FormDefinition = {
|
|
63
|
+
name: input.name,
|
|
64
|
+
slug: input.slug,
|
|
65
|
+
pages: input.pages,
|
|
66
|
+
settings: {
|
|
67
|
+
confirmationMessage: input.settings.confirmationMessage ?? "Thank you for your submission.",
|
|
68
|
+
redirectUrl: input.settings.redirectUrl || undefined,
|
|
69
|
+
notifyEmails: input.settings.notifyEmails ?? [],
|
|
70
|
+
digestEnabled: input.settings.digestEnabled ?? false,
|
|
71
|
+
digestHour: input.settings.digestHour ?? 9,
|
|
72
|
+
autoresponder: input.settings.autoresponder,
|
|
73
|
+
webhookUrl: input.settings.webhookUrl || undefined,
|
|
74
|
+
retentionDays: input.settings.retentionDays ?? 0,
|
|
75
|
+
spamProtection: input.settings.spamProtection ?? "honeypot",
|
|
76
|
+
submitLabel: input.settings.submitLabel ?? "Submit",
|
|
77
|
+
nextLabel: input.settings.nextLabel,
|
|
78
|
+
prevLabel: input.settings.prevLabel,
|
|
79
|
+
},
|
|
80
|
+
status: "active",
|
|
81
|
+
submissionCount: 0,
|
|
82
|
+
lastSubmissionAt: null,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
await forms(ctx).put(id, form);
|
|
88
|
+
|
|
89
|
+
// Schedule digest cron if enabled
|
|
90
|
+
if (form.settings.digestEnabled && ctx.cron) {
|
|
91
|
+
await ctx.cron.schedule(`digest:${id}`, {
|
|
92
|
+
schedule: `0 ${form.settings.digestHour} * * *`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { id, ...form };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Update Form ─────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export async function formsUpdateHandler(ctx: RouteContext<FormUpdateInput>) {
|
|
102
|
+
const input = ctx.input;
|
|
103
|
+
|
|
104
|
+
const existing = await forms(ctx).get(input.id);
|
|
105
|
+
if (!existing) {
|
|
106
|
+
throw PluginRouteError.notFound("Form not found");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check slug uniqueness if changing
|
|
110
|
+
if (input.slug && input.slug !== existing.slug) {
|
|
111
|
+
const slugCheck = await forms(ctx).query({
|
|
112
|
+
where: { slug: input.slug },
|
|
113
|
+
limit: 1,
|
|
114
|
+
});
|
|
115
|
+
if (slugCheck.items.length > 0) {
|
|
116
|
+
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (input.pages) {
|
|
121
|
+
validateFieldNames(input.pages);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const updated: FormDefinition = {
|
|
125
|
+
...existing,
|
|
126
|
+
name: input.name ?? existing.name,
|
|
127
|
+
slug: input.slug ?? existing.slug,
|
|
128
|
+
pages: input.pages ?? existing.pages,
|
|
129
|
+
settings: input.settings ? { ...existing.settings, ...input.settings } : existing.settings,
|
|
130
|
+
status: input.status ?? existing.status,
|
|
131
|
+
updatedAt: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Clean up empty strings
|
|
135
|
+
if (updated.settings.redirectUrl === "") updated.settings.redirectUrl = undefined;
|
|
136
|
+
if (updated.settings.webhookUrl === "") updated.settings.webhookUrl = undefined;
|
|
137
|
+
|
|
138
|
+
await forms(ctx).put(input.id, updated);
|
|
139
|
+
|
|
140
|
+
// Update digest cron if settings changed
|
|
141
|
+
if (ctx.cron) {
|
|
142
|
+
if (updated.settings.digestEnabled && !existing.settings.digestEnabled) {
|
|
143
|
+
await ctx.cron.schedule(`digest:${input.id}`, {
|
|
144
|
+
schedule: `0 ${updated.settings.digestHour} * * *`,
|
|
145
|
+
});
|
|
146
|
+
} else if (!updated.settings.digestEnabled && existing.settings.digestEnabled) {
|
|
147
|
+
await ctx.cron.cancel(`digest:${input.id}`);
|
|
148
|
+
} else if (
|
|
149
|
+
updated.settings.digestEnabled &&
|
|
150
|
+
updated.settings.digestHour !== existing.settings.digestHour
|
|
151
|
+
) {
|
|
152
|
+
await ctx.cron.schedule(`digest:${input.id}`, {
|
|
153
|
+
schedule: `0 ${updated.settings.digestHour} * * *`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { id: input.id, ...updated };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Delete Form ─────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export async function formsDeleteHandler(ctx: RouteContext<FormDeleteInput>) {
|
|
164
|
+
const input = ctx.input;
|
|
165
|
+
|
|
166
|
+
const existing = await forms(ctx).get(input.id);
|
|
167
|
+
if (!existing) {
|
|
168
|
+
throw PluginRouteError.notFound("Form not found");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Delete associated submissions if requested
|
|
172
|
+
if (input.deleteSubmissions) {
|
|
173
|
+
await deleteFormSubmissions(input.id, ctx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Cancel digest cron
|
|
177
|
+
if (ctx.cron) {
|
|
178
|
+
await ctx.cron.cancel(`digest:${input.id}`).catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await forms(ctx).delete(input.id);
|
|
182
|
+
|
|
183
|
+
return { deleted: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Duplicate Form ──────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export async function formsDuplicateHandler(ctx: RouteContext<FormDuplicateInput>) {
|
|
189
|
+
const input = ctx.input;
|
|
190
|
+
|
|
191
|
+
const existing = await forms(ctx).get(input.id);
|
|
192
|
+
if (!existing) {
|
|
193
|
+
throw PluginRouteError.notFound("Form not found");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const newSlug = input.slug ?? `${existing.slug}-copy`;
|
|
197
|
+
const newName = input.name ?? `${existing.name} (Copy)`;
|
|
198
|
+
|
|
199
|
+
// Check slug uniqueness
|
|
200
|
+
const slugCheck = await forms(ctx).query({
|
|
201
|
+
where: { slug: newSlug },
|
|
202
|
+
limit: 1,
|
|
203
|
+
});
|
|
204
|
+
if (slugCheck.items.length > 0) {
|
|
205
|
+
throw PluginRouteError.conflict(`A form with slug "${newSlug}" already exists`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const now = new Date().toISOString();
|
|
209
|
+
const id = ulid();
|
|
210
|
+
const duplicate: FormDefinition = {
|
|
211
|
+
...existing,
|
|
212
|
+
name: newName,
|
|
213
|
+
slug: newSlug,
|
|
214
|
+
submissionCount: 0,
|
|
215
|
+
lastSubmissionAt: null,
|
|
216
|
+
createdAt: now,
|
|
217
|
+
updatedAt: now,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
await forms(ctx).put(id, duplicate);
|
|
221
|
+
|
|
222
|
+
return { id, ...duplicate };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function validateFieldNames(pages: Array<{ fields: Array<{ name: string }> }>) {
|
|
228
|
+
const names = new Set<string>();
|
|
229
|
+
for (const page of pages) {
|
|
230
|
+
for (const field of page.fields) {
|
|
231
|
+
if (names.has(field.name)) {
|
|
232
|
+
throw PluginRouteError.badRequest(`Duplicate field name "${field.name}" across form pages`);
|
|
233
|
+
}
|
|
234
|
+
names.add(field.name);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Delete all submissions for a form, including media files */
|
|
240
|
+
async function deleteFormSubmissions(formId: string, ctx: RouteContext) {
|
|
241
|
+
let cursor: string | undefined;
|
|
242
|
+
do {
|
|
243
|
+
const batch = await submissions(ctx).query({
|
|
244
|
+
where: { formId },
|
|
245
|
+
limit: 100,
|
|
246
|
+
cursor,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Delete associated media files
|
|
250
|
+
if (ctx.media && "delete" in ctx.media) {
|
|
251
|
+
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
|
252
|
+
for (const item of batch.items) {
|
|
253
|
+
const sub = item.data as { files?: Array<{ mediaId: string }> };
|
|
254
|
+
if (sub.files) {
|
|
255
|
+
for (const file of sub.files) {
|
|
256
|
+
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const ids = batch.items.map((item) => item.id);
|
|
263
|
+
if (ids.length > 0) {
|
|
264
|
+
await submissions(ctx).deleteMany(ids);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
cursor = batch.cursor;
|
|
268
|
+
} while (cursor);
|
|
269
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submission management route handlers.
|
|
3
|
+
*
|
|
4
|
+
* Admin-only routes for viewing, updating, exporting, and deleting submissions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RouteContext, StorageCollection } from "emdash";
|
|
8
|
+
import { PluginRouteError } from "emdash";
|
|
9
|
+
|
|
10
|
+
import { formatCsv } from "../format.js";
|
|
11
|
+
import type {
|
|
12
|
+
ExportInput,
|
|
13
|
+
SubmissionDeleteInput,
|
|
14
|
+
SubmissionGetInput,
|
|
15
|
+
SubmissionsListInput,
|
|
16
|
+
SubmissionUpdateInput,
|
|
17
|
+
} from "../schemas.js";
|
|
18
|
+
import type { FormDefinition, Submission } from "../types.js";
|
|
19
|
+
|
|
20
|
+
/** Typed access to plugin storage collections */
|
|
21
|
+
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
|
22
|
+
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function submissions(ctx: RouteContext): StorageCollection<Submission> {
|
|
26
|
+
return ctx.storage.submissions as StorageCollection<Submission>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── List Submissions ────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export async function submissionsListHandler(ctx: RouteContext<SubmissionsListInput>) {
|
|
32
|
+
const input = ctx.input;
|
|
33
|
+
|
|
34
|
+
const result = await submissions(ctx).query({
|
|
35
|
+
where: {
|
|
36
|
+
formId: input.formId,
|
|
37
|
+
...(input.status ? { status: input.status } : {}),
|
|
38
|
+
...(input.starred !== undefined ? { starred: input.starred } : {}),
|
|
39
|
+
},
|
|
40
|
+
orderBy: { createdAt: "desc" },
|
|
41
|
+
limit: input.limit,
|
|
42
|
+
cursor: input.cursor,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
|
47
|
+
hasMore: result.hasMore,
|
|
48
|
+
cursor: result.cursor,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Get Single Submission ───────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function submissionGetHandler(ctx: RouteContext<SubmissionGetInput>) {
|
|
55
|
+
const sub = await submissions(ctx).get(ctx.input.id);
|
|
56
|
+
if (!sub) {
|
|
57
|
+
throw PluginRouteError.notFound("Submission not found");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { id: ctx.input.id, ...sub };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Update Submission ───────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export async function submissionUpdateHandler(ctx: RouteContext<SubmissionUpdateInput>) {
|
|
66
|
+
const input = ctx.input;
|
|
67
|
+
|
|
68
|
+
const existing = await submissions(ctx).get(input.id);
|
|
69
|
+
if (!existing) {
|
|
70
|
+
throw PluginRouteError.notFound("Submission not found");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const updated: Submission = {
|
|
74
|
+
...existing,
|
|
75
|
+
status: input.status ?? existing.status,
|
|
76
|
+
starred: input.starred ?? existing.starred,
|
|
77
|
+
notes: input.notes !== undefined ? input.notes : existing.notes,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await submissions(ctx).put(input.id, updated);
|
|
81
|
+
|
|
82
|
+
return { id: input.id, ...updated };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Delete Submission ───────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export async function submissionDeleteHandler(ctx: RouteContext<SubmissionDeleteInput>) {
|
|
88
|
+
const input = ctx.input;
|
|
89
|
+
|
|
90
|
+
const existing = await submissions(ctx).get(input.id);
|
|
91
|
+
if (!existing) {
|
|
92
|
+
throw PluginRouteError.notFound("Submission not found");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Delete associated media files
|
|
96
|
+
if (existing.files && ctx.media && "delete" in ctx.media) {
|
|
97
|
+
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
|
98
|
+
for (const file of existing.files) {
|
|
99
|
+
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await submissions(ctx).delete(input.id);
|
|
104
|
+
|
|
105
|
+
// Update form counter using count() to avoid race conditions
|
|
106
|
+
if (existing.formId) {
|
|
107
|
+
const form = await forms(ctx).get(existing.formId);
|
|
108
|
+
if (form) {
|
|
109
|
+
const count = await submissions(ctx).count({ formId: existing.formId });
|
|
110
|
+
await forms(ctx).put(existing.formId, {
|
|
111
|
+
...form,
|
|
112
|
+
submissionCount: count,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { deleted: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ��── Export Submissions ──────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export async function exportHandler(ctx: RouteContext<ExportInput>) {
|
|
123
|
+
const input = ctx.input;
|
|
124
|
+
|
|
125
|
+
// Load form definition
|
|
126
|
+
let form: FormDefinition | null = null;
|
|
127
|
+
const byId = await forms(ctx).get(input.formId);
|
|
128
|
+
if (byId) {
|
|
129
|
+
form = byId;
|
|
130
|
+
} else {
|
|
131
|
+
const bySlug = await forms(ctx).query({
|
|
132
|
+
where: { slug: input.formId },
|
|
133
|
+
limit: 1,
|
|
134
|
+
});
|
|
135
|
+
if (bySlug.items.length > 0) {
|
|
136
|
+
form = bySlug.items[0]!.data;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!form) {
|
|
141
|
+
throw PluginRouteError.notFound("Form not found");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build where clause
|
|
145
|
+
const where: Record<string, string | number | boolean | null | Record<string, string>> = {
|
|
146
|
+
formId: input.formId,
|
|
147
|
+
};
|
|
148
|
+
if (input.status) where.status = input.status;
|
|
149
|
+
if (input.from || input.to) {
|
|
150
|
+
const range: Record<string, string> = {};
|
|
151
|
+
if (input.from) range.gte = input.from;
|
|
152
|
+
if (input.to) range.lte = input.to;
|
|
153
|
+
where.createdAt = range;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Collect all submissions (paginate through)
|
|
157
|
+
const allItems: Array<{ id: string; data: Submission }> = [];
|
|
158
|
+
let cursor: string | undefined;
|
|
159
|
+
|
|
160
|
+
do {
|
|
161
|
+
const batch = await submissions(ctx).query({
|
|
162
|
+
where: where as Record<string, string | number | boolean | null>,
|
|
163
|
+
orderBy: { createdAt: "desc" },
|
|
164
|
+
limit: 100,
|
|
165
|
+
cursor,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
for (const item of batch.items) {
|
|
169
|
+
allItems.push(item);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
cursor = batch.cursor;
|
|
173
|
+
} while (cursor);
|
|
174
|
+
|
|
175
|
+
if (input.format === "json") {
|
|
176
|
+
return {
|
|
177
|
+
data: allItems.map((item) => item.data),
|
|
178
|
+
count: allItems.length,
|
|
179
|
+
contentType: "application/json",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// CSV
|
|
184
|
+
const csv = formatCsv(form, allItems);
|
|
185
|
+
return {
|
|
186
|
+
data: csv,
|
|
187
|
+
count: allItems.length,
|
|
188
|
+
contentType: "text/csv",
|
|
189
|
+
filename: `${form.slug}-submissions-${new Date().toISOString().split("T")[0]}.csv`,
|
|
190
|
+
};
|
|
191
|
+
}
|