Files
emdash-patch-imageupload/packages/plugins/forms/src/handlers/submissions.ts
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

192 lines
5.5 KiB
TypeScript
Raw Blame History

/**
* 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`,
};
}