/** * 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 { return ctx.storage.forms as StorageCollection; } function submissions(ctx: PluginContext): StorageCollection { return ctx.storage.submissions as StorageCollection; } /** * 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 }; 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, }); }); } }