first commit
This commit is contained in:
151
packages/plugins/forms/src/handlers/cron.ts
Normal file
151
packages/plugins/forms/src/handlers/cron.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Cron task handlers.
|
||||
*
|
||||
* - cleanup: Delete submissions past their retention period
|
||||
* - digest: Send daily digest emails for forms with digest enabled
|
||||
*/
|
||||
|
||||
import type { PluginContext, StorageCollection } from "emdash";
|
||||
|
||||
import { formatDigestText } from "../format.js";
|
||||
import type { FormDefinition, Submission } from "../types.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: PluginContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: PluginContext): StorageCollection<Submission> {
|
||||
return ctx.storage.submissions as StorageCollection<Submission>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly cleanup: delete submissions past retention period.
|
||||
*/
|
||||
export async function handleCleanup(ctx: PluginContext) {
|
||||
let formsCursor: string | undefined;
|
||||
|
||||
do {
|
||||
const formsBatch = await forms(ctx).query({ limit: 100, cursor: formsCursor });
|
||||
|
||||
for (const formItem of formsBatch.items) {
|
||||
const form = formItem.data;
|
||||
if (form.settings.retentionDays === 0) continue;
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - form.settings.retentionDays);
|
||||
const cutoffStr = cutoff.toISOString();
|
||||
|
||||
let cursor: string | undefined;
|
||||
let deletedCount = 0;
|
||||
|
||||
do {
|
||||
const batch = await submissions(ctx).query({
|
||||
where: {
|
||||
formId: formItem.id,
|
||||
createdAt: { lt: cutoffStr },
|
||||
},
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
// Delete media files
|
||||
if (ctx.media && "delete" in ctx.media) {
|
||||
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
||||
for (const item of batch.items) {
|
||||
if (item.data.files) {
|
||||
for (const file of item.data.files) {
|
||||
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = batch.items.map((item) => item.id);
|
||||
if (ids.length > 0) {
|
||||
await submissions(ctx).deleteMany(ids);
|
||||
deletedCount += ids.length;
|
||||
}
|
||||
|
||||
cursor = batch.cursor;
|
||||
} while (cursor);
|
||||
|
||||
// Update form counter
|
||||
if (deletedCount > 0) {
|
||||
const count = await submissions(ctx).count({ formId: formItem.id });
|
||||
await forms(ctx).put(formItem.id, {
|
||||
...form,
|
||||
submissionCount: count,
|
||||
});
|
||||
|
||||
ctx.log.info("Cleaned up expired submissions", {
|
||||
formId: formItem.id,
|
||||
formName: form.name,
|
||||
deleted: deletedCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formsCursor = formsBatch.cursor;
|
||||
} while (formsCursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily digest: send summary email for a specific form.
|
||||
*
|
||||
* The cron task name contains the form ID: "digest:{formId}"
|
||||
*/
|
||||
export async function handleDigest(formId: string, ctx: PluginContext) {
|
||||
const form = await forms(ctx).get(formId);
|
||||
if (!form) {
|
||||
ctx.log.warn("Digest: form not found, cancelling", { formId });
|
||||
if (ctx.cron) {
|
||||
await ctx.cron.cancel(`digest:${formId}`).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.settings.digestEnabled || form.settings.notifyEmails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.email) {
|
||||
ctx.log.warn("Digest: email not configured", { formId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get submissions since last 24 hours
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - 1);
|
||||
|
||||
const recent = await submissions(ctx).query({
|
||||
where: {
|
||||
formId,
|
||||
createdAt: { gte: since.toISOString() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
if (recent.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subs = recent.items.map((item) => item.data);
|
||||
const text = formatDigestText(form, formId, subs, ctx.site.url);
|
||||
|
||||
for (const email of form.settings.notifyEmails) {
|
||||
await ctx.email
|
||||
.send({
|
||||
to: email,
|
||||
subject: `Daily digest: ${form.name} (${subs.length} new)`,
|
||||
text,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Failed to send digest email", {
|
||||
error: String(err),
|
||||
to: email,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
269
packages/plugins/forms/src/handlers/forms.ts
Normal file
269
packages/plugins/forms/src/handlers/forms.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Form CRUD route handlers.
|
||||
*
|
||||
* Admin-only routes for managing form definitions.
|
||||
*/
|
||||
|
||||
import type { RouteContext, StorageCollection } from "emdash";
|
||||
import { PluginRouteError } from "emdash";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import type {
|
||||
FormCreateInput,
|
||||
FormDeleteInput,
|
||||
FormDuplicateInput,
|
||||
FormUpdateInput,
|
||||
} from "../schemas.js";
|
||||
import type { FormDefinition } from "../types.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: RouteContext): StorageCollection {
|
||||
return ctx.storage.submissions as StorageCollection;
|
||||
}
|
||||
|
||||
// ─── List Forms ──────────────────────────────────────────────────
|
||||
|
||||
export async function formsListHandler(ctx: RouteContext) {
|
||||
const result = await forms(ctx).query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
||||
hasMore: result.hasMore,
|
||||
cursor: result.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Create Form ─────────────────────────────────────────────────
|
||||
|
||||
export async function formsCreateHandler(ctx: RouteContext<FormCreateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
// Check slug uniqueness
|
||||
const existing = await forms(ctx).query({
|
||||
where: { slug: input.slug },
|
||||
limit: 1,
|
||||
});
|
||||
if (existing.items.length > 0) {
|
||||
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
|
||||
}
|
||||
|
||||
// Validate field names are unique across all pages
|
||||
validateFieldNames(input.pages);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = ulid();
|
||||
const form: FormDefinition = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
pages: input.pages,
|
||||
settings: {
|
||||
confirmationMessage: input.settings.confirmationMessage ?? "Thank you for your submission.",
|
||||
redirectUrl: input.settings.redirectUrl || undefined,
|
||||
notifyEmails: input.settings.notifyEmails ?? [],
|
||||
digestEnabled: input.settings.digestEnabled ?? false,
|
||||
digestHour: input.settings.digestHour ?? 9,
|
||||
autoresponder: input.settings.autoresponder,
|
||||
webhookUrl: input.settings.webhookUrl || undefined,
|
||||
retentionDays: input.settings.retentionDays ?? 0,
|
||||
spamProtection: input.settings.spamProtection ?? "honeypot",
|
||||
submitLabel: input.settings.submitLabel ?? "Submit",
|
||||
nextLabel: input.settings.nextLabel,
|
||||
prevLabel: input.settings.prevLabel,
|
||||
},
|
||||
status: "active",
|
||||
submissionCount: 0,
|
||||
lastSubmissionAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await forms(ctx).put(id, form);
|
||||
|
||||
// Schedule digest cron if enabled
|
||||
if (form.settings.digestEnabled && ctx.cron) {
|
||||
await ctx.cron.schedule(`digest:${id}`, {
|
||||
schedule: `0 ${form.settings.digestHour} * * *`,
|
||||
});
|
||||
}
|
||||
|
||||
return { id, ...form };
|
||||
}
|
||||
|
||||
// ─── Update Form ─────────────────────────────────────────────────
|
||||
|
||||
export async function formsUpdateHandler(ctx: RouteContext<FormUpdateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await forms(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
// Check slug uniqueness if changing
|
||||
if (input.slug && input.slug !== existing.slug) {
|
||||
const slugCheck = await forms(ctx).query({
|
||||
where: { slug: input.slug },
|
||||
limit: 1,
|
||||
});
|
||||
if (slugCheck.items.length > 0) {
|
||||
throw PluginRouteError.conflict(`A form with slug "${input.slug}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.pages) {
|
||||
validateFieldNames(input.pages);
|
||||
}
|
||||
|
||||
const updated: FormDefinition = {
|
||||
...existing,
|
||||
name: input.name ?? existing.name,
|
||||
slug: input.slug ?? existing.slug,
|
||||
pages: input.pages ?? existing.pages,
|
||||
settings: input.settings ? { ...existing.settings, ...input.settings } : existing.settings,
|
||||
status: input.status ?? existing.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Clean up empty strings
|
||||
if (updated.settings.redirectUrl === "") updated.settings.redirectUrl = undefined;
|
||||
if (updated.settings.webhookUrl === "") updated.settings.webhookUrl = undefined;
|
||||
|
||||
await forms(ctx).put(input.id, updated);
|
||||
|
||||
// Update digest cron if settings changed
|
||||
if (ctx.cron) {
|
||||
if (updated.settings.digestEnabled && !existing.settings.digestEnabled) {
|
||||
await ctx.cron.schedule(`digest:${input.id}`, {
|
||||
schedule: `0 ${updated.settings.digestHour} * * *`,
|
||||
});
|
||||
} else if (!updated.settings.digestEnabled && existing.settings.digestEnabled) {
|
||||
await ctx.cron.cancel(`digest:${input.id}`);
|
||||
} else if (
|
||||
updated.settings.digestEnabled &&
|
||||
updated.settings.digestHour !== existing.settings.digestHour
|
||||
) {
|
||||
await ctx.cron.schedule(`digest:${input.id}`, {
|
||||
schedule: `0 ${updated.settings.digestHour} * * *`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { id: input.id, ...updated };
|
||||
}
|
||||
|
||||
// ─── Delete Form ─────────────────────────────────────────────────
|
||||
|
||||
export async function formsDeleteHandler(ctx: RouteContext<FormDeleteInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await forms(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
// Delete associated submissions if requested
|
||||
if (input.deleteSubmissions) {
|
||||
await deleteFormSubmissions(input.id, ctx);
|
||||
}
|
||||
|
||||
// Cancel digest cron
|
||||
if (ctx.cron) {
|
||||
await ctx.cron.cancel(`digest:${input.id}`).catch(() => {});
|
||||
}
|
||||
|
||||
await forms(ctx).delete(input.id);
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ─── Duplicate Form ──────────────────────────────────────────────
|
||||
|
||||
export async function formsDuplicateHandler(ctx: RouteContext<FormDuplicateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await forms(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
const newSlug = input.slug ?? `${existing.slug}-copy`;
|
||||
const newName = input.name ?? `${existing.name} (Copy)`;
|
||||
|
||||
// Check slug uniqueness
|
||||
const slugCheck = await forms(ctx).query({
|
||||
where: { slug: newSlug },
|
||||
limit: 1,
|
||||
});
|
||||
if (slugCheck.items.length > 0) {
|
||||
throw PluginRouteError.conflict(`A form with slug "${newSlug}" already exists`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const id = ulid();
|
||||
const duplicate: FormDefinition = {
|
||||
...existing,
|
||||
name: newName,
|
||||
slug: newSlug,
|
||||
submissionCount: 0,
|
||||
lastSubmissionAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await forms(ctx).put(id, duplicate);
|
||||
|
||||
return { id, ...duplicate };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function validateFieldNames(pages: Array<{ fields: Array<{ name: string }> }>) {
|
||||
const names = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const field of page.fields) {
|
||||
if (names.has(field.name)) {
|
||||
throw PluginRouteError.badRequest(`Duplicate field name "${field.name}" across form pages`);
|
||||
}
|
||||
names.add(field.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all submissions for a form, including media files */
|
||||
async function deleteFormSubmissions(formId: string, ctx: RouteContext) {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const batch = await submissions(ctx).query({
|
||||
where: { formId },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
// Delete associated media files
|
||||
if (ctx.media && "delete" in ctx.media) {
|
||||
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
||||
for (const item of batch.items) {
|
||||
const sub = item.data as { files?: Array<{ mediaId: string }> };
|
||||
if (sub.files) {
|
||||
for (const file of sub.files) {
|
||||
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = batch.items.map((item) => item.id);
|
||||
if (ids.length > 0) {
|
||||
await submissions(ctx).deleteMany(ids);
|
||||
}
|
||||
|
||||
cursor = batch.cursor;
|
||||
} while (cursor);
|
||||
}
|
||||
191
packages/plugins/forms/src/handlers/submissions.ts
Normal file
191
packages/plugins/forms/src/handlers/submissions.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Submission management route handlers.
|
||||
*
|
||||
* Admin-only routes for viewing, updating, exporting, and deleting submissions.
|
||||
*/
|
||||
|
||||
import type { RouteContext, StorageCollection } from "emdash";
|
||||
import { PluginRouteError } from "emdash";
|
||||
|
||||
import { formatCsv } from "../format.js";
|
||||
import type {
|
||||
ExportInput,
|
||||
SubmissionDeleteInput,
|
||||
SubmissionGetInput,
|
||||
SubmissionsListInput,
|
||||
SubmissionUpdateInput,
|
||||
} from "../schemas.js";
|
||||
import type { FormDefinition, Submission } from "../types.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: RouteContext): StorageCollection<Submission> {
|
||||
return ctx.storage.submissions as StorageCollection<Submission>;
|
||||
}
|
||||
|
||||
// ─── List Submissions ────────────────────────────────────────────
|
||||
|
||||
export async function submissionsListHandler(ctx: RouteContext<SubmissionsListInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const result = await submissions(ctx).query({
|
||||
where: {
|
||||
formId: input.formId,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.starred !== undefined ? { starred: input.starred } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({ id: item.id, ...item.data })),
|
||||
hasMore: result.hasMore,
|
||||
cursor: result.cursor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Get Single Submission ───────────────────────────────────────
|
||||
|
||||
export async function submissionGetHandler(ctx: RouteContext<SubmissionGetInput>) {
|
||||
const sub = await submissions(ctx).get(ctx.input.id);
|
||||
if (!sub) {
|
||||
throw PluginRouteError.notFound("Submission not found");
|
||||
}
|
||||
|
||||
return { id: ctx.input.id, ...sub };
|
||||
}
|
||||
|
||||
// ─── Update Submission ───────────────────────────────────────────
|
||||
|
||||
export async function submissionUpdateHandler(ctx: RouteContext<SubmissionUpdateInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await submissions(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Submission not found");
|
||||
}
|
||||
|
||||
const updated: Submission = {
|
||||
...existing,
|
||||
status: input.status ?? existing.status,
|
||||
starred: input.starred ?? existing.starred,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
};
|
||||
|
||||
await submissions(ctx).put(input.id, updated);
|
||||
|
||||
return { id: input.id, ...updated };
|
||||
}
|
||||
|
||||
// ─── Delete Submission ───────────────────────────────────────────
|
||||
|
||||
export async function submissionDeleteHandler(ctx: RouteContext<SubmissionDeleteInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
const existing = await submissions(ctx).get(input.id);
|
||||
if (!existing) {
|
||||
throw PluginRouteError.notFound("Submission not found");
|
||||
}
|
||||
|
||||
// Delete associated media files
|
||||
if (existing.files && ctx.media && "delete" in ctx.media) {
|
||||
const mediaWithDelete = ctx.media as { delete(id: string): Promise<boolean> };
|
||||
for (const file of existing.files) {
|
||||
await mediaWithDelete.delete(file.mediaId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await submissions(ctx).delete(input.id);
|
||||
|
||||
// Update form counter using count() to avoid race conditions
|
||||
if (existing.formId) {
|
||||
const form = await forms(ctx).get(existing.formId);
|
||||
if (form) {
|
||||
const count = await submissions(ctx).count({ formId: existing.formId });
|
||||
await forms(ctx).put(existing.formId, {
|
||||
...form,
|
||||
submissionCount: count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// <20><>── Export Submissions ──────────────────────────────────────────
|
||||
|
||||
export async function exportHandler(ctx: RouteContext<ExportInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
// Load form definition
|
||||
let form: FormDefinition | null = null;
|
||||
const byId = await forms(ctx).get(input.formId);
|
||||
if (byId) {
|
||||
form = byId;
|
||||
} else {
|
||||
const bySlug = await forms(ctx).query({
|
||||
where: { slug: input.formId },
|
||||
limit: 1,
|
||||
});
|
||||
if (bySlug.items.length > 0) {
|
||||
form = bySlug.items[0]!.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, string | number | boolean | null | Record<string, string>> = {
|
||||
formId: input.formId,
|
||||
};
|
||||
if (input.status) where.status = input.status;
|
||||
if (input.from || input.to) {
|
||||
const range: Record<string, string> = {};
|
||||
if (input.from) range.gte = input.from;
|
||||
if (input.to) range.lte = input.to;
|
||||
where.createdAt = range;
|
||||
}
|
||||
|
||||
// Collect all submissions (paginate through)
|
||||
const allItems: Array<{ id: string; data: Submission }> = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const batch = await submissions(ctx).query({
|
||||
where: where as Record<string, string | number | boolean | null>,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
for (const item of batch.items) {
|
||||
allItems.push(item);
|
||||
}
|
||||
|
||||
cursor = batch.cursor;
|
||||
} while (cursor);
|
||||
|
||||
if (input.format === "json") {
|
||||
return {
|
||||
data: allItems.map((item) => item.data),
|
||||
count: allItems.length,
|
||||
contentType: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
// CSV
|
||||
const csv = formatCsv(form, allItems);
|
||||
return {
|
||||
data: csv,
|
||||
count: allItems.length,
|
||||
contentType: "text/csv",
|
||||
filename: `${form.slug}-submissions-${new Date().toISOString().split("T")[0]}.csv`,
|
||||
};
|
||||
}
|
||||
297
packages/plugins/forms/src/handlers/submit.ts
Normal file
297
packages/plugins/forms/src/handlers/submit.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Public form submission handler.
|
||||
*
|
||||
* This is the main entry point for form submissions from anonymous visitors.
|
||||
* Handles spam protection, validation, file uploads, notifications, and webhooks.
|
||||
*/
|
||||
|
||||
import type { RouteContext, StorageCollection } from "emdash";
|
||||
import { PluginRouteError } from "emdash";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import { formatSubmissionText, formatWebhookPayload } from "../format.js";
|
||||
import type { SubmitInput } from "../schemas.js";
|
||||
import { verifyTurnstile } from "../turnstile.js";
|
||||
import type { FormDefinition, Submission, SubmissionFile } from "../types.js";
|
||||
import { getFormFields } from "../types.js";
|
||||
import { validateSubmission } from "../validation.js";
|
||||
|
||||
/** Typed access to plugin storage collections */
|
||||
function forms(ctx: RouteContext): StorageCollection<FormDefinition> {
|
||||
return ctx.storage.forms as StorageCollection<FormDefinition>;
|
||||
}
|
||||
|
||||
function submissions(ctx: RouteContext): StorageCollection<Submission> {
|
||||
return ctx.storage.submissions as StorageCollection<Submission>;
|
||||
}
|
||||
|
||||
export async function submitHandler(ctx: RouteContext<SubmitInput>) {
|
||||
const input = ctx.input;
|
||||
|
||||
// 1. Load form definition (by ID first, then by slug)
|
||||
let formId = input.formId;
|
||||
let form = await forms(ctx).get(formId);
|
||||
if (!form) {
|
||||
const bySlug = await forms(ctx).query({
|
||||
where: { slug: input.formId },
|
||||
limit: 1,
|
||||
});
|
||||
if (bySlug.items.length > 0) {
|
||||
formId = bySlug.items[0]!.id;
|
||||
form = bySlug.items[0]!.data;
|
||||
}
|
||||
}
|
||||
if (!form) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
if (form.status === "paused") {
|
||||
throw new PluginRouteError(
|
||||
"FORM_PAUSED",
|
||||
"This form is not currently accepting submissions",
|
||||
410,
|
||||
);
|
||||
}
|
||||
|
||||
const settings = form.settings;
|
||||
|
||||
// 2. Spam protection
|
||||
if (settings.spamProtection === "turnstile") {
|
||||
const token = input.data["cf-turnstile-response"];
|
||||
if (typeof token !== "string" || !token) {
|
||||
throw PluginRouteError.forbidden("Spam verification required");
|
||||
}
|
||||
|
||||
const secretKey = await ctx.kv.get<string>("settings:turnstileSecretKey");
|
||||
if (!secretKey || !ctx.http) {
|
||||
throw PluginRouteError.internal("Turnstile is not configured");
|
||||
}
|
||||
|
||||
const result = await verifyTurnstile(
|
||||
token,
|
||||
secretKey,
|
||||
ctx.http.fetch.bind(ctx.http),
|
||||
ctx.requestMeta.ip,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
ctx.log.warn("Turnstile verification failed", {
|
||||
errorCodes: result.errorCodes,
|
||||
});
|
||||
throw PluginRouteError.forbidden("Spam verification failed. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.spamProtection === "honeypot") {
|
||||
if (input.data._hp) {
|
||||
// Honeypot triggered — return success silently
|
||||
return {
|
||||
success: true,
|
||||
message: settings.confirmationMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate submission data
|
||||
const allFields = getFormFields(form);
|
||||
const result = validateSubmission(allFields, input.data);
|
||||
|
||||
if (!result.valid) {
|
||||
throw PluginRouteError.badRequest("Validation failed", { errors: result.errors });
|
||||
}
|
||||
|
||||
// 4. Upload files
|
||||
const files: SubmissionFile[] = [];
|
||||
if (input.files && ctx.media && "upload" in ctx.media) {
|
||||
const mediaWithWrite = ctx.media as {
|
||||
upload(
|
||||
filename: string,
|
||||
contentType: string,
|
||||
bytes: ArrayBuffer,
|
||||
): Promise<{ mediaId: string; storageKey: string; url: string }>;
|
||||
};
|
||||
|
||||
for (const field of allFields.filter((f) => f.type === "file")) {
|
||||
const fileData = input.files[field.name];
|
||||
if (!fileData) continue;
|
||||
|
||||
// Validate file type
|
||||
if (field.validation?.accept) {
|
||||
const allowed = field.validation.accept.split(",").map((s) => s.trim().toLowerCase());
|
||||
const ext = `.${fileData.filename.split(".").pop()?.toLowerCase()}`;
|
||||
const typeMatch = allowed.some(
|
||||
(a) =>
|
||||
a === ext ||
|
||||
a === fileData.contentType ||
|
||||
fileData.contentType.startsWith(a.replace("/*", "/")),
|
||||
);
|
||||
if (!typeMatch) {
|
||||
throw PluginRouteError.badRequest(`File type not allowed for ${field.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (
|
||||
field.validation?.maxFileSize &&
|
||||
fileData.bytes.byteLength > field.validation.maxFileSize
|
||||
) {
|
||||
throw PluginRouteError.badRequest(
|
||||
`File too large for ${field.label}. Maximum: ${Math.round(field.validation.maxFileSize / 1024)} KB`,
|
||||
);
|
||||
}
|
||||
|
||||
const uploaded = await mediaWithWrite.upload(
|
||||
fileData.filename,
|
||||
fileData.contentType,
|
||||
fileData.bytes,
|
||||
);
|
||||
|
||||
files.push({
|
||||
fieldName: field.name,
|
||||
filename: fileData.filename,
|
||||
contentType: fileData.contentType,
|
||||
size: fileData.bytes.byteLength,
|
||||
mediaId: uploaded.mediaId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Store submission
|
||||
const submissionId = ulid();
|
||||
const submission: Submission = {
|
||||
formId,
|
||||
data: result.data,
|
||||
files: files.length > 0 ? files : undefined,
|
||||
status: "new",
|
||||
starred: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
meta: {
|
||||
ip: ctx.requestMeta.ip,
|
||||
userAgent: ctx.requestMeta.userAgent,
|
||||
referer: ctx.requestMeta.referer,
|
||||
country: ctx.requestMeta.geo?.country ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
await submissions(ctx).put(submissionId, submission);
|
||||
|
||||
// 6. Update form counters (use count() to avoid race conditions
|
||||
// from concurrent submissions doing read-modify-write)
|
||||
const submissionCount = await submissions(ctx).count({ formId });
|
||||
await forms(ctx).put(formId, {
|
||||
...form,
|
||||
submissionCount,
|
||||
lastSubmissionAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 7. Immediate email notifications (not digest)
|
||||
if (settings.notifyEmails.length > 0 && !settings.digestEnabled && ctx.email) {
|
||||
const text = formatSubmissionText(form, result.data, files);
|
||||
for (const email of settings.notifyEmails) {
|
||||
await ctx.email
|
||||
.send({
|
||||
to: email,
|
||||
subject: `New submission: ${form.name}`,
|
||||
text,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Failed to send notification email", {
|
||||
error: String(err),
|
||||
to: email,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Autoresponder
|
||||
if (settings.autoresponder && ctx.email) {
|
||||
const emailField = allFields.find((f) => f.type === "email");
|
||||
const submitterEmail = emailField ? result.data[emailField.name] : null;
|
||||
if (typeof submitterEmail === "string" && submitterEmail) {
|
||||
await ctx.email
|
||||
.send({
|
||||
to: submitterEmail,
|
||||
subject: settings.autoresponder.subject,
|
||||
text: settings.autoresponder.body,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Failed to send autoresponder", { error: String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Webhook (fire and forget)
|
||||
if (settings.webhookUrl && ctx.http) {
|
||||
const payload = formatWebhookPayload(form, submissionId, result.data, files);
|
||||
ctx.http
|
||||
.fetch(settings.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
ctx.log.error("Webhook failed", {
|
||||
error: String(err),
|
||||
url: settings.webhookUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Return success
|
||||
return {
|
||||
success: true,
|
||||
message: settings.confirmationMessage,
|
||||
redirect: settings.redirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public Form Definition Endpoint ─────────────────────────────
|
||||
|
||||
export async function definitionHandler(
|
||||
ctx: RouteContext<import("../schemas.js").DefinitionInput>,
|
||||
) {
|
||||
const { id } = ctx.input;
|
||||
|
||||
// Look up by ID first, then by slug
|
||||
let form = await forms(ctx).get(id);
|
||||
|
||||
if (!form) {
|
||||
const bySlug = await forms(ctx).query({
|
||||
where: { slug: id },
|
||||
limit: 1,
|
||||
});
|
||||
if (bySlug.items.length > 0) {
|
||||
form = bySlug.items[0]!.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
throw PluginRouteError.notFound("Form not found");
|
||||
}
|
||||
|
||||
if (form.status !== "active") {
|
||||
throw new PluginRouteError("FORM_PAUSED", "This form is not currently available", 410);
|
||||
}
|
||||
|
||||
// Include Turnstile site key if configured
|
||||
const turnstileSiteKey =
|
||||
form.settings.spamProtection === "turnstile"
|
||||
? await ctx.kv.get<string>("settings:turnstileSiteKey")
|
||||
: null;
|
||||
|
||||
// Return only the settings needed for client rendering — never expose
|
||||
// admin emails, webhook URLs, or other internal configuration.
|
||||
return {
|
||||
name: form.name,
|
||||
slug: form.slug,
|
||||
pages: form.pages,
|
||||
settings: {
|
||||
spamProtection: form.settings.spamProtection,
|
||||
submitLabel: form.settings.submitLabel,
|
||||
nextLabel: form.settings.nextLabel,
|
||||
prevLabel: form.settings.prevLabel,
|
||||
},
|
||||
status: form.status,
|
||||
_turnstileSiteKey: turnstileSiteKey,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user