@murumets-ee/forms-ui 0.101.0 → 0.103.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"pages.mjs","names":["UUID_RE"],"sources":["../src/pages/form-facts.ts","../src/pages/form-canvas-page.tsx","../src/pages/forms-list-page.tsx","../src/pages/privacy-page.tsx","../src/pages/submissions-page.tsx"],"sourcesContent":["/**\n * What the Forms list (W1) and the form canvas (W2) show about the spam\n * protection (forms D004): how many submissions each form has dropped, and\n * whether the site can run each extra check.\n *\n * Read on the SERVER, inside the page's admin context, behind the page's own\n * gate (`forms:manage`). Counts only — nothing a visitor sent.\n */\n\nimport { getApp, requireRequestSite } from '@murumets-ee/core'\nimport { FORMS_SETTINGS_NAMESPACE, formSpamCounts, formsSettings } from '@murumets-ee/forms'\nimport { challengeSecretFor } from '@murumets-ee/forms/server'\nimport { SettingsClient } from '@murumets-ee/settings'\n\n/** Dropped-as-spam counts for `formIds` (at most a page's worth), by id; absent = 0. */\nexport async function discardedCounts(formIds: readonly string[]): Promise<Map<string, number>> {\n const counts = new Map<string, number>()\n if (formIds.length === 0) return counts\n const rows = await formSpamCounts.makeClient(getApp().db.readWrite).findMany({\n where: { formId: { in: [...formIds] } },\n limit: formIds.length,\n })\n for (const row of rows) counts.set(row.formId, row.discarded)\n return counts\n}\n\n/** Whether this site can run each extra check: a site key in settings AND a secret on the server. */\nexport async function challengeReadiness(): Promise<{ turnstile: boolean; recaptcha: boolean }> {\n const app = getApp()\n const site = await requireRequestSite(FORMS_SETTINGS_NAMESPACE, app)\n const settings = new SettingsClient(formsSettings, { app, site })\n const [turnstileKey, recaptchaKey] = await Promise.all([\n settings.get('turnstileSiteKey'),\n settings.get('recaptchaSiteKey'),\n ])\n const has = (value: unknown) => typeof value === 'string' && value !== ''\n return {\n turnstile:\n has(turnstileKey) && challengeSecretFor('turnstile', site, process.env) !== undefined,\n recaptcha:\n has(recaptchaKey) && challengeSecretFor('recaptcha', site, process.env) !== undefined,\n }\n}\n","/**\n * The form canvas (W2) — a form open on its own canvas, in the `form`\n * environment.\n *\n * It owns `/admin/forms/:id` (and the singular `/admin/form/:id`), the URLs\n * the generic entity edit page would have answered, so a form has one editor and it is gated by the\n * job permission: the route's `forms:manage`, then — inside\n * `renderEntityEditFallback` — the entity's own `form:update`, which is also\n * what every write from the canvas is checked against. The editor itself is\n * the toolkit's: drafts, publish, locks, undo — nothing re-implemented.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission, renderEntityEditFallback } from '@murumets-ee/admin-ui/pages'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n submissionCounts,\n} from '@murumets-ee/forms'\nimport { FormCanvasFactsProvider } from '@murumets-ee/forms/editor'\nimport { notFound } from 'next/navigation'\nimport type { ReactNode } from 'react'\nimport { challengeReadiness, discardedCounts } from './form-facts.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport async function FormCanvasPage({\n params,\n searchParams,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const { id, locale } = await params\n // `forms/new` lands here too (this route shadows the entity family); a form\n // is only ever created from the Forms list, so anything but an id is a 404.\n if (!UUID_RE.test(id)) notFound()\n const config = getRegisteredAdminConfig()\n // The editor FIRST: it is what refuses another site's id or a role without\n // `form:update` (a no-leak 404), so nothing below runs for a form the\n // person may not open.\n const editor = await renderEntityEditFallback(Form, config, { params, searchParams })\n // What only the server knows, for the Form panel's Spam tab (D004): the\n // blocked count and which extra checks this site can run. Supplementary:\n // a failed read never costs the editor. Without the count the panel leaves\n // that line out; without the readiness it gets no facts at all, as on a\n // canvas that does not provide them.\n const facts = await config\n .withAdminContext(locale, async () => {\n const adminBase = config.adminBasePath.replace(':locale', locale)\n const [counts, ready, fresh] = await Promise.all([\n discardedCounts([id]).catch(() => {\n // Never the error object: only the fact, and which form.\n console.warn(`forms: the blocked count for form ${id} could not be read`)\n return null\n }),\n challengeReadiness(),\n // The way to this form's Submissions (W4), with how many are new —\n // only for a person who may open them, and never at the editor's cost.\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config)\n .then(async (allowed) =>\n allowed ? ((await submissionCounts(getApp(), [id])).get(id)?.fresh ?? 0) : null,\n )\n .catch(() => {\n console.warn(`forms: the new-submissions count for form ${id} could not be read`)\n return null\n }),\n ])\n return {\n discarded: counts === null ? null : (counts.get(id) ?? 0),\n ready,\n settingsHref: `${adminBase}/settings/${FORMS_SETTINGS_NAMESPACE}`,\n ...(fresh !== null && {\n submissions: { href: `${adminBase}/forms/${id}/submissions`, fresh },\n }),\n }\n })\n .catch(() => {\n console.warn(`forms: the spam facts for form ${id} could not be read`)\n return null\n })\n if (facts === null) return editor\n return <FormCanvasFactsProvider facts={facts}>{editor}</FormCanvasFactsProvider>\n}\n","/**\n * Forms (W1) — the selected site's forms, and the way into each one's canvas\n * and its submissions.\n *\n * Gated by `form:view` on its route (the plugin's `defaultRoutes`), which is\n * also what the sidebar link inherits: seeing the list is seeing this site's\n * forms. What a person may DO from it follows their job permissions, each\n * asked here and each only deciding what is drawn —\n *\n * - `forms:manage` (build forms): the name opens the canvas, and \"New form\".\n * - `form_submissions:view`: the Submissions and New counts, each a link to\n * the form's Submissions screen (W4). For a person who holds this and not\n * `forms:manage` — the read-only role of D005 — the name opens Submissions\n * too, so the list is their way in. Also the \"Privacy requests\" link (W5)\n * and the line saying when the daily clean-up last ran and what it removed\n * (D007).\n * - `form:delete` together with `forms:manage`: \"Delete…\" on a row — the form\n * AND its submissions, after a confirm that names the count (the route\n * decides; see `handleRemove`).\n *\n * Lists THIS site's forms only: `form` is site-bound, so the entity read is\n * scoped by the request's site like every other bound entity (C2).\n *\n * The columns are the approved wireframe's. \"Used on\" counts the PUBLISHED\n * pages that show the form (forms D002 — from `entity_refs`, which a page's\n * Form block feeds when the page is published); \"Blocked\" counts what the spam\n * checks dropped (D004). A number that could not be read, or that this person\n * may not see, is a dash — never a zero that claims a count nobody took.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasEntityPermission, hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { fetchEntityList } from '@murumets-ee/admin-ui/server'\nimport { WorkflowStatusBadge } from '@murumets-ee/admin-ui/workflow'\nimport { countEmbedUsages } from '@murumets-ee/blocks/server'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n FORM_ENTITY,\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_RESOURCE,\n Form,\n lastRetentionRun,\n submissionCounts,\n} from '@murumets-ee/forms'\nimport { DeleteFormButton, NewFormDialog } from '@murumets-ee/forms-ui'\nimport {\n Button,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { discardedCounts } from './form-facts.js'\n\n/** A site's forms are a handful; the bound keeps one page load one query. */\nconst MAX_FORMS = 200\n\nexport async function FormsListPage({ params }: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n const t = await getTranslations('Forms.list')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n\n return config.withAdminContext(locale, async () => {\n // The route's gate is `form:view`, asked again here because this is the\n // read it guards: a page rendered past its gate must still not list.\n if (!(await hasEntityPermission(Form, 'view', config))) notFound()\n const [{ items, total }, canCreate, canDelete, canBuild, canSeeSubmissions] = await Promise.all(\n [\n fetchEntityList(Form, { sortField: 'name', sortDirection: 'asc', limit: MAX_FORMS }),\n hasResourcePermission('form', 'create', config),\n hasResourcePermission('form', 'delete', config),\n hasResourcePermission(FORMS_RESOURCE, 'manage', config),\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config),\n ],\n )\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n // One grouped query for every listed form. A failure costs the column its\n // numbers (a dash), never the list.\n const usedOn = await countEmbedUsages(\n FORM_ENTITY,\n items.map((form) => form.id),\n ).catch(() => null)\n // Dropped-as-spam per form (D004) — one bounded read for the listed forms.\n // A failure costs the column its numbers (a dash), never the list.\n // Shown to people who build forms or read submissions — not to a role that\n // can only see that the forms exist.\n const blocked = !(canBuild || canSeeSubmissions)\n ? null\n : await discardedCounts(items.map((form) => String(form.id))).catch(() => {\n console.warn('forms: the blocked counts for the Forms list could not be read')\n return null\n })\n\n // Stored and new submissions per form — one grouped query for the list,\n // and only for a person who may see submissions at all.\n const received = canSeeSubmissions\n ? await submissionCounts(\n getApp(),\n items.map((form) => String(form.id)),\n ).catch(() => {\n console.warn('forms: the submission counts for the Forms list could not be read')\n return null\n })\n : null\n const submissionsHref = (id: unknown) => `${formsBasePath}/${String(id)}/submissions`\n // When the daily clean-up last ran here and what it removed (D007) — for\n // the people whose submissions it removes. `undefined`: could not be read\n // (the line is left out); `null`: it has never run on this site.\n const lastRun = canSeeSubmissions\n ? await lastRetentionRun(getApp()).catch(() => {\n console.warn('forms: the last clean-up run for the Forms list could not be read')\n return undefined\n })\n : undefined\n const format = await getFormatter()\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-2xl font-bold\">{t('title')}</h1>\n <p className=\"text-sm text-muted-foreground\">{t('description')}</p>\n </div>\n <div className=\"flex items-center gap-2\">\n {canSeeSubmissions && (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <Link href={`${formsBasePath}/privacy`} data-forms-privacy-link=\"\">\n {t('privacy')}\n </Link>\n </Button>\n )}\n {canCreate && canBuild && (\n <NewFormDialog apiBasePath={apiBasePath} formsBasePath={formsBasePath} />\n )}\n </div>\n </div>\n\n {items.length === 0 ? (\n <p className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n {t('empty')}\n </p>\n ) : (\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t('name')}</TableHead>\n <TableHead>{t('usedOn')}</TableHead>\n <TableHead className=\"text-right\">{t('submissions')}</TableHead>\n <TableHead className=\"text-right\">{t('new')}</TableHead>\n <TableHead className=\"text-right\">{t('blocked')}</TableHead>\n <TableHead>{t('status')}</TableHead>\n {canDelete && canBuild && <TableHead className=\"w-0\" />}\n </TableRow>\n </TableHeader>\n <TableBody>\n {items.map((form) => (\n <TableRow key={form.id}>\n <TableCell className=\"font-medium\">\n {canBuild || canSeeSubmissions ? (\n <Link\n href={canBuild ? `${formsBasePath}/${form.id}` : submissionsHref(form.id)}\n className=\"text-primary hover:underline\"\n >\n {form.name}\n </Link>\n ) : (\n form.name\n )}\n </TableCell>\n <TableCell className=\"text-muted-foreground\" data-forms-used-on={form.id}>\n {usedOn === null\n ? t('notYet')\n : t('usedOnPages', { count: usedOn.get(form.id) ?? 0 })}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\" data-forms-submissions={form.id}>\n {received === null ? (\n <span className=\"text-muted-foreground\">{t('notYet')}</span>\n ) : (\n <Link\n href={submissionsHref(form.id)}\n className=\"text-primary hover:underline\"\n aria-label={t('submissionsOf', {\n form: String(form.name),\n count: received.get(String(form.id))?.total ?? 0,\n })}\n >\n {received.get(String(form.id))?.total ?? 0}\n </Link>\n )}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\" data-forms-new={form.id}>\n {received === null ? (\n <span className=\"text-muted-foreground\">{t('notYet')}</span>\n ) : (\n (received.get(String(form.id))?.fresh ?? 0)\n )}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\">\n {blocked === null ? t('notYet') : (blocked.get(String(form.id)) ?? 0)}\n </TableCell>\n <TableCell>\n <WorkflowStatusBadge\n status={form.status === 'published' ? 'published' : 'draft'}\n />\n </TableCell>\n {canDelete && canBuild && (\n <TableCell className=\"text-right\">\n <DeleteFormButton\n apiBasePath={apiBasePath}\n formId={String(form.id)}\n formName={String(form.name)}\n submissions={\n received === null ? null : (received.get(String(form.id))?.total ?? 0)\n }\n pages={usedOn === null ? null : (usedOn.get(form.id) ?? 0)}\n />\n </TableCell>\n )}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n )}\n {total > items.length && (\n <p className=\"text-xs text-muted-foreground\">{t('truncated', { count: items.length })}</p>\n )}\n {lastRun !== undefined && (\n <p className=\"text-xs text-muted-foreground\" data-forms-retention-run=\"\">\n {lastRun === null\n ? t('retentionNever')\n : `${t('retentionRun', {\n when: format.dateTime(new Date(lastRun.lastRunAt), {\n dateStyle: 'medium',\n timeStyle: 'short',\n }),\n count: lastRun.removed,\n })}${lastRun.unfinished ? ` ${t('retentionUnfinished')}` : ''}`}\n </p>\n )}\n </div>\n )\n })\n}\n","/**\n * Privacy requests (W5) — find one person's submissions on this site by email\n * address, export them, erase them (forms D007).\n *\n * `/admin/forms/privacy`, reached from the Forms list's header; never a menu\n * entry of its own. Gated on `form_submissions:view` by its route (the\n * plugin's `defaultRoutes`, forms D011) — the same permission that opens a\n * form's Submissions, because what this screen reaches is the same answers.\n * Erasing additionally needs `form_submissions:manage` AND a person's own\n * session — the erase route is closed to every agent door (`privacy-routes.ts`\n * says why); without both the button is not drawn, and the route refuses in\n * any case.\n *\n * The page itself reads NOTHING about anyone: it draws the search box. The\n * address is typed into the client view and sent in a request body\n * (`privacy-routes.ts`), so it never appears in a URL.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { getContext, isTokenDerivedDoor, type PageProps } from '@murumets-ee/core'\nimport { FORM_SUBMISSIONS_RESOURCE } from '@murumets-ee/forms'\nimport { PrivacyRequestsView } from '@murumets-ee/forms-ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\n\nexport async function PrivacyRequestsPage({\n params,\n}: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n const t = await getTranslations('Forms.privacy')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n\n return config.withAdminContext(locale, async () => {\n // The route's gate, asked again: a page rendered past its gate must still\n // offer nothing.\n if (!(await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config))) notFound()\n // An agent's browser session finds and exports; it is never offered an\n // erase its route would refuse.\n // The door is read from the request context the app's `withAdminContext`\n // establishes (the in-repo admin and the scaffold both carry it). An app\n // whose own copy does not would draw the button for an agent session —\n // and the route would still refuse it, with the reason shown.\n const canErase =\n !isTokenDerivedDoor(getContext()?.door) &&\n (await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'manage', config))\n return (\n <div className=\"space-y-4 p-6\">\n <div>\n <p className=\"text-sm text-muted-foreground\">\n <Link href={formsBasePath} className=\"hover:underline\">\n ← {t('backToForms')}\n </Link>\n </p>\n <h1 className=\"text-2xl font-bold\">{t('title')}</h1>\n <p className=\"max-w-prose text-sm text-muted-foreground\">{t('intro')}</p>\n </div>\n <PrivacyRequestsView\n apiBasePath={config.apiBasePath ?? '/api/admin'}\n formsBasePath={formsBasePath}\n locale={locale}\n canErase={canErase}\n />\n </div>\n )\n })\n}\n","/**\n * Submissions (W4) — what visitors sent through one form.\n *\n * `/admin/forms/:id/submissions`, gated on `form_submissions:view` by its\n * route (the plugin's `defaultRoutes`). The rows are read HERE, on the server,\n * through `@murumets-ee/forms`' submission functions — an elevated read the\n * page's gate has authorized, confined to the request's site (see\n * `submissions.ts`): another site's form id is a 404, and so is a form of\n * this site for a role without the permission.\n *\n * The filters are a plain GET form — status, page, day range — so the URL is\n * the state: it can be bookmarked, it works without JavaScript, and \"Export\n * CSV\" exports exactly what the URL names.\n *\n * Columns: one per field of the form as it is saved now, then one per retired\n * key that an answer on this page still holds (D005).\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n collectRetiredColumns,\n currentFormFields,\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_RESOURCE,\n Form,\n listSubmissions,\n parseSubmissionFilters,\n STATUS_FILTERS,\n SUBMISSIONS_PAGE_SIZE,\n submissionColumns,\n submissionFilterParams,\n submissionPages,\n} from '@murumets-ee/forms'\nimport { SubmissionsExportLink, SubmissionsView } from '@murumets-ee/forms-ui'\nimport { Button } from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound, redirect } from 'next/navigation'\nimport { getTimeZone, getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { discardedCounts } from './form-facts.js'\n\n/** Far past any real list; keeps a hand-typed `?p=` from becoming an absurd offset. */\nconst MAX_PAGE = 100_000\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** The first value of a query parameter that may repeat. */\nconst first = (value: string | string[] | undefined): string | undefined =>\n Array.isArray(value) ? value[0] : value\n\nexport async function SubmissionsPage({\n params,\n searchParams,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const { id, locale } = await params\n if (!UUID_RE.test(id)) notFound()\n const query = await searchParams\n const config = getRegisteredAdminConfig()\n setRequestLocale(locale)\n const t = await getTranslations('Forms.submissions')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n\n return config.withAdminContext(locale, async () => {\n // The route's gate is `form_submissions:view`, asked again here because\n // everything below reads through an elevated client: a page rendered past\n // its gate must still show nothing.\n if (!(await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config))) notFound()\n const app = getApp()\n // The form of THIS site, or nothing: the elevated read keeps the request's\n // site, so another site's id is not found.\n const form: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n if (form === null || form === undefined) notFound()\n\n // Days are days of the zone the rows' times are shown in (next-intl's),\n // so \"from the 9th\" matches the clock a person reads beside each row.\n const timeZone = await getTimeZone()\n const filters = parseSubmissionFilters((name) => first(query[name]), { timeZone })\n // A whole page number within reach — `?p=1e400` is not one.\n const asked = Number(first(query.p))\n const pageNumber = Number.isFinite(asked)\n ? Math.min(Math.max(Math.trunc(asked), 1), MAX_PAGE)\n : 1\n const [{ items, total }, pages, canManage, canBuild, blocked] = await Promise.all([\n listSubmissions(app, id, filters, {\n limit: SUBMISSIONS_PAGE_SIZE,\n offset: (pageNumber - 1) * SUBMISSIONS_PAGE_SIZE,\n }),\n submissionPages(app, id),\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'manage', config),\n hasResourcePermission(FORMS_RESOURCE, 'manage', config),\n // Supplementary: a failed read costs the line, never the screen.\n discardedCounts([id]).catch(() => {\n console.warn(`forms: the blocked count for form ${id} could not be read`)\n return null\n }),\n ])\n\n const current = currentFormFields(form.body)\n const columns = submissionColumns(current, collectRetiredColumns(current, items))\n const filterQuery = submissionFilterParams(filters)\n const exportQuery = new URLSearchParams(filterQuery)\n exportQuery.set('form', id)\n exportQuery.set('uiLocale', locale)\n exportQuery.set('tz', filters.timeZone)\n const pageHref = (page: number): string => {\n const next = new URLSearchParams(filterQuery)\n if (page > 1) next.set('p', String(page))\n const text = next.toString()\n return `${formsBasePath}/${id}/submissions${text === '' ? '' : `?${text}`}`\n }\n const lastPage = Math.max(Math.ceil(total / SUBMISSIONS_PAGE_SIZE), 1)\n // Past the end — the last rows of the page were deleted or archived, or\n // the URL was typed: show the last page that has rows, never an empty one\n // that reads as \"no submissions\".\n if (items.length === 0 && total > 0 && pageNumber > lastPage) redirect(pageHref(lastPage))\n const filtered = filterQuery.toString() !== ''\n const name = typeof form.name === 'string' ? form.name : ''\n const discarded = blocked === null ? null : (blocked.get(id) ?? 0)\n // The page filter keeps a value that is no longer among the options (its\n // last submission was deleted), so the select never shows a blank.\n const pageOptions =\n filters.page !== undefined && !pages.includes(filters.page) ? [filters.page, ...pages] : pages\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex flex-wrap items-start justify-between gap-4\">\n <div>\n <p className=\"text-sm text-muted-foreground\">\n <Link href={formsBasePath} className=\"hover:underline\">\n ← {t('backToForms')}\n </Link>\n </p>\n <h1 className=\"text-2xl font-bold\">{t('title', { form: name })}</h1>\n </div>\n <div className=\"flex items-center gap-2\">\n {canBuild && (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <Link href={`${formsBasePath}/${id}`}>{t('openForm')}</Link>\n </Button>\n )}\n <SubmissionsExportLink\n href={`${apiBasePath}/form-submissions/export?${exportQuery.toString()}`}\n />\n </div>\n </div>\n\n <form\n method=\"get\"\n action={`${formsBasePath}/${id}/submissions`}\n className=\"flex flex-wrap items-end gap-3 rounded-md border p-3 text-sm\"\n >\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.from')}</span>\n <input\n type=\"date\"\n name=\"from\"\n defaultValue={filters.from ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n />\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.to')}</span>\n <input\n type=\"date\"\n name=\"to\"\n defaultValue={filters.to ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n />\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.page')}</span>\n <select\n name=\"page\"\n defaultValue={filters.page ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n >\n <option value=\"\">{t('filters.allPages')}</option>\n {pageOptions.map((path) => (\n <option key={path} value={path}>\n {path}\n </option>\n ))}\n </select>\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.status')}</span>\n <select\n name=\"status\"\n defaultValue={filters.status}\n className=\"h-8 rounded-md border bg-background px-2\"\n >\n {STATUS_FILTERS.map((status) => (\n <option key={status} value={status}>\n {t(`filters.statuses.${status}`)}\n </option>\n ))}\n </select>\n </label>\n <Button type=\"submit\" size=\"sm\" variant=\"secondary\">\n {t('filters.apply')}\n </Button>\n {filtered && (\n <Button size=\"sm\" variant=\"ghost\" asChild>\n <Link href={`${formsBasePath}/${id}/submissions`}>{t('filters.clear')}</Link>\n </Button>\n )}\n {discarded !== null && discarded > 0 && (\n <span className=\"ml-auto text-xs text-muted-foreground\" data-forms-blocked>\n {t('blocked', { count: discarded })}\n </span>\n )}\n </form>\n\n {/* Always mounted: the panel holds the submission being read, which may\n be the last one a filter listed. Keyed by what is listed, so moving\n to another page or filter starts with nothing open. */}\n <SubmissionsView\n key={`${filterQuery.toString()}#${pageNumber}`}\n formId={id}\n rows={items}\n columns={columns}\n canManage={canManage}\n apiBasePath={apiBasePath}\n emptyText={filtered ? t('emptyFiltered') : t('empty')}\n />\n\n <div className=\"flex items-center justify-between text-sm text-muted-foreground\">\n <span>{t('showing', { shown: items.length, total })}</span>\n {lastPage > 1 && (\n <span className=\"flex items-center gap-2\">\n {pageNumber > 1 && (\n <Link href={pageHref(pageNumber - 1)} className=\"hover:underline\">\n ← {t('previous')}\n </Link>\n )}\n <span>{t('pageOf', { page: Math.min(pageNumber, lastPage), pages: lastPage })}</span>\n {pageNumber < lastPage && (\n <Link href={pageHref(pageNumber + 1)} className=\"hover:underline\">\n {t('next')} →\n </Link>\n )}\n </span>\n )}\n </div>\n </div>\n )\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"pages.mjs","names":["UUID_RE"],"sources":["../src/pages/form-facts.ts","../src/pages/form-canvas-page.tsx","../src/pages/forms-list-page.tsx","../src/pages/privacy-page.tsx","../src/pages/submissions-page.tsx"],"sourcesContent":["/**\n * What the Forms list (W1) and the form canvas (W2) show about the spam\n * protection (forms D004): how many submissions each form has dropped, and\n * whether the site can run each extra check.\n *\n * Read on the SERVER, inside the page's admin context, behind the page's own\n * gate (`forms:manage`). Counts only — nothing a visitor sent.\n */\n\nimport { getApp, requireRequestSite } from '@murumets-ee/core'\nimport {\n compileForm,\n ensureFormEmailDefaults,\n FORMS_AUTO_REPLY_TYPE,\n FORMS_NOTIFICATION_TYPE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n formSpamCounts,\n formsSettings,\n readEmailSettings,\n} from '@murumets-ee/forms'\nimport type { FormEmailFacts } from '@murumets-ee/forms/editor'\nimport { challengeSecretFor } from '@murumets-ee/forms/server'\nimport { instanceTypeId } from '@murumets-ee/notifications/define'\nimport { SettingsClient } from '@murumets-ee/settings'\n\n/** Dropped-as-spam counts for `formIds` (at most a page's worth), by id; absent = 0. */\nexport async function discardedCounts(formIds: readonly string[]): Promise<Map<string, number>> {\n const counts = new Map<string, number>()\n if (formIds.length === 0) return counts\n const rows = await formSpamCounts.makeClient(getApp().db.readWrite).findMany({\n where: { formId: { in: [...formIds] } },\n limit: formIds.length,\n })\n for (const row of rows) counts.set(row.formId, row.discarded)\n return counts\n}\n\n/** Whether this site can run each extra check: a site key in settings AND a secret on the server. */\nexport async function challengeReadiness(): Promise<{ turnstile: boolean; recaptcha: boolean }> {\n const app = getApp()\n const site = await requireRequestSite(FORMS_SETTINGS_NAMESPACE, app)\n const settings = new SettingsClient(formsSettings, { app, site })\n const [turnstileKey, recaptchaKey] = await Promise.all([\n settings.get('turnstileSiteKey'),\n settings.get('recaptchaSiteKey'),\n ])\n const has = (value: unknown) => typeof value === 'string' && value !== ''\n return {\n turnstile:\n has(turnstileKey) && challengeSecretFor('turnstile', site, process.env) !== undefined,\n recaptcha:\n has(recaptchaKey) && challengeSecretFor('recaptcha', site, process.env) !== undefined,\n }\n}\n\n/**\n * The Emails tab's facts for form `formId` (forms D006): its recipients and\n * auto-reply, its email fields, and where its two emails are edited.\n *\n * A form made before emails existed has no defaults yet; they are added here —\n * idempotently, behind the page's `forms:manage` gate — so the editor link\n * always opens a real template.\n *\n * `canEditTemplates` is the viewer's own `message_template:update`: the\n * template editor is the administrator's, so an editor without it is told so\n * rather than shown a link that 404s.\n */\nexport async function emailFacts(args: {\n formId: string\n adminBase: string\n apiBasePath: string\n canEditTemplates: boolean\n}): Promise<FormEmailFacts | null> {\n const app = getApp()\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(args.formId)\n if (row === null || row === undefined) return null\n await ensureFormEmailDefaults(app, args.formId)\n const settings = await readEmailSettings(app, args.formId)\n const emailFields = (compileForm(row.body)?.fields ?? [])\n .filter((field) => field.kind === 'email')\n .map((field) => ({ key: field.key, label: field.label !== '' ? field.label : field.key }))\n const editor = (base: string): string | null =>\n args.canEditTemplates\n ? `${args.adminBase}/notifications/templates/${encodeURIComponent(instanceTypeId(base, args.formId))}/email`\n : null\n return {\n formId: args.formId,\n recipients: settings.recipients,\n autoReplyEnabled: settings.autoReplyEnabled,\n autoReplyField: settings.autoReplyField,\n emailFields,\n notificationHref: editor(FORMS_NOTIFICATION_TYPE),\n autoReplyHref: editor(FORMS_AUTO_REPLY_TYPE),\n apiBasePath: args.apiBasePath,\n }\n}\n","/**\n * The form canvas (W2) — a form open on its own canvas, in the `form`\n * environment.\n *\n * It owns `/admin/forms/:id` (and the singular `/admin/form/:id`), the URLs\n * the generic entity edit page would have answered, so a form has one editor and it is gated by the\n * job permission: the route's `forms:manage`, then — inside\n * `renderEntityEditFallback` — the entity's own `form:update`, which is also\n * what every write from the canvas is checked against. The editor itself is\n * the toolkit's: drafts, publish, locks, undo — nothing re-implemented.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission, renderEntityEditFallback } from '@murumets-ee/admin-ui/pages'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n submissionCounts,\n} from '@murumets-ee/forms'\nimport { FormCanvasFactsProvider } from '@murumets-ee/forms/editor'\nimport { notFound } from 'next/navigation'\nimport type { ReactNode } from 'react'\nimport { challengeReadiness, discardedCounts, emailFacts } from './form-facts.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport async function FormCanvasPage({\n params,\n searchParams,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const { id, locale } = await params\n // `forms/new` lands here too (this route shadows the entity family); a form\n // is only ever created from the Forms list, so anything but an id is a 404.\n if (!UUID_RE.test(id)) notFound()\n const config = getRegisteredAdminConfig()\n // The editor FIRST: it is what refuses another site's id or a role without\n // `form:update` (a no-leak 404), so nothing below runs for a form the\n // person may not open.\n const editor = await renderEntityEditFallback(Form, config, { params, searchParams })\n // What only the server knows, for the Form panel's Spam tab (D004): the\n // blocked count and which extra checks this site can run. Supplementary:\n // a failed read never costs the editor. Without the count the panel leaves\n // that line out; without the readiness it gets no facts at all, as on a\n // canvas that does not provide them.\n const facts = await config\n .withAdminContext(locale, async () => {\n const adminBase = config.adminBasePath.replace(':locale', locale)\n const [counts, ready, fresh, emails] = await Promise.all([\n discardedCounts([id]).catch(() => {\n // Never the error object: only the fact, and which form.\n console.warn(`forms: the blocked count for form ${id} could not be read`)\n return null\n }),\n challengeReadiness(),\n // The way to this form's Submissions (W4), with how many are new —\n // only for a person who may open them, and never at the editor's cost.\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config)\n .then(async (allowed) =>\n allowed ? ((await submissionCounts(getApp(), [id])).get(id)?.fresh ?? 0) : null,\n )\n .catch(() => {\n console.warn(`forms: the new-submissions count for form ${id} could not be read`)\n return null\n }),\n // The Emails tab (D006). Without it the tab points at the Forms list.\n hasResourcePermission('message_template', 'update', config)\n .then((canEditTemplates) =>\n emailFacts({\n formId: id,\n adminBase,\n apiBasePath: config.apiBasePath ?? '/api/admin',\n canEditTemplates,\n }),\n )\n .catch(() => {\n console.warn(`forms: the email settings of form ${id} could not be read`)\n return null\n }),\n ])\n return {\n discarded: counts === null ? null : (counts.get(id) ?? 0),\n ready,\n settingsHref: `${adminBase}/settings/${FORMS_SETTINGS_NAMESPACE}`,\n ...(fresh !== null && {\n submissions: { href: `${adminBase}/forms/${id}/submissions`, fresh },\n }),\n ...(emails !== null && { emails }),\n }\n })\n .catch(() => {\n console.warn(`forms: the spam facts for form ${id} could not be read`)\n return null\n })\n if (facts === null) return editor\n return <FormCanvasFactsProvider facts={facts}>{editor}</FormCanvasFactsProvider>\n}\n","/**\n * Forms (W1) — the selected site's forms, and the way into each one's canvas\n * and its submissions.\n *\n * Gated by `form:view` on its route (the plugin's `defaultRoutes`), which is\n * also what the sidebar link inherits: seeing the list is seeing this site's\n * forms. What a person may DO from it follows their job permissions, each\n * asked here and each only deciding what is drawn —\n *\n * - `forms:manage` (build forms): the name opens the canvas, and \"New form\".\n * - `form_submissions:view`: the Submissions and New counts, each a link to\n * the form's Submissions screen (W4). For a person who holds this and not\n * `forms:manage` — the read-only role of D005 — the name opens Submissions\n * too, so the list is their way in. Also the \"Privacy requests\" link (W5)\n * and the line saying when the daily clean-up last ran and what it removed\n * (D007).\n * - `form:delete` together with `forms:manage`: \"Delete…\" on a row — the form\n * AND its submissions, after a confirm that names the count (the route\n * decides; see `handleRemove`).\n *\n * Lists THIS site's forms only: `form` is site-bound, so the entity read is\n * scoped by the request's site like every other bound entity (C2).\n *\n * The columns are the approved wireframe's. \"Used on\" counts the PUBLISHED\n * pages that show the form (forms D002 — from `entity_refs`, which a page's\n * Form block feeds when the page is published); \"Blocked\" counts what the spam\n * checks dropped (D004). A number that could not be read, or that this person\n * may not see, is a dash — never a zero that claims a count nobody took.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasEntityPermission, hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { fetchEntityList } from '@murumets-ee/admin-ui/server'\nimport { WorkflowStatusBadge } from '@murumets-ee/admin-ui/workflow'\nimport { countEmbedUsages } from '@murumets-ee/blocks/server'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n FORM_ENTITY,\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_RESOURCE,\n Form,\n lastRetentionRun,\n submissionCounts,\n} from '@murumets-ee/forms'\nimport { DeleteFormButton, NewFormDialog } from '@murumets-ee/forms-ui'\nimport {\n Button,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { discardedCounts } from './form-facts.js'\n\n/** A site's forms are a handful; the bound keeps one page load one query. */\nconst MAX_FORMS = 200\n\nexport async function FormsListPage({ params }: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n const t = await getTranslations('Forms.list')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n\n return config.withAdminContext(locale, async () => {\n // The route's gate is `form:view`, asked again here because this is the\n // read it guards: a page rendered past its gate must still not list.\n if (!(await hasEntityPermission(Form, 'view', config))) notFound()\n const [{ items, total }, canCreate, canDelete, canBuild, canSeeSubmissions] = await Promise.all(\n [\n fetchEntityList(Form, { sortField: 'name', sortDirection: 'asc', limit: MAX_FORMS }),\n hasResourcePermission('form', 'create', config),\n hasResourcePermission('form', 'delete', config),\n hasResourcePermission(FORMS_RESOURCE, 'manage', config),\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config),\n ],\n )\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n // One grouped query for every listed form. A failure costs the column its\n // numbers (a dash), never the list.\n const usedOn = await countEmbedUsages(\n FORM_ENTITY,\n items.map((form) => form.id),\n ).catch(() => null)\n // Dropped-as-spam per form (D004) — one bounded read for the listed forms.\n // A failure costs the column its numbers (a dash), never the list.\n // Shown to people who build forms or read submissions — not to a role that\n // can only see that the forms exist.\n const blocked = !(canBuild || canSeeSubmissions)\n ? null\n : await discardedCounts(items.map((form) => String(form.id))).catch(() => {\n console.warn('forms: the blocked counts for the Forms list could not be read')\n return null\n })\n\n // Stored and new submissions per form — one grouped query for the list,\n // and only for a person who may see submissions at all.\n const received = canSeeSubmissions\n ? await submissionCounts(\n getApp(),\n items.map((form) => String(form.id)),\n ).catch(() => {\n console.warn('forms: the submission counts for the Forms list could not be read')\n return null\n })\n : null\n const submissionsHref = (id: unknown) => `${formsBasePath}/${String(id)}/submissions`\n // When the daily clean-up last ran here and what it removed (D007) — for\n // the people whose submissions it removes. `undefined`: could not be read\n // (the line is left out); `null`: it has never run on this site.\n const lastRun = canSeeSubmissions\n ? await lastRetentionRun(getApp()).catch(() => {\n console.warn('forms: the last clean-up run for the Forms list could not be read')\n return undefined\n })\n : undefined\n const format = await getFormatter()\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-2xl font-bold\">{t('title')}</h1>\n <p className=\"text-sm text-muted-foreground\">{t('description')}</p>\n </div>\n <div className=\"flex items-center gap-2\">\n {canSeeSubmissions && (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <Link href={`${formsBasePath}/privacy`} data-forms-privacy-link=\"\">\n {t('privacy')}\n </Link>\n </Button>\n )}\n {canCreate && canBuild && (\n <NewFormDialog apiBasePath={apiBasePath} formsBasePath={formsBasePath} />\n )}\n </div>\n </div>\n\n {items.length === 0 ? (\n <p className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n {t('empty')}\n </p>\n ) : (\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t('name')}</TableHead>\n <TableHead>{t('usedOn')}</TableHead>\n <TableHead className=\"text-right\">{t('submissions')}</TableHead>\n <TableHead className=\"text-right\">{t('new')}</TableHead>\n <TableHead className=\"text-right\">{t('blocked')}</TableHead>\n <TableHead>{t('status')}</TableHead>\n {canDelete && canBuild && <TableHead className=\"w-0\" />}\n </TableRow>\n </TableHeader>\n <TableBody>\n {items.map((form) => (\n <TableRow key={form.id}>\n <TableCell className=\"font-medium\">\n {canBuild || canSeeSubmissions ? (\n <Link\n href={canBuild ? `${formsBasePath}/${form.id}` : submissionsHref(form.id)}\n className=\"text-primary hover:underline\"\n >\n {form.name}\n </Link>\n ) : (\n form.name\n )}\n </TableCell>\n <TableCell className=\"text-muted-foreground\" data-forms-used-on={form.id}>\n {usedOn === null\n ? t('notYet')\n : t('usedOnPages', { count: usedOn.get(form.id) ?? 0 })}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\" data-forms-submissions={form.id}>\n {received === null ? (\n <span className=\"text-muted-foreground\">{t('notYet')}</span>\n ) : (\n <Link\n href={submissionsHref(form.id)}\n className=\"text-primary hover:underline\"\n aria-label={t('submissionsOf', {\n form: String(form.name),\n count: received.get(String(form.id))?.total ?? 0,\n })}\n >\n {received.get(String(form.id))?.total ?? 0}\n </Link>\n )}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\" data-forms-new={form.id}>\n {received === null ? (\n <span className=\"text-muted-foreground\">{t('notYet')}</span>\n ) : (\n (received.get(String(form.id))?.fresh ?? 0)\n )}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\">\n {blocked === null ? t('notYet') : (blocked.get(String(form.id)) ?? 0)}\n </TableCell>\n <TableCell>\n <WorkflowStatusBadge\n status={form.status === 'published' ? 'published' : 'draft'}\n />\n </TableCell>\n {canDelete && canBuild && (\n <TableCell className=\"text-right\">\n <DeleteFormButton\n apiBasePath={apiBasePath}\n formId={String(form.id)}\n formName={String(form.name)}\n submissions={\n received === null ? null : (received.get(String(form.id))?.total ?? 0)\n }\n pages={usedOn === null ? null : (usedOn.get(form.id) ?? 0)}\n />\n </TableCell>\n )}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n )}\n {total > items.length && (\n <p className=\"text-xs text-muted-foreground\">{t('truncated', { count: items.length })}</p>\n )}\n {lastRun !== undefined && (\n <p className=\"text-xs text-muted-foreground\" data-forms-retention-run=\"\">\n {lastRun === null\n ? t('retentionNever')\n : `${t('retentionRun', {\n when: format.dateTime(new Date(lastRun.lastRunAt), {\n dateStyle: 'medium',\n timeStyle: 'short',\n }),\n count: lastRun.removed,\n })}${lastRun.unfinished ? ` ${t('retentionUnfinished')}` : ''}`}\n </p>\n )}\n </div>\n )\n })\n}\n","/**\n * Privacy requests (W5) — find one person's submissions on this site by email\n * address, export them, erase them (forms D007).\n *\n * `/admin/forms/privacy`, reached from the Forms list's header; never a menu\n * entry of its own. Gated on `form_submissions:view` by its route (the\n * plugin's `defaultRoutes`, forms D011) — the same permission that opens a\n * form's Submissions, because what this screen reaches is the same answers.\n * Erasing additionally needs `form_submissions:manage` AND a person's own\n * session — the erase route is closed to every agent door (`privacy-routes.ts`\n * says why); without both the button is not drawn, and the route refuses in\n * any case.\n *\n * The page itself reads NOTHING about anyone: it draws the search box. The\n * address is typed into the client view and sent in a request body\n * (`privacy-routes.ts`), so it never appears in a URL.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { getContext, isTokenDerivedDoor, type PageProps } from '@murumets-ee/core'\nimport { FORM_SUBMISSIONS_RESOURCE } from '@murumets-ee/forms'\nimport { PrivacyRequestsView } from '@murumets-ee/forms-ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\n\nexport async function PrivacyRequestsPage({\n params,\n}: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n const t = await getTranslations('Forms.privacy')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n\n return config.withAdminContext(locale, async () => {\n // The route's gate, asked again: a page rendered past its gate must still\n // offer nothing.\n if (!(await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config))) notFound()\n // An agent's browser session finds and exports; it is never offered an\n // erase its route would refuse.\n // The door is read from the request context the app's `withAdminContext`\n // establishes (the in-repo admin and the scaffold both carry it). An app\n // whose own copy does not would draw the button for an agent session —\n // and the route would still refuse it, with the reason shown.\n const canErase =\n !isTokenDerivedDoor(getContext()?.door) &&\n (await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'manage', config))\n return (\n <div className=\"space-y-4 p-6\">\n <div>\n <p className=\"text-sm text-muted-foreground\">\n <Link href={formsBasePath} className=\"hover:underline\">\n ← {t('backToForms')}\n </Link>\n </p>\n <h1 className=\"text-2xl font-bold\">{t('title')}</h1>\n <p className=\"max-w-prose text-sm text-muted-foreground\">{t('intro')}</p>\n </div>\n <PrivacyRequestsView\n apiBasePath={config.apiBasePath ?? '/api/admin'}\n formsBasePath={formsBasePath}\n locale={locale}\n canErase={canErase}\n />\n </div>\n )\n })\n}\n","/**\n * Submissions (W4) — what visitors sent through one form.\n *\n * `/admin/forms/:id/submissions`, gated on `form_submissions:view` by its\n * route (the plugin's `defaultRoutes`). The rows are read HERE, on the server,\n * through `@murumets-ee/forms`' submission functions — an elevated read the\n * page's gate has authorized, confined to the request's site (see\n * `submissions.ts`): another site's form id is a 404, and so is a form of\n * this site for a role without the permission.\n *\n * The filters are a plain GET form — status, page, day range — so the URL is\n * the state: it can be bookmarked, it works without JavaScript, and \"Export\n * CSV\" exports exactly what the URL names.\n *\n * Columns: one per field of the form as it is saved now, then one per retired\n * key that an answer on this page still holds (D005).\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n collectRetiredColumns,\n currentFormFields,\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_RESOURCE,\n Form,\n listSubmissions,\n parseSubmissionFilters,\n readDeliveries,\n STATUS_FILTERS,\n SUBMISSIONS_PAGE_SIZE,\n submissionColumns,\n submissionFilterParams,\n submissionPages,\n} from '@murumets-ee/forms'\nimport { SubmissionsExportLink, SubmissionsView } from '@murumets-ee/forms-ui'\nimport { Button } from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound, redirect } from 'next/navigation'\nimport { getTimeZone, getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { discardedCounts } from './form-facts.js'\n\n/** Far past any real list; keeps a hand-typed `?p=` from becoming an absurd offset. */\nconst MAX_PAGE = 100_000\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** The first value of a query parameter that may repeat. */\nconst first = (value: string | string[] | undefined): string | undefined =>\n Array.isArray(value) ? value[0] : value\n\nexport async function SubmissionsPage({\n params,\n searchParams,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const { id, locale } = await params\n if (!UUID_RE.test(id)) notFound()\n const query = await searchParams\n const config = getRegisteredAdminConfig()\n setRequestLocale(locale)\n const t = await getTranslations('Forms.submissions')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n\n return config.withAdminContext(locale, async () => {\n // The route's gate is `form_submissions:view`, asked again here because\n // everything below reads through an elevated client: a page rendered past\n // its gate must still show nothing.\n if (!(await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config))) notFound()\n const app = getApp()\n // The form of THIS site, or nothing: the elevated read keeps the request's\n // site, so another site's id is not found.\n const form: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n if (form === null || form === undefined) notFound()\n\n // Days are days of the zone the rows' times are shown in (next-intl's),\n // so \"from the 9th\" matches the clock a person reads beside each row.\n const timeZone = await getTimeZone()\n const filters = parseSubmissionFilters((name) => first(query[name]), { timeZone })\n // A whole page number within reach — `?p=1e400` is not one.\n const asked = Number(first(query.p))\n const pageNumber = Number.isFinite(asked)\n ? Math.min(Math.max(Math.trunc(asked), 1), MAX_PAGE)\n : 1\n const [{ items, total }, pages, canManage, canBuild, blocked] = await Promise.all([\n listSubmissions(app, id, filters, {\n limit: SUBMISSIONS_PAGE_SIZE,\n offset: (pageNumber - 1) * SUBMISSIONS_PAGE_SIZE,\n }),\n submissionPages(app, id),\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'manage', config),\n hasResourcePermission(FORMS_RESOURCE, 'manage', config),\n // Supplementary: a failed read costs the line, never the screen.\n discardedCounts([id]).catch(() => {\n console.warn(`forms: the blocked count for form ${id} could not be read`)\n return null\n }),\n ])\n\n // Each shown submission's email state (D006) — supplementary: a failed read\n // costs the column, never the screen.\n const deliveries = await readDeliveries(\n app,\n id,\n items.map((item) => item.id),\n )\n .then((byId) => Object.fromEntries(byId))\n .catch(() => {\n console.warn(`forms: the email state of form ${id}'s submissions could not be read`)\n return {}\n })\n\n const current = currentFormFields(form.body)\n const columns = submissionColumns(current, collectRetiredColumns(current, items))\n const filterQuery = submissionFilterParams(filters)\n const exportQuery = new URLSearchParams(filterQuery)\n exportQuery.set('form', id)\n exportQuery.set('uiLocale', locale)\n exportQuery.set('tz', filters.timeZone)\n const pageHref = (page: number): string => {\n const next = new URLSearchParams(filterQuery)\n if (page > 1) next.set('p', String(page))\n const text = next.toString()\n return `${formsBasePath}/${id}/submissions${text === '' ? '' : `?${text}`}`\n }\n const lastPage = Math.max(Math.ceil(total / SUBMISSIONS_PAGE_SIZE), 1)\n // Past the end — the last rows of the page were deleted or archived, or\n // the URL was typed: show the last page that has rows, never an empty one\n // that reads as \"no submissions\".\n if (items.length === 0 && total > 0 && pageNumber > lastPage) redirect(pageHref(lastPage))\n const filtered = filterQuery.toString() !== ''\n const name = typeof form.name === 'string' ? form.name : ''\n const discarded = blocked === null ? null : (blocked.get(id) ?? 0)\n // The page filter keeps a value that is no longer among the options (its\n // last submission was deleted), so the select never shows a blank.\n const pageOptions =\n filters.page !== undefined && !pages.includes(filters.page) ? [filters.page, ...pages] : pages\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex flex-wrap items-start justify-between gap-4\">\n <div>\n <p className=\"text-sm text-muted-foreground\">\n <Link href={formsBasePath} className=\"hover:underline\">\n ← {t('backToForms')}\n </Link>\n </p>\n <h1 className=\"text-2xl font-bold\">{t('title', { form: name })}</h1>\n </div>\n <div className=\"flex items-center gap-2\">\n {canBuild && (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <Link href={`${formsBasePath}/${id}`}>{t('openForm')}</Link>\n </Button>\n )}\n <SubmissionsExportLink\n href={`${apiBasePath}/form-submissions/export?${exportQuery.toString()}`}\n />\n </div>\n </div>\n\n <form\n method=\"get\"\n action={`${formsBasePath}/${id}/submissions`}\n className=\"flex flex-wrap items-end gap-3 rounded-md border p-3 text-sm\"\n >\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.from')}</span>\n <input\n type=\"date\"\n name=\"from\"\n defaultValue={filters.from ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n />\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.to')}</span>\n <input\n type=\"date\"\n name=\"to\"\n defaultValue={filters.to ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n />\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.page')}</span>\n <select\n name=\"page\"\n defaultValue={filters.page ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n >\n <option value=\"\">{t('filters.allPages')}</option>\n {pageOptions.map((path) => (\n <option key={path} value={path}>\n {path}\n </option>\n ))}\n </select>\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.status')}</span>\n <select\n name=\"status\"\n defaultValue={filters.status}\n className=\"h-8 rounded-md border bg-background px-2\"\n >\n {STATUS_FILTERS.map((status) => (\n <option key={status} value={status}>\n {t(`filters.statuses.${status}`)}\n </option>\n ))}\n </select>\n </label>\n <Button type=\"submit\" size=\"sm\" variant=\"secondary\">\n {t('filters.apply')}\n </Button>\n {filtered && (\n <Button size=\"sm\" variant=\"ghost\" asChild>\n <Link href={`${formsBasePath}/${id}/submissions`}>{t('filters.clear')}</Link>\n </Button>\n )}\n {discarded !== null && discarded > 0 && (\n <span className=\"ml-auto text-xs text-muted-foreground\" data-forms-blocked>\n {t('blocked', { count: discarded })}\n </span>\n )}\n </form>\n\n {/* Always mounted: the panel holds the submission being read, which may\n be the last one a filter listed. Keyed by what is listed, so moving\n to another page or filter starts with nothing open. */}\n <SubmissionsView\n key={`${filterQuery.toString()}#${pageNumber}`}\n formId={id}\n rows={items}\n columns={columns}\n canManage={canManage}\n deliveries={deliveries}\n apiBasePath={apiBasePath}\n emptyText={filtered ? t('emptyFiltered') : t('empty')}\n />\n\n <div className=\"flex items-center justify-between text-sm text-muted-foreground\">\n <span>{t('showing', { shown: items.length, total })}</span>\n {lastPage > 1 && (\n <span className=\"flex items-center gap-2\">\n {pageNumber > 1 && (\n <Link href={pageHref(pageNumber - 1)} className=\"hover:underline\">\n ← {t('previous')}\n </Link>\n )}\n <span>{t('pageOf', { page: Math.min(pageNumber, lastPage), pages: lastPage })}</span>\n {pageNumber < lastPage && (\n <Link href={pageHref(pageNumber + 1)} className=\"hover:underline\">\n {t('next')} →\n </Link>\n )}\n </span>\n )}\n </div>\n </div>\n )\n })\n}\n"],"mappings":"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"}
package/dist/plugin.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PluginNotificationTypeDefinition } from "@murumets-ee/core";
1
2
  import { getFormsMessages } from "@murumets-ee/forms/i18n";
2
3
 
3
4
  //#region ../admin-route/dist/index.d.mts
@@ -180,6 +181,7 @@ declare function formsPlugin(): Pick<import("@murumets-ee/core").Plugin, "server
180
181
  readonly name: "@murumets-ee/forms";
181
182
  readonly shared?: {
182
183
  readonly features: readonly DefineFeatureSpec[];
184
+ readonly notifications: PluginNotificationTypeDefinition[];
183
185
  readonly settings: readonly [import("@murumets-ee/settings").SettingsDefinition<{
184
186
  readonly turnstileSiteKey: import("@murumets-ee/settings").TextSettingConfig & {
185
187
  readonly default: "";
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":["PermissionString","AdminRouteMethod","SegmentPath","P","DefineAdminRouteSpec","TApp","AdminRouteHandler","RouteTokenChoice","AdminRouteMcpSpec","prefix","path","method","permission","defaultRoles","description","descriptionKey","handler","matchAnyPath","tokens","mcp","confirm","JsonValue","key","McpInputSchema","type","properties","required","additionalProperties","inputSchema","ENTRY_GATED","AdminRouteEntry","RouteTokenRefusalReason","resource","action","guardedHandler","acceptsTokens","tokenRefusal","requiresConfirmation","PermissionCatalogEntry","source","registerPermission","entry","getPermissionCatalog","Map","_resetPermissionCatalog","clearFeaturePermissions","safeAudit","Parameters","NonNullable","ctx","safeAuditMany","Promise","entries","PermissionDenialContext","Record","extraMetadata","emitPermissionDenied","spec","permissionDeniedResponse","Response","role","defineAdminRoute","DefineFeatureSpec","actions","defineFeature","combineAdminRoutes","AdminRoute","AuthDoor","TOKEN_DERIVED_DOORS","ReadonlySet","isTokenDerivedDoor","door","CredentialNarrowing","Readonly","CallerCredential","id","narrowing","rateLimitPerMinute","CREDENTIAL_RATE_BUDGET_MAX","parseCredentialRateBudget","raw","AdminApiCaller","U","ConfirmationPolicy","name","email","user","credential","confirmation","narrowCheck","base","GATED","AuthUser","AuditLogFn","entityType","entityId","userId","userName","changes","metadata","AuditLogManyFn","PermissionChecker","Request","req","segments","locale","defaultLocale","audit","auditMany","checkPermission","app","TokenPolicyEntry","handlers","GET","POST","PATCH","DELETE","tokenPolicy","ROUTE_TOKEN_REFUSAL_REASONS","refuse","RouteTokenDecision","refusal","matchTokenPolicyEntry","Pick","route","routeRequiresConfirmation","routeAcceptsTokens","AdminRouteTokenDecision","decision","RouteTokenDecisionSource","listAdminRouteTokenDecisions","routes","findUndecidedAdminRoutes","isGatedRoute","value","AGENT_APPROVALS_RESOURCE","APPROVAL_STATUSES","ApprovalStatus","isApprovalStatus","DEFAULT_APPROVAL_TTL_MS","MAX_PARKED_BODY_BYTES","MAX_PENDING_PER_PRINCIPAL","APPROVAL_ID_PATTERN","RegExp","ApprovalPreview","kind","entity","ids","snapshot","ParkedOperation","Date","principalId","credentialId","query","body","preview","status","requestedAt","expiresAt","decidedBy","decidedAt","resultStatus","result","ParkInput","ParkOutcome","ok","reason","AgentApprovalQueue","park","input","now","get","list","limit","offset","options","items","total","countPending","claim","reject","finish","outcome","resumeCaller","op","sweep","staleExecutingMs","expired","interrupted","CONFIRMATION_REQUIRED_STATUS","CONFIRMATION_REQUIRED_CODE","BuiltInEntityAction","suffix","behavior","requires","needsContentClient","BUILT_IN_ENTITY_ACTIONS","ADMIN_SITE_HEADER","ADMIN_SITE_QUERY_PARAM","ADMIN_SITE_SEGMENT_PREFIX","isAdminSiteHandleShape","parseAdminSiteSegment","segment","isAdminSiteSegment","adminSiteSegment","site","siteFromAdminPath","pathname","withAdminSite","href","stripAdminSite"],"sources":["../../admin-route/dist/index.d.mts","../src/plugin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;UAmwBU8D,iBAAAA;;;;;;EAMR9B,QAAAA;;;;;;;;EAQA+B,OAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0CAlD,YAAAA;;;;;;;EAOAC,WAAAA;AAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCnvBW,mBAAA;;cAGA,wBAAA;AAAA,iBAEG,WAAA,CAAA,GAAW,IAAA,6BAAA,MAAA;EAAA;;gCAAA,iBAAA;IAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":["PermissionString","AdminRouteMethod","SegmentPath","P","DefineAdminRouteSpec","TApp","AdminRouteHandler","RouteTokenChoice","AdminRouteMcpSpec","prefix","path","method","permission","defaultRoles","description","descriptionKey","handler","matchAnyPath","tokens","mcp","confirm","JsonValue","key","McpInputSchema","type","properties","required","additionalProperties","inputSchema","ENTRY_GATED","AdminRouteEntry","RouteTokenRefusalReason","resource","action","guardedHandler","acceptsTokens","tokenRefusal","requiresConfirmation","PermissionCatalogEntry","source","registerPermission","entry","getPermissionCatalog","Map","_resetPermissionCatalog","clearFeaturePermissions","safeAudit","Parameters","NonNullable","ctx","safeAuditMany","Promise","entries","PermissionDenialContext","Record","extraMetadata","emitPermissionDenied","spec","permissionDeniedResponse","Response","role","defineAdminRoute","DefineFeatureSpec","actions","defineFeature","combineAdminRoutes","AdminRoute","AuthDoor","TOKEN_DERIVED_DOORS","ReadonlySet","isTokenDerivedDoor","door","CredentialNarrowing","Readonly","CallerCredential","id","narrowing","rateLimitPerMinute","CREDENTIAL_RATE_BUDGET_MAX","parseCredentialRateBudget","raw","AdminApiCaller","U","ConfirmationPolicy","name","email","user","credential","confirmation","narrowCheck","base","GATED","AuthUser","AuditLogFn","entityType","entityId","userId","userName","changes","metadata","AuditLogManyFn","PermissionChecker","Request","req","segments","locale","defaultLocale","audit","auditMany","checkPermission","app","TokenPolicyEntry","handlers","GET","POST","PATCH","DELETE","tokenPolicy","ROUTE_TOKEN_REFUSAL_REASONS","refuse","RouteTokenDecision","refusal","matchTokenPolicyEntry","Pick","route","routeRequiresConfirmation","routeAcceptsTokens","AdminRouteTokenDecision","decision","RouteTokenDecisionSource","listAdminRouteTokenDecisions","routes","findUndecidedAdminRoutes","isGatedRoute","value","AGENT_APPROVALS_RESOURCE","APPROVAL_STATUSES","ApprovalStatus","isApprovalStatus","DEFAULT_APPROVAL_TTL_MS","MAX_PARKED_BODY_BYTES","MAX_PENDING_PER_PRINCIPAL","APPROVAL_ID_PATTERN","RegExp","ApprovalPreview","kind","entity","ids","snapshot","ParkedOperation","Date","principalId","credentialId","query","body","preview","status","requestedAt","expiresAt","decidedBy","decidedAt","resultStatus","result","ParkInput","ParkOutcome","ok","reason","AgentApprovalQueue","park","input","now","get","list","limit","offset","options","items","total","countPending","claim","reject","finish","outcome","resumeCaller","op","sweep","staleExecutingMs","expired","interrupted","CONFIRMATION_REQUIRED_STATUS","CONFIRMATION_REQUIRED_CODE","BuiltInEntityAction","suffix","behavior","requires","needsContentClient","BUILT_IN_ENTITY_ACTIONS","ADMIN_SITE_HEADER","ADMIN_SITE_QUERY_PARAM","ADMIN_SITE_SEGMENT_PREFIX","isAdminSiteHandleShape","parseAdminSiteSegment","segment","isAdminSiteSegment","adminSiteSegment","site","siteFromAdminPath","pathname","withAdminSite","href","stripAdminSite"],"sources":["../../admin-route/dist/index.d.mts","../src/plugin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;UAmwBU8D,iBAAAA;;;;;;EAMR9B,QAAAA;;;;;;;;EAQA+B,OAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0CAlD,YAAAA;;;;;;;EAOAC,WAAAA;AAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cC7uBW,mBAAA;;cAGA,wBAAA;AAAA,iBAEG,WAAA,CAAA,GAAW,IAAA,6BAAA,MAAA;EAAA;;gCAAA,iBAAA;IAAA,wBAiG4B,gCAAA;IAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{combineAdminRoutes as e,defineAdminRoute as t,definePlugin as n}from"@murumets-ee/core";import{FORMS_MANAGE as r,FORM_SUBMISSIONS_MANAGE as i,FORM_SUBMISSIONS_VIEW as a,Form as o,FormSubmission as s,MAX_SUBMISSION_BULK as c,PRIVACY_EXPORT_MAX as l,PRIVACY_LIST_MAX as u,SUBMISSIONS_READ_MAX as d,collectRetiredColumns as f,countSubmissionsByEmail as p,countUnindexedSubmissions as m,currentFormFields as h,deleteSubmissions as g,eraseSubmissionsByEmail as ee,findSubmissionsByEmail as _,formNames as v,formsFeatures as te,formsRoutes as y,formsSettings as b,formsTables as x,isSubmissionStatus as S,listSubmissionRows as ne,parsePrivacyEmail as re,parseSubmissionFilters as ie,readCappedBody as C,setSubmissionStatus as ae,submissionColumns as oe,submissionCounts as se,submissionFilterParams as ce}from"@murumets-ee/forms";import{getFormsMessages as w}from"@murumets-ee/forms/i18n";import{FormEmbedBlock as le,formEnvironment as ue,formPageEnvironmentExtension as de,formSchemaBlocks as fe}from"@murumets-ee/forms/schema";import{FormSubmissionsHeaderAction as T}from"@murumets-ee/forms-ui/canvas-header-action";import{csvDelimiterFor as E,csvFilename as D,pagedExportRows as O,runCsvExport as k}from"@murumets-ee/admin-ui/csv-export";const A=`form-privacy`,j=`X-Lumi-Export-Truncated`;let M=0;function N(){return new Response(JSON.stringify({error:`export_busy`,code:`export_busy`}),{status:503,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`,"Retry-After":`10`}})}function P(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}function F(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}async function I(e,t){let n=await C(e,2048);if(`tooLarge`in n)return{refusal:P({error:`body_too_large`},413)};let r;try{r=JSON.parse(n.text)}catch{return{refusal:P({error:`invalid_json`},400)}}let i=new Set([`email`,...t]);if(!F(r)||Object.keys(r).some(e=>!i.has(e)))return{refusal:P({error:`invalid_body`},400)};let a=re(r.email);return a===null?{refusal:P({error:`invalid_email`,code:`invalid_email`},400)}:{email:a,rest:r}}const L=async(e,t)=>{let n=await I(e,[]);if(`refusal`in n)return n.refusal;let{email:r}=n,[i,a,o]=await Promise.all([p(t.app,r),_(t.app,r,{limit:u,offset:0}),m(t.app)]),s=await v(t.app,a.map(e=>e.formId)),c=a.map(e=>({id:e.id,formId:e.formId,formName:s.get(e.formId)??null,createdAt:e.createdAt,pagePath:e.pagePath}));return t.audit?.({action:`forms.privacy.search`,entityType:`form_submission`,metadata:{count:i,forms:[...new Set(a.map(e=>e.formId))],unindexed:o}}),P({total:i,items:c,unindexed:o,exportMax:l})},R=[`csv`,`json`];function z(e,t){let n=e.Forms;for(let e of t){if(!F(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function B(e){let t=await w(e??`en`),n=(e,n)=>z(t,e)??n;return{form:n([`privacy`,`csv`,`form`],`Form`),received:n([`privacy`,`csv`,`received`],`Received (UTC)`),page:n([`privacy`,`csv`,`page`],`Page`),status:n([`privacy`,`csv`,`status`],`Status`),yes:n([`submissions`,`yes`],`yes`),no:n([`submissions`,`no`],`no`),deletedForm:n([`privacy`,`deletedForm`],`deleted`),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function V(e,t){let n=e.fields[t]?.label;return n!==void 0&&n!==``?n:t}function pe(e){let t=new Map;for(let[n,r]of Object.entries(e.data)){let i=V(e,n);t.set(i,[...t.get(i)??[],r])}return t}function me(e,t){if(e===void 0||e.length===0)return``;let n=e.map(e=>typeof e==`boolean`?e?t.yes:t.no:e),r=n[0];return n.length===1&&r!==void 0?r:n.join(` | `)}function he(e){return e.slice(0,16).replace(`T`,` `)}function ge(e,t,n,r){return O((r,i)=>_(e,t,{limit:i,offset:r,until:n}),{pageSize:d,maxRows:l,...r!==void 0&&{onTruncated:r}})}const H=async(e,t)=>{let n=await I(e,[`format`,`uiLocale`]);if(`refusal`in n)return n.refusal;let{email:r,rest:i}=n,a=R.find(e=>e===i.format);if(a===void 0)return P({error:`invalid_format`},400);let o=typeof i.uiLocale==`string`&&i.uiLocale.length<=35?i.uiLocale:t.locale,s=t.app;if(M>=2)return N();M+=1;let c=new Date,l=[],u=!1,d;try{for await(let e of ge(s,r,c,()=>{u=!0}))l.push(e);d=await v(s,[...new Set(l.map(e=>e.formId))])}finally{--M}u&&s.logger.warn({written:l.length},`forms: a privacy export reached the row cap — the file holds the newest rows only`);let f=()=>{t.audit?.({action:`forms.privacy.export`,entityType:`form_submission`,metadata:{count:l.length,forms:[...new Set(l.map(e=>e.formId))],format:a,truncated:u}})};if(a===`json`){let e=JSON.stringify({exportedAt:c.toISOString(),truncated:u,submissions:l.map(e=>({form:d.get(e.formId)??null,receivedAt:e.createdAt,page:e.pagePath,language:e.locale,status:e.status,answers:Object.entries(e.data).map(([t,n])=>({field:V(e,t),key:t,answer:n})),consent:Object.values(e.consent)}))},null,2);return f(),new Response(e,{status:200,headers:{"Content-Type":`application/json; charset=utf-8`,"Content-Disposition":`attachment; filename="${D(`privacy-request`).replace(/\.csv$/,`.json`)}"`,"Cache-Control":`private, no-store`,"X-Content-Type-Options":`nosniff`,[j]:String(u)}})}let p=await B(o),m=[],h=new Set;for(let e of l)for(let t of Object.keys(e.data)){let n=V(e,t);h.has(n)||(h.add(n),m.push(n))}let g=await k(()=>({filename:D(`privacy-request`),delimiter:E(o),header:[p.form,p.received,p.page,p.status,...m],rows:l.map(e=>{let t=pe(e);return[d.get(e.formId)??p.deletedForm,he(e.createdAt),e.pagePath??``,p.statuses[e.status]??e.status,...m.map(e=>me(t.get(e),p))]})}));return g.ok&&(f(),g.headers.set(j,String(u))),g},_e=async(e,t)=>{let n=await I(e,[]);if(`refusal`in n)return n.refusal;let r=await ee(t.app,n.email),i=await m(t.app).catch(()=>null);return t.audit?.({action:`forms.privacy.erase`,entityType:`form_submission`,metadata:{count:r.erased,forms:[...r.formIds],unfinished:r.more,failed:r.failed,unindexed:i}}),P({erased:r.erased,forms:r.formIds.length,more:r.more,failed:r.failed,unindexed:i},r.failed?500:200)},ve=t({prefix:A,path:`search`,method:`POST`,permission:a,defaultRoles:[`admin`,`editor`],description:`Find this site’s submissions by one whole email address: { email }.`,descriptionKey:`Forms.permissions.routePrivacySearch`,tokens:`accept`,handler:L}),ye=t({prefix:A,path:`export`,method:`POST`,permission:a,defaultRoles:[`admin`,`editor`],description:`Download one person’s submissions as CSV or JSON: { email, format }.`,descriptionKey:`Forms.permissions.routePrivacyExport`,tokens:`accept`,handler:H}),be=t({prefix:A,path:``,method:`DELETE`,permission:i,defaultRoles:[`admin`,`editor`],description:`Erase one person’s submissions for good: { email }. People only, no agents.`,descriptionKey:`Forms.permissions.routePrivacyErase`,tokens:{refuse:`personal-data`},handler:_e});function xe(){return e([ve,ye,be])}const U=`form-submissions`;function W(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}const G=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function K(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function Se(e){return Array.isArray(e)}function q(e,t){if(!K(e))return null;let n=new Set([`form`,`ids`,...t]);if(Object.keys(e).some(e=>!n.has(e)))return null;let{form:r,ids:i}=e;if(typeof r!=`string`||!G.test(r)||!Se(i)||i.length===0||i.length>c)return null;let a=i.filter(e=>typeof e==`string`&&G.test(e));return a.length===i.length?{form:r,ids:a,rest:e}:null}async function J(e){let t=await C(e,8192);if(`tooLarge`in t)return{refusal:W({error:`body_too_large`},413)};let n=t.text;try{return{value:JSON.parse(n)}}catch{return{refusal:W({error:`invalid_json`},400)}}}async function Y(e,t){let n=await e.getSystemClient(o).findById(t);return n==null?null:{name:typeof n.name==`string`?n.name:`form`,body:n.body}}const Ce=async(e,t)=>{let n=new URL(e.url).searchParams.get(`form`)??``;if(!G.test(n)||await Y(t.app,n)===null)return W({error:`not_found`},404);let r=(await se(t.app,[n])).get(n);return W({total:r?.total??0,fresh:r?.fresh??0})},we=async(e,t)=>{let n=await J(e);if(`refusal`in n)return n.refusal;let r=q(n.value,[`status`]),i=r?.rest.status;if(r===null||!S(i))return W({error:`Expected { form: uuid, ids: uuid[], status: new|read|archived }`},400);let{form:a}=r;if(await Y(t.app,a)===null)return W({error:`not_found`},404);let o=await ae(t.app,a,r.ids,i);return t.audit?.({action:`forms.submissions.status`,entityType:`form`,entityId:a,metadata:{status:i,count:o}}),W({changed:o})},Te=async(e,t)=>{let n=await J(e);if(`refusal`in n)return n.refusal;let r=q(n.value,[]);if(r===null)return W({error:`Expected { form: uuid, ids: uuid[] }`},400);let{form:i}=r;if(await Y(t.app,i)===null)return W({error:`not_found`},404);let a=await g(t.app,i,r.ids);return t.audit?.({action:`forms.submissions.delete`,entityType:`form`,entityId:i,metadata:{count:a}}),W({deleted:a})},X={received:`Received (UTC)`,page:`Page`,status:`Status`,yes:`yes`,no:`no`,removed:`removed field`,statuses:{new:`New`,read:`Read`,archived:`Archived`}};function Ee(e,t){let n=e.Forms;for(let e of t){if(!K(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function De(e){let t=await w(e??`en`),n=(e,n)=>Ee(t,e)??n;return{received:n([`submissions`,`csv`,`received`],X.received),page:n([`submissions`,`csv`,`page`],X.page),status:n([`submissions`,`csv`,`status`],X.status),yes:n([`submissions`,`yes`],X.yes),no:n([`submissions`,`no`],X.no),removed:n([`submissions`,`removedField`],X.removed),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function Oe(e){return e.slice(0,16).replace(`T`,` `)}function ke(e,t){return e===void 0?``:typeof e==`boolean`?e?t.yes:t.no:e}const Ae=t({prefix:U,path:`export`,method:`GET`,permission:a,defaultRoles:[`admin`,`editor`],description:`Download a form’s submissions as CSV: ?form=<id> plus the screen’s filters.`,descriptionKey:`Forms.permissions.routeExport`,tokens:`accept`,handler:async(e,t)=>{let n=new URL(e.url),r=n.searchParams.get(`form`)??``;if(!G.test(r))return W({error:`not_found`},404);let i=await Y(t.app,r);if(i===null)return W({error:`not_found`},404);let a={...ie(e=>n.searchParams.get(e)),until:new Date},o=n.searchParams.get(`uiLocale`)??t.locale,s=h(i.body),c=t.app,l=e=>O((e,t)=>ne(c,r,a,{limit:t,offset:e}),{pageSize:d,...e!==void 0&&{onTruncated:e}});return k(async()=>{t.audit?.({action:`forms.submissions.export`,entityType:`form`,entityId:r,metadata:{filtered:ce(a).size>0}});let e=await De(o),n=new Map;for await(let e of l())f(s,[e],n);let u=oe(s,n);return{filename:D(i.name),delimiter:E(o),header:[e.received,e.page,e.status,...u.map(t=>t.retired?`${t.label} (${e.removed})`:t.label)],rows:(async function*(){let t=l(e=>{c.logger.warn({formId:r,written:e},`forms: a submissions export reached the row cap — the file holds the newest rows only`)});for await(let n of t)yield[Oe(n.createdAt),n.pagePath??``,e.statuses[n.status]??n.status,...u.map(t=>ke(n.data[t.key],e))]})()}})}}),je=t({prefix:U,path:`summary`,method:`GET`,permission:a,defaultRoles:[`admin`,`editor`],description:`How many submissions a form holds and how many are new: ?form=<id>.`,descriptionKey:`Forms.permissions.routeSummary`,tokens:`accept`,handler:Ce}),Me=t({prefix:U,path:``,method:`PATCH`,permission:i,defaultRoles:[`admin`,`editor`],description:`Mark submissions of a form new, read or archived: { form, ids, status }.`,descriptionKey:`Forms.permissions.routeStatus`,tokens:`accept`,handler:we}),Ne=t({prefix:U,path:``,method:`DELETE`,permission:i,defaultRoles:[`admin`,`editor`],description:`Delete submissions of a form for good: { form, ids }.`,descriptionKey:`Forms.permissions.routeDelete`,tokens:`accept`,confirm:!0,handler:Te});function Pe(){return e([Ae,je,Me,Ne])}const Z=`${o.name}:view`,Fe=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormsListPage(e),Ie=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormCanvasPage(e),Le=async e=>(await import(`@murumets-ee/forms-ui/pages`)).SubmissionsPage(e),Re=async e=>(await import(`@murumets-ee/forms-ui/pages`)).PrivacyRequestsPage(e),Q=`forms:retention`,$=`23 3 * * *`;function ze(){return n({name:`@murumets-ee/forms`,requires:[`@murumets-ee/content`,`@murumets-ee/settings`],server:{entities:[o,s],tables:{...x},routes:[...y(),...Pe(),...xe()],init:async e=>{if(!e.plugins.has(`@murumets-ee/queue`))return;let{defineJob:t,registerJob:n}=await import(`@murumets-ee/queue/client`);n(t({name:Q,schedule:$}),async()=>{let{runAsCli:t,runInEverySite:n}=await import(`@murumets-ee/core`),{runFormsRetention:r}=await import(`@murumets-ee/forms`),i=await t(()=>n(()=>r(e),{app:e,label:Q})),a=i.reduce((e,t)=>e+t.removed,0);e.logger.info({sites:i.length,removed:a,unfinished:i.filter(e=>e.unfinished).length,backfilled:i.reduce((e,t)=>e+t.backfilled,0)},`forms: daily clean-up of expired submissions finished`)})}},contributes:{"blocks.blocks":[...fe,le],"blocks.environmentBases":[ue],"blocks.environments":de},shared:{features:te,settings:[b],messages:w},adminUi:{canvasHeaderActions:{[o.name]:T},pages:{FormsListPage:Fe,FormCanvasPage:Ie,SubmissionsPage:Le,PrivacyRequestsPage:Re},sidebar:[{id:`forms`,group:`Content`,label:`Forms`,href:`/admin/forms`,iconName:`clipboard-list`,requires:Z}],defaultRoutes:[{path:`forms`,factory:`FormsListPage`,page:{requires:Z,defaultRoles:[`admin`,`editor`],description:`List this site’s forms and create new ones.`,descriptionKey:`Forms.permissions.list`,tokens:`accept`}},{path:`forms/privacy`,factory:`PrivacyRequestsPage`,page:{requires:a,defaultRoles:[`admin`,`editor`],description:`Find one person’s submissions by email address, to export or erase them.`,descriptionKey:`Forms.permissions.privacy`,tokens:`accept`}},{path:`forms/[id]`,factory:`FormCanvasPage`,page:{requires:r,defaultRoles:[`admin`,`editor`],description:`Edit a form on its canvas.`,descriptionKey:`Forms.permissions.canvas`,tokens:`accept`}},{path:`forms/[id]/submissions`,factory:`SubmissionsPage`,page:{requires:a,defaultRoles:[`admin`,`editor`],description:`Read and export what visitors sent through a form.`,descriptionKey:`Forms.permissions.submissions`,tokens:`accept`}},{path:`form`,factory:`FormsListPage`},{path:`form/[id]`,factory:`FormCanvasPage`}]}})}export{Q as FORMS_RETENTION_JOB,$ as FORMS_RETENTION_SCHEDULE,ze as formsPlugin};
1
+ import{combineAdminRoutes as e,defineAdminRoute as t,definePlugin as n,requireRequestSite as r}from"@murumets-ee/core";import{FORMS_MANAGE as i,FORMS_SETTINGS_NAMESPACE as a,FORM_SUBMISSIONS_MANAGE as o,FORM_SUBMISSIONS_VIEW as s,FORM_SUBMISSION_ENTITY as c,Form as l,FormSubmission as u,MAX_SUBMISSION_BULK as d,PRIVACY_EXPORT_MAX as f,PRIVACY_LIST_MAX as p,SUBMISSIONS_READ_MAX as m,collectRetiredColumns as h,compileForm as g,countSubmissionsByEmail as ee,countUnindexedSubmissions as _,currentFormFields as v,deleteSubmissions as te,enqueueFormEmail as ne,eraseSubmissionsByEmail as re,findSubmissionsByEmail as y,formEmailTypes as ie,formNames as b,formsFeatures as x,formsRoutes as S,formsSettings as ae,formsTables as oe,isEmailKind as se,isSubmissionStatus as ce,listSubmissionRows as le,parsePrivacyEmail as ue,parseRecipients as de,parseSubmissionFilters as fe,readCappedBody as C,readDeliveries as pe,setSubmissionStatus as me,submissionColumns as he,submissionCounts as ge,submissionFilterParams as _e,writeEmailSettings as ve}from"@murumets-ee/forms";import{getFormsMessages as w}from"@murumets-ee/forms/i18n";import{FormEmbedBlock as ye,formEnvironment as be,formPageEnvironmentExtension as xe,formSchemaBlocks as Se}from"@murumets-ee/forms/schema";import{FormSubmissionsHeaderAction as Ce}from"@murumets-ee/forms-ui/canvas-header-action";import{csvDelimiterFor as T,csvFilename as E,pagedExportRows as D,runCsvExport as O}from"@murumets-ee/admin-ui/csv-export";const k=`form-emails`,A=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function j(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}function we(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}async function M(e){let t=await C(e,8192);if(`tooLarge`in t)return{refusal:j({error:`body_too_large`},413)};try{return{value:JSON.parse(t.text)}}catch{return{refusal:j({error:`invalid_json`},400)}}}function N(e,t){if(!we(e))return null;let n=new Set(t);return Object.keys(e).every(e=>n.has(e))?e:null}async function P(e,t){let n=await e.getSystemClient(l).findById(t);return n==null?null:{body:n.body}}const Te=async(e,t)=>{let n=await M(e);if(`refusal`in n)return n.refusal;let r=N(n.value,[`form`,`recipients`,`autoReplyEnabled`,`autoReplyField`]),{form:i,recipients:a,autoReplyEnabled:o,autoReplyField:s}=r??{};if(r===null||typeof i!=`string`||!A.test(i)||typeof o!=`boolean`||!(s===null||typeof s==`string`))return j({error:`Expected { form: uuid, recipients: string[], autoReplyEnabled: boolean, autoReplyField: string|null }`},400);let c=de(a);if(!c.ok)return j({error:`invalid_recipients`,invalid:c.invalid,tooMany:c.tooMany},400);let l=await P(t.app,i);if(l===null)return j({error:`not_found`},404);let u=(g(l.body)?.fields??[]).filter(e=>e.kind===`email`).map(e=>e.key);return s!==null&&!u.includes(s)?j({error:`invalid_auto_reply_field`},400):o&&s===null?j({error:`auto_reply_needs_field`},400):(await ve(t.app,i,{recipients:c.recipients,autoReplyEnabled:o,autoReplyField:s}),t.audit?.({action:`forms.emails.update`,entityType:`form`,entityId:i,metadata:{recipients:c.recipients.length,autoReply:o}}),j({recipients:c.recipients,autoReplyEnabled:o,autoReplyField:s}))},Ee=[`no_recipients`,`disabled`];function De(e,t){return e.status===`failed`?!(e.kind===`autoReply`&&e.errorClass===`timeout`&&t-new Date(e.updatedAt).getTime()<36e5):e.status===`queued`?t-new Date(e.updatedAt).getTime()>3e5:e.status===`skipped`&&e.errorClass!==null&&Ee.includes(e.errorClass)}const Oe=async(e,t)=>{let n=await M(e);if(`refusal`in n)return n.refusal;let i=N(n.value,[`form`,`submission`,`kind`]),{form:o,submission:s,kind:l}=i??{};if(i===null||typeof o!=`string`||!A.test(o)||typeof s!=`string`||!A.test(s)||!se(l))return j({error:`Expected { form: uuid, submission: uuid, kind: notification|autoReply }`},400);if(await P(t.app,o)===null)return j({error:`not_found`},404);let d=await t.app.getSystemClient(u).findById(s);if(d==null||String(d.form)!==o)return j({error:`not_found`},404);let f=((await pe(t.app,o,[s])).get(s)??[]).find(e=>e.kind===l);if(f!==void 0&&!De(f,Date.now()))return j({error:`not_retryable`,status:f.status},409);let p=await r(a,t.app),m=await ne(t.app,{site:p,formId:o,submissionId:s,kind:l});return t.audit?.({action:`forms.emails.retry`,entityType:c,entityId:s,metadata:{kind:l,queued:m}}),j({queued:m})},ke=t({prefix:k,path:``,method:`PATCH`,permission:i,defaultRoles:[`admin`,`editor`],description:`Set a form’s email recipients and auto-reply: { form, recipients, … }.`,descriptionKey:`Forms.permissions.routeEmails`,tokens:`accept`,confirm:!0,handler:Te}),Ae=t({prefix:k,path:`retry`,method:`POST`,permission:o,defaultRoles:[`admin`,`editor`],description:`Queue a failed form email again: { form, submission, kind }.`,descriptionKey:`Forms.permissions.routeEmailRetry`,tokens:`accept`,handler:Oe});function je(){return e([ke,Ae])}const F=`form-privacy`,I=`X-Lumi-Export-Truncated`;let L=0;function Me(){return new Response(JSON.stringify({error:`export_busy`,code:`export_busy`}),{status:503,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`,"Retry-After":`10`}})}function R(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}function z(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}async function B(e,t){let n=await C(e,2048);if(`tooLarge`in n)return{refusal:R({error:`body_too_large`},413)};let r;try{r=JSON.parse(n.text)}catch{return{refusal:R({error:`invalid_json`},400)}}let i=new Set([`email`,...t]);if(!z(r)||Object.keys(r).some(e=>!i.has(e)))return{refusal:R({error:`invalid_body`},400)};let a=ue(r.email);return a===null?{refusal:R({error:`invalid_email`,code:`invalid_email`},400)}:{email:a,rest:r}}const Ne=async(e,t)=>{let n=await B(e,[]);if(`refusal`in n)return n.refusal;let{email:r}=n,[i,a,o]=await Promise.all([ee(t.app,r),y(t.app,r,{limit:p,offset:0}),_(t.app)]),s=await b(t.app,a.map(e=>e.formId)),c=a.map(e=>({id:e.id,formId:e.formId,formName:s.get(e.formId)??null,createdAt:e.createdAt,pagePath:e.pagePath}));return t.audit?.({action:`forms.privacy.search`,entityType:`form_submission`,metadata:{count:i,forms:[...new Set(a.map(e=>e.formId))],unindexed:o}}),R({total:i,items:c,unindexed:o,exportMax:f})},Pe=[`csv`,`json`];function Fe(e,t){let n=e.Forms;for(let e of t){if(!z(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function Ie(e){let t=await w(e??`en`),n=(e,n)=>Fe(t,e)??n;return{form:n([`privacy`,`csv`,`form`],`Form`),received:n([`privacy`,`csv`,`received`],`Received (UTC)`),page:n([`privacy`,`csv`,`page`],`Page`),status:n([`privacy`,`csv`,`status`],`Status`),yes:n([`submissions`,`yes`],`yes`),no:n([`submissions`,`no`],`no`),deletedForm:n([`privacy`,`deletedForm`],`deleted`),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function V(e,t){let n=e.fields[t]?.label;return n!==void 0&&n!==``?n:t}function Le(e){let t=new Map;for(let[n,r]of Object.entries(e.data)){let i=V(e,n);t.set(i,[...t.get(i)??[],r])}return t}function Re(e,t){if(e===void 0||e.length===0)return``;let n=e.map(e=>typeof e==`boolean`?e?t.yes:t.no:e),r=n[0];return n.length===1&&r!==void 0?r:n.join(` | `)}function ze(e){return e.slice(0,16).replace(`T`,` `)}function Be(e,t,n,r){return D((r,i)=>y(e,t,{limit:i,offset:r,until:n}),{pageSize:m,maxRows:f,...r!==void 0&&{onTruncated:r}})}const Ve=async(e,t)=>{let n=await B(e,[`format`,`uiLocale`]);if(`refusal`in n)return n.refusal;let{email:r,rest:i}=n,a=Pe.find(e=>e===i.format);if(a===void 0)return R({error:`invalid_format`},400);let o=typeof i.uiLocale==`string`&&i.uiLocale.length<=35?i.uiLocale:t.locale,s=t.app;if(L>=2)return Me();L+=1;let c=new Date,l=[],u=!1,d;try{for await(let e of Be(s,r,c,()=>{u=!0}))l.push(e);d=await b(s,[...new Set(l.map(e=>e.formId))])}finally{--L}u&&s.logger.warn({written:l.length},`forms: a privacy export reached the row cap — the file holds the newest rows only`);let f=()=>{t.audit?.({action:`forms.privacy.export`,entityType:`form_submission`,metadata:{count:l.length,forms:[...new Set(l.map(e=>e.formId))],format:a,truncated:u}})};if(a===`json`){let e=JSON.stringify({exportedAt:c.toISOString(),truncated:u,submissions:l.map(e=>({form:d.get(e.formId)??null,receivedAt:e.createdAt,page:e.pagePath,language:e.locale,status:e.status,answers:Object.entries(e.data).map(([t,n])=>({field:V(e,t),key:t,answer:n})),consent:Object.values(e.consent)}))},null,2);return f(),new Response(e,{status:200,headers:{"Content-Type":`application/json; charset=utf-8`,"Content-Disposition":`attachment; filename="${E(`privacy-request`).replace(/\.csv$/,`.json`)}"`,"Cache-Control":`private, no-store`,"X-Content-Type-Options":`nosniff`,[I]:String(u)}})}let p=await Ie(o),m=[],h=new Set;for(let e of l)for(let t of Object.keys(e.data)){let n=V(e,t);h.has(n)||(h.add(n),m.push(n))}let g=await O(()=>({filename:E(`privacy-request`),delimiter:T(o),header:[p.form,p.received,p.page,p.status,...m],rows:l.map(e=>{let t=Le(e);return[d.get(e.formId)??p.deletedForm,ze(e.createdAt),e.pagePath??``,p.statuses[e.status]??e.status,...m.map(e=>Re(t.get(e),p))]})}));return g.ok&&(f(),g.headers.set(I,String(u))),g},He=async(e,t)=>{let n=await B(e,[]);if(`refusal`in n)return n.refusal;let r=await re(t.app,n.email),i=await _(t.app).catch(()=>null);return t.audit?.({action:`forms.privacy.erase`,entityType:`form_submission`,metadata:{count:r.erased,forms:[...r.formIds],unfinished:r.more,failed:r.failed,unindexed:i}}),R({erased:r.erased,forms:r.formIds.length,more:r.more,failed:r.failed,unindexed:i},r.failed?500:200)},Ue=t({prefix:F,path:`search`,method:`POST`,permission:s,defaultRoles:[`admin`,`editor`],description:`Find this site’s submissions by one whole email address: { email }.`,descriptionKey:`Forms.permissions.routePrivacySearch`,tokens:`accept`,handler:Ne}),We=t({prefix:F,path:`export`,method:`POST`,permission:s,defaultRoles:[`admin`,`editor`],description:`Download one person’s submissions as CSV or JSON: { email, format }.`,descriptionKey:`Forms.permissions.routePrivacyExport`,tokens:`accept`,handler:Ve}),Ge=t({prefix:F,path:``,method:`DELETE`,permission:o,defaultRoles:[`admin`,`editor`],description:`Erase one person’s submissions for good: { email }. People only, no agents.`,descriptionKey:`Forms.permissions.routePrivacyErase`,tokens:{refuse:`personal-data`},handler:He});function Ke(){return e([Ue,We,Ge])}const H=`form-submissions`;function U(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}const W=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function G(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function qe(e){return Array.isArray(e)}function K(e,t){if(!G(e))return null;let n=new Set([`form`,`ids`,...t]);if(Object.keys(e).some(e=>!n.has(e)))return null;let{form:r,ids:i}=e;if(typeof r!=`string`||!W.test(r)||!qe(i)||i.length===0||i.length>d)return null;let a=i.filter(e=>typeof e==`string`&&W.test(e));return a.length===i.length?{form:r,ids:a,rest:e}:null}async function q(e){let t=await C(e,8192);if(`tooLarge`in t)return{refusal:U({error:`body_too_large`},413)};let n=t.text;try{return{value:JSON.parse(n)}}catch{return{refusal:U({error:`invalid_json`},400)}}}async function J(e,t){let n=await e.getSystemClient(l).findById(t);return n==null?null:{name:typeof n.name==`string`?n.name:`form`,body:n.body}}const Je=async(e,t)=>{let n=new URL(e.url).searchParams.get(`form`)??``;if(!W.test(n)||await J(t.app,n)===null)return U({error:`not_found`},404);let r=(await ge(t.app,[n])).get(n);return U({total:r?.total??0,fresh:r?.fresh??0})},Ye=async(e,t)=>{let n=await q(e);if(`refusal`in n)return n.refusal;let r=K(n.value,[`status`]),i=r?.rest.status;if(r===null||!ce(i))return U({error:`Expected { form: uuid, ids: uuid[], status: new|read|archived }`},400);let{form:a}=r;if(await J(t.app,a)===null)return U({error:`not_found`},404);let o=await me(t.app,a,r.ids,i);return t.audit?.({action:`forms.submissions.status`,entityType:`form`,entityId:a,metadata:{status:i,count:o}}),U({changed:o})},Xe=async(e,t)=>{let n=await q(e);if(`refusal`in n)return n.refusal;let r=K(n.value,[]);if(r===null)return U({error:`Expected { form: uuid, ids: uuid[] }`},400);let{form:i}=r;if(await J(t.app,i)===null)return U({error:`not_found`},404);let a=await te(t.app,i,r.ids);return t.audit?.({action:`forms.submissions.delete`,entityType:`form`,entityId:i,metadata:{count:a}}),U({deleted:a})},Y={received:`Received (UTC)`,page:`Page`,status:`Status`,yes:`yes`,no:`no`,removed:`removed field`,statuses:{new:`New`,read:`Read`,archived:`Archived`}};function Ze(e,t){let n=e.Forms;for(let e of t){if(!G(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function X(e){let t=await w(e??`en`),n=(e,n)=>Ze(t,e)??n;return{received:n([`submissions`,`csv`,`received`],Y.received),page:n([`submissions`,`csv`,`page`],Y.page),status:n([`submissions`,`csv`,`status`],Y.status),yes:n([`submissions`,`yes`],Y.yes),no:n([`submissions`,`no`],Y.no),removed:n([`submissions`,`removedField`],Y.removed),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function Qe(e){return e.slice(0,16).replace(`T`,` `)}function $e(e,t){return e===void 0?``:typeof e==`boolean`?e?t.yes:t.no:e}const et=t({prefix:H,path:`export`,method:`GET`,permission:s,defaultRoles:[`admin`,`editor`],description:`Download a form’s submissions as CSV: ?form=<id> plus the screen’s filters.`,descriptionKey:`Forms.permissions.routeExport`,tokens:`accept`,handler:async(e,t)=>{let n=new URL(e.url),r=n.searchParams.get(`form`)??``;if(!W.test(r))return U({error:`not_found`},404);let i=await J(t.app,r);if(i===null)return U({error:`not_found`},404);let a={...fe(e=>n.searchParams.get(e)),until:new Date},o=n.searchParams.get(`uiLocale`)??t.locale,s=v(i.body),c=t.app,l=e=>D((e,t)=>le(c,r,a,{limit:t,offset:e}),{pageSize:m,...e!==void 0&&{onTruncated:e}});return O(async()=>{t.audit?.({action:`forms.submissions.export`,entityType:`form`,entityId:r,metadata:{filtered:_e(a).size>0}});let e=await X(o),n=new Map;for await(let e of l())h(s,[e],n);let u=he(s,n);return{filename:E(i.name),delimiter:T(o),header:[e.received,e.page,e.status,...u.map(t=>t.retired?`${t.label} (${e.removed})`:t.label)],rows:(async function*(){let t=l(e=>{c.logger.warn({formId:r,written:e},`forms: a submissions export reached the row cap — the file holds the newest rows only`)});for await(let n of t)yield[Qe(n.createdAt),n.pagePath??``,e.statuses[n.status]??n.status,...u.map(t=>$e(n.data[t.key],e))]})()}})}}),tt=t({prefix:H,path:`summary`,method:`GET`,permission:s,defaultRoles:[`admin`,`editor`],description:`How many submissions a form holds and how many are new: ?form=<id>.`,descriptionKey:`Forms.permissions.routeSummary`,tokens:`accept`,handler:Je}),nt=t({prefix:H,path:``,method:`PATCH`,permission:o,defaultRoles:[`admin`,`editor`],description:`Mark submissions of a form new, read or archived: { form, ids, status }.`,descriptionKey:`Forms.permissions.routeStatus`,tokens:`accept`,handler:Ye}),rt=t({prefix:H,path:``,method:`DELETE`,permission:o,defaultRoles:[`admin`,`editor`],description:`Delete submissions of a form for good: { form, ids }.`,descriptionKey:`Forms.permissions.routeDelete`,tokens:`accept`,confirm:!0,handler:Xe});function it(){return e([et,tt,nt,rt])}const Z=`${l.name}:view`,at=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormsListPage(e),ot=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormCanvasPage(e),st=async e=>(await import(`@murumets-ee/forms-ui/pages`)).SubmissionsPage(e),ct=async e=>(await import(`@murumets-ee/forms-ui/pages`)).PrivacyRequestsPage(e),Q=`forms:retention`,$=`23 3 * * *`;function lt(){return n({name:`@murumets-ee/forms`,requires:[`@murumets-ee/content`,`@murumets-ee/settings`,`@murumets-ee/notifications`,`@murumets-ee/queue`],server:{entities:[l,u],tables:{...oe},routes:[...S(),...it(),...Ke(),...je()],init:async e=>{if(!e.plugins.has(`@murumets-ee/queue`))return;let{defineJob:t,registerJob:n}=await import(`@murumets-ee/queue/client`);n(t({name:Q,schedule:$}),async()=>{let{runAsCli:t,runInEverySite:n}=await import(`@murumets-ee/core`),{runFormsRetention:r}=await import(`@murumets-ee/forms`),i=await t(()=>n(()=>r(e),{app:e,label:Q})),a=i.reduce((e,t)=>e+t.removed,0);e.logger.info({sites:i.length,removed:a,unfinished:i.filter(e=>e.unfinished).length,backfilled:i.reduce((e,t)=>e+t.backfilled,0)},`forms: daily clean-up of expired submissions finished`)});let{runAsCli:r}=await import(`@murumets-ee/core`),{runFormEmailJob:i,sendFormEmailJob:a}=await import(`@murumets-ee/forms/email-jobs`);n(a,async t=>{await r(async()=>{let n=`en`;try{let{getNotificationsConfig:e}=await import(`@murumets-ee/notifications/plugin`);n=e().defaultLocale}catch{e.logger.warn({outcome:`no_default_locale`},`forms: notifications default locale unavailable — using en`)}await i(t.payload,{app:e,logger:e.logger,defaultLocale:n})})})}},contributes:{"blocks.blocks":[...Se,ye],"blocks.environmentBases":[be],"blocks.environments":xe},shared:{features:x,notifications:[...ie],settings:[ae],messages:w},adminUi:{canvasHeaderActions:{[l.name]:Ce},pages:{FormsListPage:at,FormCanvasPage:ot,SubmissionsPage:st,PrivacyRequestsPage:ct},sidebar:[{id:`forms`,group:`Content`,label:`Forms`,href:`/admin/forms`,iconName:`clipboard-list`,requires:Z}],defaultRoutes:[{path:`forms`,factory:`FormsListPage`,page:{requires:Z,defaultRoles:[`admin`,`editor`],description:`List this site’s forms and create new ones.`,descriptionKey:`Forms.permissions.list`,tokens:`accept`}},{path:`forms/privacy`,factory:`PrivacyRequestsPage`,page:{requires:s,defaultRoles:[`admin`,`editor`],description:`Find one person’s submissions by email address, to export or erase them.`,descriptionKey:`Forms.permissions.privacy`,tokens:`accept`}},{path:`forms/[id]`,factory:`FormCanvasPage`,page:{requires:i,defaultRoles:[`admin`,`editor`],description:`Edit a form on its canvas.`,descriptionKey:`Forms.permissions.canvas`,tokens:`accept`}},{path:`forms/[id]/submissions`,factory:`SubmissionsPage`,page:{requires:s,defaultRoles:[`admin`,`editor`],description:`Read and export what visitors sent through a form.`,descriptionKey:`Forms.permissions.submissions`,tokens:`accept`}},{path:`form`,factory:`FormsListPage`},{path:`form/[id]`,factory:`FormCanvasPage`}]}})}export{Q as FORMS_RETENTION_JOB,$ as FORMS_RETENTION_SCHEDULE,lt as formsPlugin};
2
2
  //# sourceMappingURL=plugin.mjs.map