@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.
@@ -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
+ }