@acms-subir/core 0.3.6 → 0.3.8
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.
- package/CHANGELOG.md +27 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EmailSettings.tsx +6 -0
- package/src/components/EventPage.astro +25 -5
- package/src/components/booking/DurationEditor.astro +156 -0
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/forms.ts +67 -0
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/drafts.ts +9 -1
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/email/readiness.ts +14 -0
- package/src/lib/email/service.ts +8 -3
- package/src/lib/email/templates.ts +8 -2
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +99 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +38 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +23 -12
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/resources/link.astro +95 -49
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/admin/settings.astro +1 -1
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/email/settings.ts +4 -2
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
package/src/lib/email/service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, EmailLinkError } from './templates';
|
|
5
5
|
import { bookingMail } from '../booking/service';
|
|
6
6
|
import type { EmailJob, Mail } from './types';
|
|
7
7
|
|
|
@@ -33,6 +33,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
33
33
|
for (let i=0;i<limit;i++) {
|
|
34
34
|
const j = await repo.email.claim(); if (!j) break;
|
|
35
35
|
let handedToProvider = false;
|
|
36
|
+
let preparationError = 'Unable to prepare email. Check the message details and Email settings.';
|
|
36
37
|
try {
|
|
37
38
|
let config = await repo.email.config(j.configVersion || undefined);
|
|
38
39
|
const resolved = resolveConfig(config, locals);
|
|
@@ -40,6 +41,9 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
40
41
|
if (!current.config.enabled || current.config.provider === 'none') {
|
|
41
42
|
await repo.email.finish(j,{state:'cancelled',error:'Email is disabled.'}); continue;
|
|
42
43
|
}
|
|
44
|
+
if (resolved.error) {
|
|
45
|
+
await repo.email.finish(j,{state:'failed',error:resolved.error}); continue;
|
|
46
|
+
}
|
|
43
47
|
if (j.fingerprint && j.fingerprint !== resolved.fingerprint) {
|
|
44
48
|
await repo.email.finish(j,{state:'failed',error:'Provider configuration changed. Restore the original environment credentials before retrying this job.'}); continue;
|
|
45
49
|
}
|
|
@@ -56,6 +60,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
56
60
|
// Retries must reuse exactly the same request body, including calendar
|
|
57
61
|
// timestamps, for Resend's idempotency key to remain valid. Encrypt the
|
|
58
62
|
// snapshot because account and booking links are bearer credentials.
|
|
63
|
+
preparationError = 'Unable to protect email content. Check EMAIL_ENCRYPTION_KEY and retain any older keys needed for retries.';
|
|
59
64
|
const frozen = j.rendered ? decrypt<Mail>(j.rendered,`job:${j.id}`,locals) : mail;
|
|
60
65
|
if (!await repo.email.prepare(j,j.rendered || encrypt(frozen,`job:${j.id}`,locals))) continue;
|
|
61
66
|
handedToProvider = true;
|
|
@@ -64,8 +69,8 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
64
69
|
// Purge the recoverable raw account token once the provider accepted it.
|
|
65
70
|
// The verifier hash remains so the recipient can redeem the link.
|
|
66
71
|
if (result.state === 'accepted') processed++;
|
|
67
|
-
} catch {
|
|
68
|
-
await repo.email.finish(j,{state:handedToProvider?'unknown':'failed',error:handedToProvider?'Provider handoff could not be recorded. Review delivery before retrying.':
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await repo.email.finish(j,{state:handedToProvider?'unknown':'failed',error:handedToProvider?'Provider handoff could not be recorded. Review delivery before retrying.':error instanceof EmailLinkError ? error.message : preparationError});
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
await repo.email.reconcile();
|
|
@@ -11,8 +11,14 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
|
|
|
11
11
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
|
|
12
12
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
|
|
13
13
|
}
|
|
14
|
+
export class EmailLinkError extends Error {}
|
|
14
15
|
export function canonicalOrigin(value: string): string {
|
|
15
|
-
|
|
16
|
-
if (
|
|
16
|
+
const raw = value.trim();
|
|
17
|
+
if (!raw) throw new EmailLinkError('Set Site URL in Settings → General before sending booking or account emails. Use HTTPS for public sites, or HTTP for localhost development.');
|
|
18
|
+
// Match the site settings form's support for a bare domain, while retaining
|
|
19
|
+
// the stricter origin checks needed for links containing bearer tokens.
|
|
20
|
+
let url: URL; try { url = new URL(raw.includes('://') ? raw : `https://${raw}`); } catch { throw new EmailLinkError('Set a valid Site URL in Settings → General before sending booking or account emails.'); }
|
|
21
|
+
const loopback = url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || url.hostname === '[::1]' || /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(url.hostname);
|
|
22
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new EmailLinkError('Site URL in Settings → General must use HTTPS (HTTP is allowed for localhost), without a path, credentials or query.');
|
|
17
23
|
return url.origin;
|
|
18
24
|
}
|
package/src/lib/email/types.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface Invitation { id: string; email: string; name: string; role: Rol
|
|
|
25
25
|
export interface PublicEmailSettings {
|
|
26
26
|
version: number; config: EmailConfig; provider: Provider | 'none'; configured: boolean; error: string;
|
|
27
27
|
encryptionAvailable: boolean; environmentLocked: boolean; credentialFields: string[];
|
|
28
|
+
linksReady: boolean; linkError: string;
|
|
28
29
|
activity: Omit<EmailJob, 'payload' | 'lease' | 'fingerprint' | 'rendered'>[]; heartbeat: number | null;
|
|
29
30
|
}
|
|
30
31
|
export const DEFAULT_EMAIL_CONFIG: EmailConfig = {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { CalEvent } from '../schema';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import { sha256Hex } from '../api-keys';
|
|
5
|
+
import { createRsvp } from '../booking/service';
|
|
6
|
+
import { collectResponse, formVersion } from './service';
|
|
7
|
+
import { starter } from './schema';
|
|
8
|
+
import { readAnswers } from './http';
|
|
9
|
+
|
|
10
|
+
// Older custom EventTemplate components only post name/email/seats. Keep
|
|
11
|
+
// that exact legacy case working; customized forms require versioned markup.
|
|
12
|
+
export async function prepareLegacyEventForm(event: CalEvent, start: number, data: FormData) {
|
|
13
|
+
if (!event.rsvp.form && !data.has('requestId') && !data.has('formVersion') && !data.has('occurrence')) {
|
|
14
|
+
data.set('requestId', crypto.randomUUID());
|
|
15
|
+
data.set('formVersion', await formVersion(starter('simple')));
|
|
16
|
+
data.set('occurrence', String(start));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function submitEventForm(repo: ContentRepository, event: CalEvent, occurrence: { start: number; end: number }, data: FormData, requestId: string, flow: Parameters<typeof createRsvp>[2]) {
|
|
21
|
+
if (event.status !== 'published') throw new ApiError(404, 'Event not found.');
|
|
22
|
+
if (Number(data.get('occurrence')) !== occurrence.start) throw new ApiError(409, 'The event time has changed. Review the date and try again.');
|
|
23
|
+
const seats = Number(data.get('seats') || 1);
|
|
24
|
+
if (!Number.isInteger(seats) || seats < 1 || seats > event.rsvp.maxSeats) throw new ApiError(400, 'Choose a valid number of seats.');
|
|
25
|
+
const definition = event.rsvp.form ?? starter('simple');
|
|
26
|
+
const booker = { name: String(data.get('name') || ''), email: String(data.get('email') || ''), answers: readAnswers(data, definition), formVersion: String(data.get('formVersion') || '') };
|
|
27
|
+
const idempotency = { keyHash: await sha256Hex(`event-form:${requestId}`), requestHash: await sha256Hex(JSON.stringify({ event: event.id, start: occurrence.start, seats, booker })) };
|
|
28
|
+
const replay = async () => {
|
|
29
|
+
const record = await repo.getBookingRequest(idempotency.keyHash);
|
|
30
|
+
if (!record) return null;
|
|
31
|
+
if (record.requestHash !== idempotency.requestHash) throw new ApiError(409, 'This registration was already sent with different details. Reload to register again.');
|
|
32
|
+
const booking = await repo.getBooking(record.bookingId);
|
|
33
|
+
if (!booking) throw new ApiError(410, 'This registration was removed. Reload to register again.');
|
|
34
|
+
return booking;
|
|
35
|
+
};
|
|
36
|
+
const existing = await replay(); if (existing) return existing;
|
|
37
|
+
await collectResponse(definition, booker.answers, booker.formVersion);
|
|
38
|
+
try { return await createRsvp(repo, { event, ...occurrence, seats, booker }, { ...flow, idempotency }); }
|
|
39
|
+
catch (e) { const existing = await replay(); if (existing) return existing; throw e; }
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Pure helpers for the admin Responses screen and its CSV export. Kept out of
|
|
2
|
+
// service.ts so the public submit path stays small.
|
|
3
|
+
import type { FormField, FormSubmission } from './schema';
|
|
4
|
+
|
|
5
|
+
type Field = Pick<FormField, 'id' | 'type' | 'label'>;
|
|
6
|
+
|
|
7
|
+
// Questions that collect an answer (section headings are layout only).
|
|
8
|
+
export const answerFields = <F extends Field>(fields: F[]) => fields.filter(f => (f.type as string) !== 'heading');
|
|
9
|
+
|
|
10
|
+
// One spreadsheet cell: yes/no for consent-style boxes, multi-choice answers
|
|
11
|
+
// (stored newline-separated) joined with commas; paragraphs keep their lines.
|
|
12
|
+
export function cellText(field: Pick<FormField, 'type'> | undefined, value: string | undefined): string {
|
|
13
|
+
if (!field) return '';
|
|
14
|
+
const type = field.type as string;
|
|
15
|
+
if (type === 'checkbox' || type === 'consent') return value === 'yes' ? 'Yes' : 'No';
|
|
16
|
+
if (type === 'textarea') return value ?? '';
|
|
17
|
+
return (value ?? '').split(/\r?\n/).map(s => s.trim()).filter(Boolean).join(', ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// v1…vN in first-seen order; the live definition is the next number when no
|
|
21
|
+
// response has used it yet. Unknown hashes fall back to a short hash.
|
|
22
|
+
export function versionLabeler(seen: string[], current?: string) {
|
|
23
|
+
const order = [...seen];
|
|
24
|
+
if (current && !order.includes(current)) order.push(current);
|
|
25
|
+
const index = new Map(order.map((v, i) => [v, i + 1]));
|
|
26
|
+
return (v: string) => (index.has(v) ? `v${index.get(v)}` : v.slice(0, 7));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Wide export: one row per response. Columns are the current questions first,
|
|
30
|
+
// then questions only found in older snapshots (labelled from the newest one).
|
|
31
|
+
export function wideRows(current: Field[], rows: FormSubmission[], version: (v: string) => string = v => v.slice(0, 7)): string[][] {
|
|
32
|
+
const cols: { id: string; label: string }[] = answerFields(current).map(f => ({ id: f.id, label: f.label }));
|
|
33
|
+
const known = new Set(cols.map(c => c.id));
|
|
34
|
+
for (const r of rows) for (const f of answerFields(r.response.definition.fields)) {
|
|
35
|
+
if (known.has(f.id)) continue;
|
|
36
|
+
known.add(f.id); cols.push({ id: f.id, label: f.label });
|
|
37
|
+
}
|
|
38
|
+
const used = new Set(['Reference', 'Submitted', 'Version']);
|
|
39
|
+
const header = cols.map(c => {
|
|
40
|
+
const label = used.has(c.label) ? `${c.label} (${c.id})` : c.label;
|
|
41
|
+
used.add(label); return label;
|
|
42
|
+
});
|
|
43
|
+
return [
|
|
44
|
+
['Reference', 'Submitted', 'Version', ...header],
|
|
45
|
+
...rows.map(r => {
|
|
46
|
+
const byId = new Map(r.response.definition.fields.map(f => [f.id, f]));
|
|
47
|
+
return [r.id, new Date(r.createdAt).toISOString(), version(r.response.version), ...cols.map(c => cellText(byId.get(c.id), r.response.answers[c.id]))];
|
|
48
|
+
}),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function relativeTime(ts: number, now = Date.now(), timeZone?: string): string {
|
|
53
|
+
const s = Math.floor((now - ts) / 1000);
|
|
54
|
+
if (s < 60) return 'Just now';
|
|
55
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
56
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
57
|
+
if (s < 86400 * 2) return 'Yesterday';
|
|
58
|
+
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d ago`;
|
|
59
|
+
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', ...(new Date(ts).getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) };
|
|
60
|
+
try { return new Date(ts).toLocaleDateString('en-US', { ...opts, timeZone }); }
|
|
61
|
+
catch { return new Date(ts).toLocaleDateString('en-US', opts); }
|
|
62
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { hasOther, isHalf, type FormDefinition, type FormField } from './schema';
|
|
2
|
+
import { OTHER } from './validate';
|
|
3
|
+
export const escapeForm = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
4
|
+
const e = escapeForm;
|
|
5
|
+
const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date' };
|
|
6
|
+
// Websites use a text box so "example.org" can be typed; the server adds https://.
|
|
7
|
+
const autocomplete: Partial<Record<FormField['type'], string>> = { email: 'email', phone: 'tel', url: 'url' };
|
|
8
|
+
|
|
9
|
+
/** The answer control alone (input, select, textarea or option list). The builder canvas reuses it under editable labels. */
|
|
10
|
+
export function renderControl(f: FormField, value: string, id: string): string {
|
|
11
|
+
const describe = f.help ? ` aria-describedby="${e(id)}-help"` : '';
|
|
12
|
+
const attrs = `id="${e(id)}" name="q_${e(f.id)}"${f.required ? ' required' : ''}${describe}`;
|
|
13
|
+
const ph = f.placeholder ? ` placeholder="${e(f.placeholder)}"` : '';
|
|
14
|
+
if (f.type === 'radio' || f.type === 'checkboxes') {
|
|
15
|
+
const radio = f.type === 'radio', chosen = radio ? [value] : value.split('\n');
|
|
16
|
+
const extra = chosen.find(v => v && !f.options.includes(v)) ?? '';
|
|
17
|
+
// A required checkbox group can't be enforced natively; the server checks it.
|
|
18
|
+
const req = radio && f.required ? ' required' : '';
|
|
19
|
+
const option = (o: string, i: number) => `<label class="pc-bk-option"><input type="${radio ? 'radio' : 'checkbox'}" id="${e(id)}-${i}" name="q_${e(f.id)}" value="${e(o)}"${req}${chosen.includes(o) ? ' checked' : ''}/><span>${e(o)}</span></label>`;
|
|
20
|
+
const other = hasOther(f) ? `<label class="pc-bk-option pc-bk-other"><input type="${radio ? 'radio' : 'checkbox'}" name="q_${e(f.id)}" value="${OTHER}"${req}${extra ? ' checked' : ''}/><span>Other</span><input type="text" name="o_${e(f.id)}" maxlength="200" aria-label="Other answer for ${e(f.label)}" placeholder="${e(f.placeholder || 'Type your answer')}" value="${e(extra)}"/></label>` : '';
|
|
21
|
+
return `<div class="pc-bk-options">${f.options.map(option).join('')}${other}</div>`;
|
|
22
|
+
}
|
|
23
|
+
if (f.type === 'textarea') return `<textarea ${attrs}${ph} maxlength="2000" rows="3">${e(value)}</textarea>`;
|
|
24
|
+
if (f.type === 'select') return `<select ${attrs}><option value="">${e(f.placeholder || 'Choose…')}</option>${f.options.map(o => `<option value="${e(o)}" ${o === value ? 'selected' : ''}>${e(o)}</option>`).join('')}</select>`;
|
|
25
|
+
const ac = (autocomplete[f.type] ? ` autocomplete="${autocomplete[f.type]}"` : '') + (f.type === 'url' ? ' inputmode="url" spellcheck="false"' : '');
|
|
26
|
+
return `<input ${attrs}${ph}${ac} type="${inputType[f.type] ?? 'text'}"${f.type === 'number' ? ' step="any"' : ''} maxlength="${f.type === 'phone' ? 40 : 2000}" value="${e(value)}"/>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderField(f: FormField, value: string, prefix: string): string {
|
|
30
|
+
const id = `${prefix}-${f.id}`, half = isHalf(f) ? ' is-half' : '';
|
|
31
|
+
const help = f.help ? `<small id="${e(id)}-help">${e(f.help)}</small>` : '';
|
|
32
|
+
const label = `${e(f.label)}${f.required ? ' *' : ''}`;
|
|
33
|
+
if (f.type === 'heading') return `<div class="pc-form-heading" id="${e(id)}"><h3>${e(f.label)}</h3>${f.help ? `<p>${e(f.help)}</p>` : ''}</div>`;
|
|
34
|
+
if (f.type === 'checkbox' || f.type === 'consent') return `<label class="pc-bk-check${half}" for="${e(id)}"><input type="checkbox" id="${e(id)}" name="q_${e(f.id)}" ${f.required ? 'required' : ''} ${f.help ? `aria-describedby="${e(id)}-help"` : ''} value="yes" ${value === 'yes' ? 'checked' : ''}/><span>${label}${help}</span></label>`;
|
|
35
|
+
if (f.type === 'radio' || f.type === 'checkboxes') return `<fieldset class="pc-bk-field pc-bk-choices${half}"${f.help ? ` aria-describedby="${e(id)}-help"` : ''}><legend>${label}</legend>${renderControl(f, value, id)}${help}</fieldset>`;
|
|
36
|
+
return `<label class="pc-bk-field${half}" for="${e(id)}"><span>${label}</span>${renderControl(f, value, id)}${help}</label>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Two consecutive half-width questions share a `.pc-form-row` (side by side when there's room). */
|
|
40
|
+
export function renderFields(definition: FormDefinition, values: Record<string, string> = {}, prefix = 'form'): string {
|
|
41
|
+
const out: string[] = []; let row: string[] = [];
|
|
42
|
+
const flush = () => { if (row.length) out.push(`<div class="pc-form-row">${row.join('')}</div>`); row = []; };
|
|
43
|
+
for (const f of definition.fields) {
|
|
44
|
+
const html = renderField(f, values[f.id] ?? '', prefix);
|
|
45
|
+
if (isHalf(f)) { row.push(html); if (row.length === 2) flush(); }
|
|
46
|
+
else { flush(); out.push(html); }
|
|
47
|
+
}
|
|
48
|
+
flush();
|
|
49
|
+
return out.join('');
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ApiError } from '../api';
|
|
2
|
+
import { sha256Hex } from '../api-keys';
|
|
3
|
+
import type { ContentRepository } from '../repo/types';
|
|
4
|
+
import type { FormDefinition } from './schema';
|
|
5
|
+
import { AnswerError, answersFrom } from './validate';
|
|
6
|
+
export function checkOrigin(request: Request) {
|
|
7
|
+
const origin = request.headers.get('origin');
|
|
8
|
+
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') throw new ApiError(403, 'Please submit from this site.');
|
|
9
|
+
}
|
|
10
|
+
export async function readForm(request: Request): Promise<FormData> {
|
|
11
|
+
checkOrigin(request);
|
|
12
|
+
// Read the stream with an actual bound (Content-Length is not trustworthy).
|
|
13
|
+
const reader = request.body?.getReader(); let length = 0; const chunks: Uint8Array[] = [];
|
|
14
|
+
if (reader) for (;;) { const { value, done } = await reader.read(); if (done) break; length += value.length; if (length > 100_000) { await reader.cancel(); throw new ApiError(413, 'This form is too large.'); } chunks.push(value); }
|
|
15
|
+
const bytes = new Uint8Array(length); let at = 0; for (const c of chunks) { bytes.set(c, at); at += c.length; }
|
|
16
|
+
try { return await new Response(bytes, { headers: { 'Content-Type': request.headers.get('content-type') || 'application/x-www-form-urlencoded' } }).formData(); }
|
|
17
|
+
catch { throw new ApiError(400, 'Could not read this form.'); }
|
|
18
|
+
}
|
|
19
|
+
export function readAnswers(data: FormData, definition: FormDefinition) {
|
|
20
|
+
try { return answersFrom(name => data.getAll(name), definition); }
|
|
21
|
+
catch (e) { if (e instanceof AnswerError) throw new ApiError(400, e.message); throw e; }
|
|
22
|
+
}
|
|
23
|
+
export async function submissionGuard(repo: ContentRepository, data: FormData, address: string, scope: string) {
|
|
24
|
+
if (data.get('website')) throw new ApiError(400, 'Could not submit this form.');
|
|
25
|
+
if (!await repo.email.rateLimit(`forms:${await sha256Hex(scope + ':' + address)}`, 20, 60_000)) throw new ApiError(429, 'Too many submissions. Please try again in a minute.');
|
|
26
|
+
const key = String(data.get('requestId') ?? '');
|
|
27
|
+
if (!/^[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(key)) throw new ApiError(400, 'Reload the form and try again.');
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FormDefinition } from './schema';
|
|
2
|
+
import { formVersion } from './service';
|
|
3
|
+
import { escapeForm, renderFields } from './fields';
|
|
4
|
+
export { escapeForm, renderFields } from './fields';
|
|
5
|
+
export function hiddenFields(version: string, requestId: string) {
|
|
6
|
+
return `<input type="hidden" name="formVersion" value="${escapeForm(version)}"><input type="hidden" name="requestId" value="${escapeForm(requestId)}"><div class="pc-form-trap" aria-hidden="true"><label>Leave empty<input name="website" tabindex="-1" autocomplete="off"></label></div>`;
|
|
7
|
+
}
|
|
8
|
+
export async function renderForm(id: string, definition: FormDefinition, values: Record<string, string> = {}, error = '', requestId: string = crypto.randomUUID(), embed = false) {
|
|
9
|
+
const e = escapeForm;
|
|
10
|
+
return `<section class="pc-simple-form">${definition.title ? `<h2>${e(definition.title)}</h2>` : ''}${definition.intro ? `<p>${e(definition.intro)}</p>` : ''}<form method="post" action="/forms/${e(id)}${embed ? '?embed=1' : ''}">${error ? `<p class="pc-bk-error" role="alert">${e(error)}</p>` : ''}${hiddenFields(await formVersion(definition), requestId)}${renderFields(definition, values, `f-${id}`)}<button type="submit" class="pc-bk-submit">${e(definition.submitLabel || 'Submit')}</button></form></section>`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// `checkbox` and `consent` both mean "must tick to agree" (consent is what the
|
|
4
|
+
// builder inserts; checkbox stays valid for existing data). `heading` has no
|
|
5
|
+
// answer: it is a section title (label) with an optional description (help).
|
|
6
|
+
export const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading'] as const;
|
|
7
|
+
export const CHOICE_TYPES: readonly string[] = ['select', 'radio', 'checkboxes'];
|
|
8
|
+
export const FIELD_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,79}$/;
|
|
9
|
+
export const FormField = z.object({
|
|
10
|
+
id: z.string().regex(FIELD_ID),
|
|
11
|
+
type: z.enum(FIELD_TYPES), label: z.string().trim().min(1).max(200),
|
|
12
|
+
required: z.boolean().default(false), help: z.string().max(300).default(''),
|
|
13
|
+
// Checkbox answers are stored newline-joined, so a choice is one line.
|
|
14
|
+
options: z.array(z.string().trim().min(1).max(200).regex(/^[^\r\n]*$/, 'Each choice must be one line.')).max(50).default([]),
|
|
15
|
+
// Newer props are optional (not defaulted) on purpose: parsing must not add
|
|
16
|
+
// keys to stored definitions, because form versions and the submission
|
|
17
|
+
// guard compare the exact JSON. Read them through the helpers below.
|
|
18
|
+
placeholder: z.string().max(120).optional(),
|
|
19
|
+
width: z.enum(['full', 'half']).optional(),
|
|
20
|
+
allowOther: z.boolean().optional(),
|
|
21
|
+
});
|
|
22
|
+
export type FormField = z.infer<typeof FormField>;
|
|
23
|
+
export const FormDefinition = z.object({
|
|
24
|
+
title: z.string().max(120).default(''), intro: z.string().max(1000).default(''),
|
|
25
|
+
submitLabel: z.string().max(80).default(''), confirmation: z.string().max(1000).default('Thank you. Your response has been received.'),
|
|
26
|
+
fields: z.array(FormField).max(30).default([]),
|
|
27
|
+
}).superRefine((d, ctx) => {
|
|
28
|
+
if (new Set(d.fields.map(f => f.id)).size !== d.fields.length) ctx.addIssue({ code: 'custom', message: 'Field IDs must be unique.' });
|
|
29
|
+
for (const f of d.fields) if (CHOICE_TYPES.includes(f.type) && (!f.options.length || new Set(f.options).size !== f.options.length)) ctx.addIssue({ code: 'custom', message: `“${f.label}” needs unique choices.` });
|
|
30
|
+
});
|
|
31
|
+
export type FormDefinition = z.infer<typeof FormDefinition>;
|
|
32
|
+
export const isHalf = (f: Pick<FormField, 'width' | 'type'>) => f.width === 'half' && f.type !== 'heading';
|
|
33
|
+
export const hasOther = (f: Pick<FormField, 'allowOther' | 'type'>) => Boolean(f.allowOther) && (f.type === 'radio' || f.type === 'checkboxes');
|
|
34
|
+
/** Fields that collect an answer (section headings don't). */
|
|
35
|
+
export const answerFields = (d: Pick<FormDefinition, 'fields'>) => d.fields.filter(f => f.type !== 'heading');
|
|
36
|
+
export const FormResponse = z.object({ version: z.string(), definition: FormDefinition, answers: z.record(z.string().max(2000)) });
|
|
37
|
+
export type FormResponse = z.infer<typeof FormResponse>;
|
|
38
|
+
export const SavedForm = z.object({
|
|
39
|
+
id: z.string(), name: z.string().trim().min(1).max(120), kind: z.enum(['form', 'template']), purpose: z.enum(['event', 'general']),
|
|
40
|
+
status: z.enum(['draft', 'published', 'archived']), definition: FormDefinition, draft: FormDefinition.nullable(),
|
|
41
|
+
revision: z.number().int(), createdAt: z.number(), updatedAt: z.number(),
|
|
42
|
+
});
|
|
43
|
+
export type SavedForm = z.infer<typeof SavedForm>;
|
|
44
|
+
export const FormSubmission = z.object({ id: z.string(), formId: z.string(), response: FormResponse, createdAt: z.number() });
|
|
45
|
+
export type FormSubmission = z.infer<typeof FormSubmission>;
|
|
46
|
+
const field = (id: string, label: string, type: FormField['type'], required = false, options: string[] = []): FormField => ({ id, label, type, required, help: '', options });
|
|
47
|
+
export function starter(key: string): FormDefinition {
|
|
48
|
+
const contact = key === 'contact', interest = key === 'interest';
|
|
49
|
+
return FormDefinition.parse({
|
|
50
|
+
title: contact ? 'Contact us' : interest ? 'Stay in touch' : 'Save your seat',
|
|
51
|
+
submitLabel: contact ? 'Send message' : interest ? 'Sign up' : '',
|
|
52
|
+
confirmation: contact || interest ? 'Thank you. Your response has been received.' : '',
|
|
53
|
+
fields: contact || interest ? [field('name', 'Your name', 'text', true), field('email', 'Email', 'email', true), ...(contact ? [field('message', 'Message', 'textarea', true)] : [])]
|
|
54
|
+
: key === 'event' ? [field('phone', 'Phone', 'phone'), field('dietary', 'Dietary needs', 'textarea'), field('notes', 'Notes', 'textarea')] : [],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ApiError } from '../api';
|
|
2
|
+
import { sha256Hex } from '../api-keys';
|
|
3
|
+
import { FormDefinition, answerFields, type FormResponse } from './schema';
|
|
4
|
+
import { AnswerError, checkAnswer } from './validate';
|
|
5
|
+
|
|
6
|
+
export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.stringify(FormDefinition.parse(definition)));
|
|
7
|
+
export async function collectResponse(definition: FormDefinition, input: Record<string, string>, version?: string): Promise<FormResponse> {
|
|
8
|
+
definition = FormDefinition.parse(definition);
|
|
9
|
+
const current = await formVersion(definition);
|
|
10
|
+
if (version !== undefined && version !== current) throw new ApiError(409, 'This form has changed. Review the updated questions and submit again.');
|
|
11
|
+
const answers: Record<string, string> = Object.create(null);
|
|
12
|
+
if (Object.keys(input).length > 30) throw new ApiError(400, 'Too many answers.');
|
|
13
|
+
for (const f of answerFields(definition)) {
|
|
14
|
+
try { answers[f.id] = checkAnswer(f, input[f.id] ?? ''); }
|
|
15
|
+
catch (e) { if (e instanceof AnswerError) throw new ApiError(400, e.message); throw e; }
|
|
16
|
+
}
|
|
17
|
+
return { version: current, definition, answers };
|
|
18
|
+
}
|
|
19
|
+
// Labels are a compatibility view only; snapshots are the authoritative record.
|
|
20
|
+
export function displayAnswers(response: FormResponse): Record<string, string> {
|
|
21
|
+
const out: Record<string, string> = Object.create(null);
|
|
22
|
+
for (const f of answerFields(response.definition)) {
|
|
23
|
+
const v = response.answers[f.id];
|
|
24
|
+
let label = f.label;
|
|
25
|
+
let n = 0;
|
|
26
|
+
while (Object.hasOwn(out, label)) label = `${f.label} (${f.id}${n++ ? `-${n}` : ''})`;
|
|
27
|
+
out[label] = f.type === 'checkbox' || f.type === 'consent' ? v === 'yes' ? 'Yes' : 'No'
|
|
28
|
+
: f.type === 'checkboxes' ? (v ? v.split('\n').join(', ') : '—') : v || '—';
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export function csv(rows: unknown[][]): string {
|
|
33
|
+
return '\uFEFF' + rows.map(row => row.map(cell => {
|
|
34
|
+
let s = String(cell ?? '');
|
|
35
|
+
if (/^[\s]*[=+\-@]|^[\t\r\n]/.test(s)) s = "'" + s;
|
|
36
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
37
|
+
}).join(',')).join('\r\n');
|
|
38
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
2
|
+
import { ApiError } from '../api';
|
|
3
|
+
import { FormDefinition, FormResponse, FormSubmission, SavedForm } from './schema';
|
|
4
|
+
const row = (r: Record<string, unknown>) => SavedForm.parse({ ...r, definition: JSON.parse(String(r.definition)), draft: r.draft ? JSON.parse(String(r.draft)) : null, createdAt: r.created_at, updatedAt: r.updated_at });
|
|
5
|
+
export const formInsert = (f: SavedForm): SqlStatement => ({ sql: 'INSERT INTO forms (id,name,kind,purpose,status,definition,draft,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)', args: [f.id,f.name,f.kind,f.purpose,f.status,JSON.stringify(f.definition),f.draft ? JSON.stringify(f.draft) : null,f.revision,f.createdAt,f.updatedAt] });
|
|
6
|
+
export const submissionInsert = (s: FormSubmission): SqlStatement => ({ sql: 'INSERT INTO form_submissions (id,form_id,response,created_at) VALUES (?,?,?,?)', args: [s.id,s.formId,JSON.stringify(s.response),s.createdAt] });
|
|
7
|
+
export class FormStore {
|
|
8
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
9
|
+
async list() { return (await (await this.driver()).all('SELECT * FROM forms ORDER BY updated_at DESC')).map(row); }
|
|
10
|
+
async get(id: string) { const r = (await (await this.driver()).all('SELECT * FROM forms WHERE id=?', [id]))[0]; return r ? row(r) : null; }
|
|
11
|
+
async create(name: string, definition: FormDefinition, kind: SavedForm['kind'] = 'form', purpose: SavedForm['purpose'] = 'general') {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const f = SavedForm.parse({ id: crypto.randomUUID(), name, definition, kind, purpose, status: 'draft', draft: null, revision: 1, createdAt: now, updatedAt: now });
|
|
14
|
+
await (await this.driver()).batch([formInsert(f)]); return f;
|
|
15
|
+
}
|
|
16
|
+
async save(id: string, revision: number, name: string, definition: FormDefinition, op: 'save' | 'publish' | 'archive') {
|
|
17
|
+
const current = await this.get(id); if (!current) throw new ApiError(404, 'Form not found.');
|
|
18
|
+
const f = SavedForm.parse({ ...current, name, definition });
|
|
19
|
+
// Published definitions remain immutable until an explicit publish.
|
|
20
|
+
const live = op === 'save' && current.status === 'published';
|
|
21
|
+
const rows = await (await this.driver()).all('UPDATE forms SET name=?,definition=?,draft=?,status=?,revision=revision+1,updated_at=? WHERE id=? AND revision=? RETURNING id',
|
|
22
|
+
[f.name, JSON.stringify(live ? current.definition : f.definition), live ? JSON.stringify(f.definition) : null, op === 'publish' ? 'published' : op === 'archive' ? 'archived' : current.status, Date.now(), id, revision]);
|
|
23
|
+
if (!rows.length) throw new ApiError(409, 'This form changed in another window. Reload before saving.');
|
|
24
|
+
}
|
|
25
|
+
async submissions(formId?: string, limit = 100, offset = 0) {
|
|
26
|
+
return (await (await this.driver()).all(`SELECT * FROM form_submissions ${formId ? 'WHERE form_id=?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...(formId ? [formId] : []), limit, offset])).map(r => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at }));
|
|
27
|
+
}
|
|
28
|
+
async submit(formId: string, response: FormResponse, requestId: string) {
|
|
29
|
+
response = FormResponse.parse(response);
|
|
30
|
+
const db = await this.driver();
|
|
31
|
+
// The receipt is also the unique request identity, so retries don't duplicate responses.
|
|
32
|
+
const old = (await db.all('SELECT form_id,response FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
33
|
+
if (old && (old.form_id !== formId || String(old.response) !== JSON.stringify(response))) throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
|
|
34
|
+
if (!old) await db.run('INSERT OR IGNORE INTO form_submissions (id,form_id,response,created_at) SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM forms WHERE id=? AND status=\'published\' AND kind=\'form\' AND purpose=\'general\' AND definition=?)', [requestId,formId,JSON.stringify(response),Date.now(),formId,JSON.stringify(response.definition)]);
|
|
35
|
+
const saved = (await db.all('SELECT form_id,response FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
36
|
+
if (!saved || saved.form_id !== formId || String(saved.response) !== JSON.stringify(response)) throw new ApiError(409, 'This form changed. Reload and try again.');
|
|
37
|
+
}
|
|
38
|
+
// Archived → draft (the definition is kept; publishing reopens it).
|
|
39
|
+
async restore(id: string) { await (await this.driver()).run("UPDATE forms SET status='draft',revision=revision+1,updated_at=? WHERE id=? AND status='archived'", [Date.now(), id]); }
|
|
40
|
+
// One grouped query for the list page: response count + latest per form.
|
|
41
|
+
async stats() {
|
|
42
|
+
const rows = await (await this.driver()).all('SELECT form_id, COUNT(*) AS n, MAX(created_at) AS last FROM form_submissions GROUP BY form_id');
|
|
43
|
+
return new Map(rows.map(r => [String(r.form_id), { count: Number(r.n), last: r.last == null ? null : Number(r.last) }]));
|
|
44
|
+
}
|
|
45
|
+
async submissionStats(formId: string, since: number) {
|
|
46
|
+
const r = (await (await this.driver()).all('SELECT COUNT(*) AS n, MAX(created_at) AS last, SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS recent FROM form_submissions WHERE form_id=?', [since, formId]))[0] ?? {};
|
|
47
|
+
return { total: Number(r.n ?? 0), recent: Number(r.recent ?? 0), last: r.last == null ? null : Number(r.last) };
|
|
48
|
+
}
|
|
49
|
+
// Distinct definition versions in first-seen order, so v1…vN read as history.
|
|
50
|
+
async versions(formId: string) {
|
|
51
|
+
return (await (await this.driver()).all("SELECT json_extract(response,'$.version') AS v, MIN(created_at) AS first FROM form_submissions WHERE form_id=? GROUP BY v ORDER BY first ASC LIMIT 500", [formId])).map(r => String(r.v));
|
|
52
|
+
}
|
|
53
|
+
// Filtered page of responses. `q` matches answers only (not question labels).
|
|
54
|
+
async searchSubmissions(formId: string, opts: { limit?: number; offset?: number; since?: number; q?: string; ids?: string[] } = {}) {
|
|
55
|
+
const where = ['form_id=?'], args: unknown[] = [formId];
|
|
56
|
+
if (opts.since) { where.push('created_at >= ?'); args.push(opts.since); }
|
|
57
|
+
const q = opts.q?.trim().slice(0, 100);
|
|
58
|
+
// instr, not LIKE: D1 rejects LIKE patterns over 50 bytes
|
|
59
|
+
if (q) { where.push("EXISTS (SELECT 1 FROM json_each(form_submissions.response,'$.answers') WHERE instr(lower(CAST(value AS TEXT)), lower(?)) > 0)"); args.push(q); }
|
|
60
|
+
if (opts.ids) { const ids = opts.ids.slice(0, 500); if (!ids.length) return []; where.push(`id IN (${ids.map(() => '?').join(',')})`); args.push(...ids); }
|
|
61
|
+
return (await (await this.driver()).all(`SELECT * FROM form_submissions WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...args, opts.limit ?? 100, opts.offset ?? 0])).map(r => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at }));
|
|
62
|
+
}
|
|
63
|
+
async deleteSubmissions(formId: string, ids: string[]) { for (const id of ids.slice(0, 500)) await this.deleteSubmission(formId, id); }
|
|
64
|
+
// Posts, pages and events whose body (live or staged draft) embeds this form.
|
|
65
|
+
async usedOn(formId: string) {
|
|
66
|
+
const db = await this.driver();
|
|
67
|
+
// instr, not LIKE: a uuid pattern is over D1's 50-byte LIKE limit
|
|
68
|
+
const html = `data-form="${formId}"`, json = `data-form=\\"${formId}\\"`;
|
|
69
|
+
const q = (table: string, extra = '') => db.all(`SELECT id, title, status${extra} FROM ${table} WHERE instr(body_html, ?) > 0 OR instr(draft, ?) > 0 ORDER BY updated_at DESC LIMIT 50`, [html, json]);
|
|
70
|
+
const [posts, pages, events] = await Promise.all([q('posts', ', collection'), q('pages'), q('events')]);
|
|
71
|
+
return [
|
|
72
|
+
...posts.map(r => ({ kind: 'post' as const, id: String(r.id), title: String(r.title || ''), status: String(r.status), collection: String(r.collection || 'posts') })),
|
|
73
|
+
...pages.map(r => ({ kind: 'page' as const, id: String(r.id), title: String(r.title || ''), status: String(r.status), collection: '' })),
|
|
74
|
+
...events.map(r => ({ kind: 'event' as const, id: String(r.id), title: String(r.title || ''), status: String(r.status), collection: '' })),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
async deleteSubmission(formId: string, id: string) { await (await this.driver()).run('DELETE FROM form_submissions WHERE form_id=? AND id=?', [formId,id]); }
|
|
78
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure answer reading + validation, shared by the server (collectResponse,
|
|
2
|
+
// readAnswers) and the builder's preview. No server imports here.
|
|
3
|
+
import { answerFields, hasOther, type FormDefinition, type FormField } from './schema';
|
|
4
|
+
|
|
5
|
+
/** Value of the "Other" radio/checkbox; its text arrives as `o_<field id>`. */
|
|
6
|
+
export const OTHER = '__other__';
|
|
7
|
+
export class AnswerError extends Error {}
|
|
8
|
+
const invalid = (f: FormField) => new AnswerError(`Please enter a valid answer for “${f.label}”.`);
|
|
9
|
+
|
|
10
|
+
/** Reads posted answers (`q_<id>`, plus `o_<id>` for "Other" text). Checkbox groups may post several values; they are stored newline-joined. */
|
|
11
|
+
export function answersFrom(getAll: (name: string) => unknown[], definition: Pick<FormDefinition, 'fields'>): Record<string, string> {
|
|
12
|
+
const answers: Record<string, string> = Object.create(null);
|
|
13
|
+
for (const f of answerFields(definition)) {
|
|
14
|
+
const values = getAll(`q_${f.id}`);
|
|
15
|
+
if (values.some(v => typeof v !== 'string')) throw new AnswerError('Invalid form answer.');
|
|
16
|
+
const other = () => { const o = getAll(`o_${f.id}`); if (o.length > 1 || o.some(v => typeof v !== 'string')) throw new AnswerError('Invalid form answer.'); return String(o[0] ?? '').trim(); };
|
|
17
|
+
if (f.type === 'checkboxes') {
|
|
18
|
+
if (values.length > f.options.length + 1) throw new AnswerError('Invalid form answer.');
|
|
19
|
+
let list = values as string[];
|
|
20
|
+
if (hasOther(f) && list.includes(OTHER)) { const text = other(); list = list.filter(v => v !== OTHER); if (text) list.push(text); }
|
|
21
|
+
answers[f.id] = list.join('\n');
|
|
22
|
+
} else {
|
|
23
|
+
if (values.length > 1) throw new AnswerError('Invalid form answer.');
|
|
24
|
+
const value = String(values[0] ?? '');
|
|
25
|
+
answers[f.id] = f.type === 'radio' && hasOther(f) && value === OTHER ? other() : value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return answers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validates one answer and returns its stored form. Throws AnswerError with a visitor-facing message. */
|
|
32
|
+
export function checkAnswer(f: FormField, raw: unknown): string {
|
|
33
|
+
if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError(`“${f.label}” is too long.`);
|
|
34
|
+
if (f.type === 'heading') return '';
|
|
35
|
+
if (f.type === 'checkboxes') {
|
|
36
|
+
const items = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
37
|
+
const extra = items.filter(i => !f.options.includes(i));
|
|
38
|
+
if (f.required && !items.length) throw new AnswerError(`Please answer “${f.label}”.`);
|
|
39
|
+
if (new Set(items).size !== items.length || extra.length > (hasOther(f) ? 1 : 0) || extra.some(e => e.length > 200)) throw invalid(f);
|
|
40
|
+
// Store in the form's choice order, with "Other" text last.
|
|
41
|
+
return [...f.options.filter(o => items.includes(o)), ...extra].join('\n');
|
|
42
|
+
}
|
|
43
|
+
let value = raw.trim();
|
|
44
|
+
const agree = f.type === 'checkbox' || f.type === 'consent';
|
|
45
|
+
if (f.required && (!value || (agree && value !== 'yes'))) throw new AnswerError(`Please answer “${f.label}”.`);
|
|
46
|
+
if (!value) return value;
|
|
47
|
+
let valid = true;
|
|
48
|
+
if (f.type === 'email') valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
49
|
+
if (f.type === 'number') valid = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value) && Number.isFinite(Number(value));
|
|
50
|
+
if (f.type === 'date') valid = /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
51
|
+
if (f.type === 'select') valid = f.options.includes(value);
|
|
52
|
+
if (f.type === 'radio') valid = f.options.includes(value) || (hasOther(f) && value.length <= 200 && !value.includes('\n'));
|
|
53
|
+
if (agree) valid = value === 'yes';
|
|
54
|
+
if (f.type === 'phone') valid = value.length <= 40 && /^[+\d().\s-]+$/.test(value);
|
|
55
|
+
if (f.type === 'url') {
|
|
56
|
+
// Accept "example.org" too; store it with a scheme.
|
|
57
|
+
if (!/^[a-z][a-z\d+.-]*:/i.test(value)) value = `https://${value}`;
|
|
58
|
+
try { const u = new URL(value); valid = (u.protocol === 'https:' || u.protocol === 'http:') && /\./.test(u.hostname) && !/\s/.test(value); }
|
|
59
|
+
catch { valid = false; }
|
|
60
|
+
}
|
|
61
|
+
if (!valid) throw invalid(f);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** All answers at once (used by the builder preview); returns the first error message, or ''. */
|
|
66
|
+
export function firstError(definition: Pick<FormDefinition, 'fields'>, answers: Record<string, string>): string {
|
|
67
|
+
for (const f of answerFields(definition)) {
|
|
68
|
+
try { checkAnswer(f, answers[f.id] ?? ''); } catch (e) { return e instanceof Error ? e.message : 'Invalid answer.'; }
|
|
69
|
+
}
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Maps setup: acms({ maps: { provider } }) picks the provider; secrets come
|
|
2
|
+
// from the environment (Worker vars/secrets or process.env):
|
|
3
|
+
// MAPS_API_KEY — the provider's API key / access token
|
|
4
|
+
// MAPS_URL — base URL of a self-hosted Photon, Nominatim or Pelias
|
|
5
|
+
// With no provider configured: MAPS_URL set → photon; else MAPS_API_KEY set
|
|
6
|
+
// → geoapify; else maps are off (link parsing and saved places still work).
|
|
7
|
+
// Keys stay on the server — only MapsStatus ever reaches the browser.
|
|
8
|
+
import { userConfig } from 'virtual:acms-config';
|
|
9
|
+
import { env } from '../env';
|
|
10
|
+
import type { MapsProviderId, MapsStatus } from './types';
|
|
11
|
+
import { PROVIDERS, type MapsProvider } from './providers';
|
|
12
|
+
|
|
13
|
+
function runtimeValue(key: string, locals?: unknown): string {
|
|
14
|
+
const value = (locals as { runtime?: { env?: Record<string, unknown> } } | undefined)?.runtime?.env?.[key];
|
|
15
|
+
return typeof value === 'string' ? value.trim() : env(key).trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function configuredProvider(): MapsProviderId | '' {
|
|
19
|
+
const p = userConfig.maps?.provider ?? '';
|
|
20
|
+
return p in PROVIDERS ? (p as MapsProviderId) : '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Resolved {
|
|
24
|
+
id: MapsProviderId | '';
|
|
25
|
+
key: string;
|
|
26
|
+
url: string;
|
|
27
|
+
ready: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolve(locals?: unknown): Resolved {
|
|
31
|
+
const key = runtimeValue('MAPS_API_KEY', locals);
|
|
32
|
+
const url = runtimeValue('MAPS_URL', locals).replace(/\/+$/, '');
|
|
33
|
+
const id = configuredProvider() || (url ? 'photon' : key ? 'geoapify' : '');
|
|
34
|
+
if (!id) return { id: '', key, url, ready: false };
|
|
35
|
+
const def = PROVIDERS[id];
|
|
36
|
+
const okUrl = /^https?:\/\//i.test(url);
|
|
37
|
+
return { id, key, url, ready: def.selfHosted ? okUrl : Boolean(key) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function mapsStatus(locals?: unknown): MapsStatus {
|
|
41
|
+
const r = resolve(locals);
|
|
42
|
+
if (!r.id) return { enabled: false, provider: '', label: '', search: false, staticMaps: false };
|
|
43
|
+
const def = PROVIDERS[r.id];
|
|
44
|
+
return { enabled: r.ready, provider: r.id, label: def.label, search: r.ready, staticMaps: r.ready && def.staticMaps };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** the active provider adapter, or null when maps aren't set up */
|
|
48
|
+
export function activeProvider(locals?: unknown, origin = ''): MapsProvider | null {
|
|
49
|
+
const r = resolve(locals);
|
|
50
|
+
if (!r.id || !r.ready) return null;
|
|
51
|
+
return PROVIDERS[r.id].create({ key: r.key, url: r.url, userAgent: `acms${origin ? ` (+${origin})` : ''}` });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// what the setup instructions show for each provider
|
|
55
|
+
export const MAPS_SETUP: { id: MapsProviderId; env: string }[] = [
|
|
56
|
+
{ id: 'geoapify', env: 'MAPS_API_KEY' },
|
|
57
|
+
{ id: 'google', env: 'MAPS_API_KEY' },
|
|
58
|
+
{ id: 'mapbox', env: 'MAPS_API_KEY' },
|
|
59
|
+
{ id: 'locationiq', env: 'MAPS_API_KEY' },
|
|
60
|
+
{ id: 'stadia', env: 'MAPS_API_KEY' },
|
|
61
|
+
{ id: 'photon', env: 'MAPS_URL' },
|
|
62
|
+
{ id: 'nominatim', env: 'MAPS_URL' },
|
|
63
|
+
{ id: 'pelias', env: 'MAPS_URL' },
|
|
64
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Saved places (HQ, the park pavilion…) and the place a room is in. Event
|
|
2
|
+
// locations live in the events.location JSON column, so the new pin fields
|
|
3
|
+
// (lat/lng, placeId, map images) need no event migration.
|
|
4
|
+
export const PLACES_MIGRATION = { version: 43, statements: [
|
|
5
|
+
`CREATE TABLE IF NOT EXISTS places (
|
|
6
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
|
|
7
|
+
lat REAL, lng REAL, notes TEXT NOT NULL DEFAULT '', is_default INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
map_image TEXT NOT NULL DEFAULT '', map_image_dark TEXT NOT NULL DEFAULT '',
|
|
9
|
+
ord INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
10
|
+
)`,
|
|
11
|
+
`ALTER TABLE resources ADD COLUMN place_id TEXT NOT NULL DEFAULT ''`,
|
|
12
|
+
] };
|