@acms-subir/core 0.3.59 → 0.3.60
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 +12 -0
- package/package.json +1 -1
- package/src/lib/booking/service.ts +3 -2
- package/src/lib/editor/site-data.ts +7 -3
- package/src/lib/forms/alert-mail.ts +7 -4
- package/src/lib/forms/http.ts +10 -8
- package/src/lib/forms/schema.ts +17 -2
- package/src/lib/forms/service.ts +50 -7
- package/src/lib/forms/store.ts +15 -5
- package/src/lib/forms/validate.ts +17 -11
- package/src/lib/forms/words.ts +64 -6
- package/src/lib/mobile/bookings.ts +7 -1
- package/src/lib/mobile/forms.ts +32 -26
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/pages/admin/calendar/bookings.astro +3 -2
- package/src/pages/admin/forms/[id]/submissions.astro +4 -1
- package/src/pages/events/[slug].astro +7 -5
- package/src/pages/forms/[id].astro +7 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.60
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9e2d741: A follow-up for forms on sites in more than one language, and for staff reading time answers.
|
|
8
|
+
|
|
9
|
+
- **Times read the way your site writes them.** A time answer, stored as 24-hour `18:30`, now shows the way your site's locale writes times (Settings → Timezone & language): "6:30 PM" for en-US, "6:30 p.m." for es-MX, "18:30" for es-ES. You'll see this in Forms → Responses, the editor app's responses and the staff alert emails. For event registrations, it's in the hosts' "New RSVP" email and the registration's details in Bookings. CSV exports keep the stored `HH:mm`, just as they keep dates as `YYYY-MM-DD`, because spreadsheets sort and format that reliably. Nothing stored changes, and searching Responses still matches the stored text ("18:30").
|
|
10
|
+
- **Error messages in the visitor's language.** On `/es/forms/<id>`, and in Form blocks on Spanish pages, a missing or invalid answer now gets a Spanish message that names the question by the Spanish label the visitor saw ("Responde “Hora”."). The form's other refusals are in Spanish too: a form that changed, a post that's too large, too many submissions, "reload the form". Event pages word their RSVP form's errors the same way. Apps get these messages in the `locale` they send to `POST /api/v1/forms/{id}/responses` (also the 404 and 410 from `GET /api/v1/forms/{id}`), and for RSVP answers sent to `POST /api/v1/bookings` with a `locale`. Error codes and statuses don't change, so apps that check them keep working. Messages come in English, Spanish, French, German and Portuguese; other languages get English, and pages in the main language read as before.
|
|
11
|
+
- **Forms stored with their keys in another order take responses again.** Sometimes a form definition is written straight into the database or imported, with its JSON keys in a different order from the one the site uses. Every response to such a form failed with "This form changed. Reload and try again." Responses are now checked against the form by what it asks, not by how its JSON is written, so they go through. A form that's unpublished, archived or really changed between loading and sending still refuses the response.
|
|
12
|
+
|
|
13
|
+
There's no migration, and nothing to change after updating.
|
|
14
|
+
|
|
3
15
|
## 0.3.59
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { collectResponse, displayAnswers } from '../forms/service';
|
|
1
|
+
import { collectResponse, displayAnswers, staffAnswers } from '../forms/service';
|
|
2
2
|
// Booking workflows shared by the public booking pages, the admin, the REST
|
|
3
3
|
// API and agent tools, so every path validates, notifies and fires webhooks
|
|
4
4
|
// the same way. Auto-confirm links confirm on the spot (and show on the
|
|
@@ -158,7 +158,8 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
|
|
|
158
158
|
position: await waitlistPosition(repo, b), hours: WAITLIST_HOLD / 3_600_000,
|
|
159
159
|
until: b.holdUntil === null ? '' : untilText(b.holdUntil, d.tz, settings),
|
|
160
160
|
} : undefined;
|
|
161
|
-
|
|
161
|
+
// staff read an RSVP's time answers as the site writes them ("6:30 PM"); Booking.answers keeps them as stored
|
|
162
|
+
return bookingEmail(kind, { booking:staff ? { ...b, answers: staffAnswers(b, siteLocale(settings)) } : b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
162
163
|
manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
163
164
|
mineLink: !!mine,
|
|
164
165
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
@@ -3,6 +3,7 @@ import type { Link } from '../schema';
|
|
|
3
3
|
import { LinksAvatarShape, LinksAvatarFit, LinksAvatarScale, LinkKind } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { displayAnswers } from '../forms/service';
|
|
6
|
+
import { siteLocale } from '../dates';
|
|
6
7
|
import type { EditorContext } from './http';
|
|
7
8
|
import { isAdmin, requireAdmin } from './roles';
|
|
8
9
|
import { principalCan, requirePrincipal } from '../permissions/access';
|
|
@@ -35,13 +36,16 @@ export async function formResponsesFor(c: EditorContext, id: string, q: z.infer<
|
|
|
35
36
|
await requirePrincipal(c.repo, c.who, 'forms.responses');
|
|
36
37
|
const form = await c.repo.forms.get(id);
|
|
37
38
|
if (!form || form.kind !== 'form') throw new ApiError(404, 'Form not found.');
|
|
38
|
-
const rows = await
|
|
39
|
+
const [rows, settings] = await Promise.all([
|
|
40
|
+
c.repo.forms.searchSubmissions(id, { q: q.q, since: q.since ? Date.parse(q.since) : undefined, limit: q.limit + 1, offset: (q.page - 1) * q.limit }),
|
|
41
|
+
c.repo.getSettings(),
|
|
42
|
+
]);
|
|
39
43
|
return {
|
|
40
44
|
form: { id: form.id, name: form.name },
|
|
41
45
|
items: rows.slice(0, q.limit).map((s) => ({
|
|
42
46
|
id: s.id, createdAt: iso(s.createdAt)!,
|
|
43
|
-
// labelled answers from the questions as they were when submitted
|
|
44
|
-
answers: Object.entries(displayAnswers(s.response)).map(([label, value]) => ({ label, value })),
|
|
47
|
+
// labelled answers from the questions as they were when submitted, times as the site writes them
|
|
48
|
+
answers: Object.entries(displayAnswers(s.response, siteLocale(settings))).map(([label, value]) => ({ label, value })),
|
|
45
49
|
})),
|
|
46
50
|
pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null },
|
|
47
51
|
};
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// and its push: in the site's main language, like other staff emails. With
|
|
3
3
|
// the answers, every question in order with its label; without them (forms
|
|
4
4
|
// that ask for sensitive things), only that a response arrived and when, so
|
|
5
|
-
// the email holds nothing about the person.
|
|
6
|
-
//
|
|
5
|
+
// the email holds nothing about the person. Times read as the site writes
|
|
6
|
+
// them ("6:30 PM" in en-US). Plain HTML like the staff booking emails, plus a
|
|
7
|
+
// text part. Other languages fall back to English.
|
|
7
8
|
import type { ContentRepository } from '../repo/types';
|
|
8
9
|
import type { Settings } from '../schema';
|
|
9
10
|
import type { Mail } from '../email/types';
|
|
@@ -12,6 +13,7 @@ import { siteLang, siteLocale } from '../dates';
|
|
|
12
13
|
import { I18N } from '../i18n';
|
|
13
14
|
import { fill } from '../live/words';
|
|
14
15
|
import { answerFields, type FormField, type FormResponse } from './schema';
|
|
16
|
+
import { timeText } from './service';
|
|
15
17
|
|
|
16
18
|
/** alerts one form sends in a day (the site's); the last one says so */
|
|
17
19
|
export const ALERTS_PER_DAY = 50;
|
|
@@ -64,9 +66,10 @@ function whenText(at: number, locale: string, tz: string, year: boolean) {
|
|
|
64
66
|
// names can't break the subject line
|
|
65
67
|
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
66
68
|
|
|
67
|
-
function shown(f: FormField, value: string | undefined, w: AlertWords) {
|
|
69
|
+
function shown(f: FormField, value: string | undefined, w: AlertWords, locale: string) {
|
|
68
70
|
if (f.type === 'checkbox' || f.type === 'consent') return value === 'yes' ? w.yes : w.no;
|
|
69
71
|
if (f.type === 'checkboxes') return value ? value.split('\n').join(', ') : '—';
|
|
72
|
+
if (f.type === 'time' && value) return timeText(value, locale);
|
|
70
73
|
return value || '—';
|
|
71
74
|
}
|
|
72
75
|
/** the answer to the first email question, when it's a usable address (else '') */
|
|
@@ -94,7 +97,7 @@ export async function formAlertMail(repo: ContentRepository, to: string, p: Aler
|
|
|
94
97
|
const name = oneLine(form.name);
|
|
95
98
|
const heading = fill(w.subject, { form: name });
|
|
96
99
|
const intro = fill(w.received, { when: whenText(s.createdAt, locale, settings.timezone, true) });
|
|
97
|
-
const rows: [string, string][] = answers ? answerFields(s.response.definition).map((f) => [f.label, shown(f, s.response.answers[f.id], w)]) : [];
|
|
100
|
+
const rows: [string, string][] = answers ? answerFields(s.response.definition).map((f) => [f.label, shown(f, s.response.answers[f.id], w, locale)]) : [];
|
|
98
101
|
// replies reach the person only when the email shows their answers
|
|
99
102
|
const replyTo = answers ? replyAddress(s.response) : '';
|
|
100
103
|
const notes = [...(replyTo ? [fill(w.reply, { email: replyTo })] : []), ...(p.last ? [fill(w.last, { max: String(ALERTS_PER_DAY) })] : [])];
|
package/src/lib/forms/http.ts
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
import { ApiError } from '../api';
|
|
2
1
|
import { sha256Hex } from '../api-keys';
|
|
3
2
|
import type { ContentRepository } from '../repo/types';
|
|
4
3
|
import type { FormDefinition } from './schema';
|
|
5
4
|
import { AnswerError, answersFrom } from './validate';
|
|
5
|
+
import { refuse } from './service';
|
|
6
|
+
// Refusals say why (service.ts refuse): the public form and event pages word
|
|
7
|
+
// them in the visitor's language (formError); admin pages show the English.
|
|
6
8
|
export function checkOrigin(request: Request) {
|
|
7
9
|
const origin = request.headers.get('origin');
|
|
8
|
-
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') throw
|
|
10
|
+
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') throw refuse(403, 'otherSite');
|
|
9
11
|
}
|
|
10
12
|
export async function readForm(request: Request): Promise<FormData> {
|
|
11
13
|
checkOrigin(request);
|
|
12
14
|
// Read the stream with an actual bound (Content-Length is not trustworthy).
|
|
13
15
|
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
|
|
16
|
+
if (reader) for (;;) { const { value, done } = await reader.read(); if (done) break; length += value.length; if (length > 100_000) { await reader.cancel(); throw refuse(413, 'tooLarge'); } chunks.push(value); }
|
|
15
17
|
const bytes = new Uint8Array(length); let at = 0; for (const c of chunks) { bytes.set(c, at); at += c.length; }
|
|
16
18
|
try { return await new Response(bytes, { headers: { 'Content-Type': request.headers.get('content-type') || 'application/x-www-form-urlencoded' } }).formData(); }
|
|
17
|
-
catch { throw
|
|
19
|
+
catch { throw refuse(400, 'unreadable'); }
|
|
18
20
|
}
|
|
19
21
|
export function readAnswers(data: FormData, definition: FormDefinition) {
|
|
20
22
|
try { return answersFrom(name => data.getAll(name), definition); }
|
|
21
|
-
catch (e) { if (e instanceof AnswerError) throw
|
|
23
|
+
catch (e) { if (e instanceof AnswerError) throw refuse(400, e.reason, e.field); throw e; }
|
|
22
24
|
}
|
|
23
25
|
export async function submissionGuard(repo: ContentRepository, data: FormData, address: string, scope: string) {
|
|
24
|
-
if (data.get('website')) throw
|
|
25
|
-
if (!await repo.email.rateLimit(`forms:${await sha256Hex(scope + ':' + address)}`, 20, 60_000)) throw
|
|
26
|
+
if (data.get('website')) throw refuse(400, 'spam');
|
|
27
|
+
if (!await repo.email.rateLimit(`forms:${await sha256Hex(scope + ':' + address)}`, 20, 60_000)) throw refuse(429, 'tooFast');
|
|
26
28
|
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
|
|
29
|
+
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 refuse(400, 'reload');
|
|
28
30
|
return key;
|
|
29
31
|
}
|
package/src/lib/forms/schema.ts
CHANGED
|
@@ -36,8 +36,8 @@ export const FormField = z.object({
|
|
|
36
36
|
// Checkbox answers are stored newline-joined, so a choice is one line.
|
|
37
37
|
options: z.array(z.string().trim().min(1).max(200).regex(/^[^\r\n]*$/, 'Each choice must be one line.')).max(50).default([]),
|
|
38
38
|
// Newer props are optional (not defaulted) on purpose: parsing must not add
|
|
39
|
-
// keys to stored definitions, because form versions
|
|
40
|
-
//
|
|
39
|
+
// keys to stored definitions, because form versions hash the exact JSON.
|
|
40
|
+
// Read them through the helpers below.
|
|
41
41
|
placeholder: z.string().max(120).optional(),
|
|
42
42
|
width: z.enum(['full', 'half']).optional(),
|
|
43
43
|
allowOther: z.boolean().optional(),
|
|
@@ -86,6 +86,21 @@ export const SavedForm = z.object({
|
|
|
86
86
|
export type SavedForm = z.infer<typeof SavedForm>;
|
|
87
87
|
export const FormSubmission = z.object({ id: z.string(), formId: z.string(), response: FormResponse, createdAt: z.number() });
|
|
88
88
|
export type FormSubmission = z.infer<typeof FormSubmission>;
|
|
89
|
+
// JSON with every object's keys in one order (and undefined left out, as
|
|
90
|
+
// JSON.stringify does), so the same data reads the same however it was written
|
|
91
|
+
const sorted = (v: unknown): string => Array.isArray(v) ? `[${v.map((x) => (x === undefined ? 'null' : sorted(x))).join(',')}]`
|
|
92
|
+
: v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => (v as Record<string, unknown>)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${sorted((v as Record<string, unknown>)[k])}`).join(',')}}`
|
|
93
|
+
: JSON.stringify(v) ?? 'null';
|
|
94
|
+
const meaning = (schema: z.ZodTypeAny, v: unknown) => { const r = schema.safeParse(v); return sorted(r.success ? r.data : v); };
|
|
95
|
+
/**
|
|
96
|
+
* Whether two definitions are the same form, by meaning rather than as
|
|
97
|
+
* written: both parsed (defaults filled in), keys in one order. One stored
|
|
98
|
+
* straight in the database or imported may order its keys differently from
|
|
99
|
+
* how the site writes it.
|
|
100
|
+
*/
|
|
101
|
+
export const sameDefinition = (a: unknown, b: unknown) => meaning(FormDefinition, a) === meaning(FormDefinition, b);
|
|
102
|
+
/** The same for two responses (its definition, version and answers). */
|
|
103
|
+
export const sameResponse = (a: unknown, b: unknown) => meaning(FormResponse, a) === meaning(FormResponse, b);
|
|
89
104
|
const field = (id: string, label: string, type: FormField['type'], required = false, options: string[] = []): FormField => ({ id, label, type, required, help: '', options });
|
|
90
105
|
export function starter(key: string): FormDefinition {
|
|
91
106
|
const contact = key === 'contact', interest = key === 'interest';
|
package/src/lib/forms/service.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import { ApiError } from '../api';
|
|
2
2
|
import { sha256Hex } from '../api-keys';
|
|
3
|
-
import {
|
|
3
|
+
import type { Booking } from '../schema';
|
|
4
|
+
import { isExtraLocale } from '../translations';
|
|
5
|
+
import { FormDefinition, answerFields, type FormField, type FormResponse } from './schema';
|
|
4
6
|
import { AnswerError, checkAnswer } from './validate';
|
|
5
|
-
import { withoutWords } from './localize';
|
|
7
|
+
import { localizeForm, withoutWords } from './localize';
|
|
8
|
+
import { formErrorText, type FormErrorReason } from './words';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A refusal the person sending a form reads (the web form, an event's RSVP
|
|
12
|
+
* questions, the app API): why, and the question when it's about one. The
|
|
13
|
+
* message is the English one; the page or app words it in the person's
|
|
14
|
+
* language with formError. Not `code`: the app API sends an ApiError's code
|
|
15
|
+
* as the error code, and these keep theirs (invalid_request, conflict…).
|
|
16
|
+
*/
|
|
17
|
+
export type FormRefusal = ApiError & { refusal: { reason: FormErrorReason; field?: Pick<FormField, 'id' | 'label'> } };
|
|
18
|
+
export const refuse = (status: number, reason: FormErrorReason, field?: Pick<FormField, 'id' | 'label'>, retryAfter?: number): FormRefusal =>
|
|
19
|
+
Object.assign(new ApiError(status, formErrorText(reason, field?.label), retryAfter), { refusal: { reason, field: field && { id: field.id, label: field.label } } });
|
|
20
|
+
const isRefusal = (e: unknown): e is FormRefusal => e instanceof ApiError && 'refusal' in e;
|
|
21
|
+
/**
|
|
22
|
+
* A form's refusal in `lang` (the page's language, or the app's ?locale=),
|
|
23
|
+
* naming the question as `definition` reads in it: the label the person saw.
|
|
24
|
+
* Same status; anything else comes back as it is.
|
|
25
|
+
*/
|
|
26
|
+
export function formError<E>(e: E, lang: string | null | undefined, definition?: FormDefinition): E | FormRefusal {
|
|
27
|
+
if (!isRefusal(e)) return e;
|
|
28
|
+
const { refusal } = e, id = refusal.field?.id;
|
|
29
|
+
const shown = id && definition ? localizeForm(definition, isExtraLocale(lang) ? lang : '').fields.find((f) => f.id === id) : undefined;
|
|
30
|
+
return Object.assign(new ApiError(e.status, formErrorText(refusal.reason, shown?.label ?? refusal.field?.label, lang), e.retryAfter), { refusal });
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
// The version is the questions and their choices in each language, not the
|
|
8
34
|
// other translated words: rewording a label, help or title doesn't change
|
|
@@ -15,17 +41,31 @@ export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.string
|
|
|
15
41
|
export async function collectResponse(definition: FormDefinition, input: Record<string, string>, version?: string): Promise<FormResponse> {
|
|
16
42
|
definition = FormDefinition.parse(definition);
|
|
17
43
|
const current = await formVersion(definition);
|
|
18
|
-
if (version !== undefined && version !== current) throw
|
|
44
|
+
if (version !== undefined && version !== current) throw refuse(409, 'changed');
|
|
19
45
|
const answers: Record<string, string> = Object.create(null);
|
|
20
|
-
if (Object.keys(input).length > 30) throw
|
|
46
|
+
if (Object.keys(input).length > 30) throw refuse(400, 'tooManyAnswers');
|
|
21
47
|
for (const f of answerFields(definition)) {
|
|
22
48
|
try { answers[f.id] = checkAnswer(f, input[f.id] ?? ''); }
|
|
23
|
-
catch (e) { if (e instanceof AnswerError) throw
|
|
49
|
+
catch (e) { if (e instanceof AnswerError) throw refuse(400, e.reason, e.field); throw e; }
|
|
24
50
|
}
|
|
25
51
|
return { version: current, definition, answers };
|
|
26
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A stored time ("18:30") as `locale` writes times: "6:30 PM" in en-US,
|
|
55
|
+
* "6:30 p.m." in es-MX, "18:30" in es-ES. Without a locale (or for anything
|
|
56
|
+
* that isn't HH:mm) it stays as it is.
|
|
57
|
+
*/
|
|
58
|
+
export function timeText(value: string, locale?: string | null): string {
|
|
59
|
+
const m = /^(\d{2}):(\d{2})$/.exec(value);
|
|
60
|
+
if (!m || !locale) return value;
|
|
61
|
+
try { return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(Date.UTC(2000, 0, 1, Number(m[1]), Number(m[2]))); }
|
|
62
|
+
catch { return value; }
|
|
63
|
+
}
|
|
27
64
|
// Labels are a compatibility view only; snapshots are the authoritative record.
|
|
28
|
-
|
|
65
|
+
// locale: staff screens and emails show times as the site writes them
|
|
66
|
+
// (timeText); without one they stay as stored, as RSVPs keep them in
|
|
67
|
+
// Booking.answers. Exports never pass one.
|
|
68
|
+
export function displayAnswers(response: FormResponse, locale?: string): Record<string, string> {
|
|
29
69
|
const out: Record<string, string> = Object.create(null);
|
|
30
70
|
for (const f of answerFields(response.definition)) {
|
|
31
71
|
const v = response.answers[f.id];
|
|
@@ -33,10 +73,13 @@ export function displayAnswers(response: FormResponse): Record<string, string> {
|
|
|
33
73
|
let n = 0;
|
|
34
74
|
while (Object.hasOwn(out, label)) label = `${f.label} (${f.id}${n++ ? `-${n}` : ''})`;
|
|
35
75
|
out[label] = f.type === 'checkbox' || f.type === 'consent' ? v === 'yes' ? 'Yes' : 'No'
|
|
36
|
-
: f.type === 'checkboxes' ? (v ? v.split('\n').join(', ') : '—') : v || '—';
|
|
76
|
+
: f.type === 'checkboxes' ? (v ? v.split('\n').join(', ') : '—') : f.type === 'time' && v ? timeText(v, locale) : v || '—';
|
|
37
77
|
}
|
|
38
78
|
return out;
|
|
39
79
|
}
|
|
80
|
+
/** A booking's answers as staff read them: an RSVP form's with times in `locale` (displayAnswers), else the stored ones. */
|
|
81
|
+
export const staffAnswers = (b: Pick<Booking, 'answers' | 'formResponse'>, locale: string) =>
|
|
82
|
+
b.formResponse ? displayAnswers(b.formResponse, locale) : b.answers;
|
|
40
83
|
export function csv(rows: unknown[][]): string {
|
|
41
84
|
return '\uFEFF' + rows.map(row => row.map(cell => {
|
|
42
85
|
let s = String(cell ?? '');
|
package/src/lib/forms/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
2
2
|
import { ApiError } from '../api';
|
|
3
|
-
import { FormAlerts, FormDefinition, FormResponse, FormSubmission, SavedForm } from './schema';
|
|
3
|
+
import { FormAlerts, FormDefinition, FormResponse, FormSubmission, SavedForm, sameDefinition, sameResponse } from './schema';
|
|
4
|
+
import { refuse } from './service';
|
|
4
5
|
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, alerts: JSON.parse(String(r.alerts || '{}')) });
|
|
5
6
|
const submission = (r: Record<string, unknown>) => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at });
|
|
6
7
|
export const formInsert = (f: SavedForm): SqlStatement => ({ sql: 'INSERT INTO forms (id,name,kind,purpose,status,definition,draft,revision,created_at,updated_at,alerts) 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,JSON.stringify(f.alerts)] });
|
|
@@ -36,7 +37,7 @@ export class FormStore {
|
|
|
36
37
|
const r = (await (await this.driver()).all('SELECT * FROM form_submissions WHERE id=? AND form_id=?', [id, formId]))[0];
|
|
37
38
|
return r ? submission(r) : null;
|
|
38
39
|
}
|
|
39
|
-
/** the response this request id already saved to this form, if any (as stored JSON) */
|
|
40
|
+
/** the response this request id already saved to this form, if any (as stored JSON; compare it with sameResponse) */
|
|
40
41
|
async receipt(formId: string, requestId: string) {
|
|
41
42
|
const r = (await (await this.driver()).all('SELECT response FROM form_submissions WHERE id=? AND form_id=?', [requestId, formId]))[0];
|
|
42
43
|
return r ? String(r.response) : null;
|
|
@@ -51,11 +52,20 @@ export class FormStore {
|
|
|
51
52
|
const db = await this.driver();
|
|
52
53
|
// The receipt is also the unique request identity, so retries don't duplicate responses.
|
|
53
54
|
const old = (await db.all('SELECT form_id,response FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
54
|
-
if (old && (old.form_id !== formId || String(old.response)
|
|
55
|
+
if (old && (old.form_id !== formId || !sameResponse(JSON.parse(String(old.response)), response))) throw refuse(409, 'resent');
|
|
55
56
|
const at = Date.now();
|
|
56
|
-
if (!old)
|
|
57
|
+
if (!old) {
|
|
58
|
+
// The form must still take responses and be the one they answered,
|
|
59
|
+
// compared by meaning: a definition written straight into the database
|
|
60
|
+
// or imported may order its keys differently. The insert then checks
|
|
61
|
+
// the stored text it just read, so a publish or archive in between
|
|
62
|
+
// still refuses it (a draft save leaves the live definition alone).
|
|
63
|
+
const live = (await db.all("SELECT definition FROM forms WHERE id=? AND status='published' AND kind='form' AND purpose='general'", [formId]))[0];
|
|
64
|
+
if (!live || !sameDefinition(JSON.parse(String(live.definition)), response.definition)) throw refuse(409, 'changedAtSave');
|
|
65
|
+
await db.batch([{ sql: '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=?)', args: [requestId,formId,JSON.stringify(response),at,formId,String(live.definition)] }, ...(also?.(at) ?? [])]);
|
|
66
|
+
}
|
|
57
67
|
const saved = (await db.all('SELECT form_id,response,created_at FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
58
|
-
if (!saved || saved.form_id !== formId || String(saved.response)
|
|
68
|
+
if (!saved || saved.form_id !== formId || !sameResponse(JSON.parse(String(saved.response)), response)) throw refuse(409, 'changedAtSave');
|
|
59
69
|
return !old && Number(saved.created_at) === at;
|
|
60
70
|
}
|
|
61
71
|
// Archived → draft (the definition is kept; publishing reopens it).
|
|
@@ -2,26 +2,32 @@
|
|
|
2
2
|
// readAnswers) and the builder's preview. No server imports here.
|
|
3
3
|
import { answerFields, hasOther, type FormDefinition, type FormField } from './schema';
|
|
4
4
|
import { mainChoice } from './localize';
|
|
5
|
+
import { formErrorText, type FormErrorReason } from './words';
|
|
5
6
|
|
|
6
7
|
/** Value of the "Other" radio/checkbox; its text arrives as `o_<field id>`. */
|
|
7
8
|
export const OTHER = '__other__';
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/** An answer refused: why, and the question. The message is the English one; pages and apps word it in the visitor's language (service.ts formError). */
|
|
10
|
+
export class AnswerError extends Error {
|
|
11
|
+
constructor(public reason: Extract<FormErrorReason, 'required' | 'invalid' | 'tooLong' | 'badAnswer'>, public field: Pick<FormField, 'id' | 'label'>) {
|
|
12
|
+
super(formErrorText(reason, field.label));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const invalid = (f: FormField) => new AnswerError('invalid', f);
|
|
10
16
|
|
|
11
17
|
/** Reads posted answers (`q_<id>`, plus `o_<id>` for "Other" text). Checkbox groups may post several values; they are stored newline-joined. */
|
|
12
18
|
export function answersFrom(getAll: (name: string) => unknown[], definition: Pick<FormDefinition, 'fields'>): Record<string, string> {
|
|
13
19
|
const answers: Record<string, string> = Object.create(null);
|
|
14
20
|
for (const f of answerFields(definition)) {
|
|
15
21
|
const values = getAll(`q_${f.id}`);
|
|
16
|
-
if (values.some(v => typeof v !== 'string')) throw new AnswerError('
|
|
17
|
-
const other = () => { const o = getAll(`o_${f.id}`); if (o.length > 1 || o.some(v => typeof v !== 'string')) throw new AnswerError('
|
|
22
|
+
if (values.some(v => typeof v !== 'string')) throw new AnswerError('badAnswer', f);
|
|
23
|
+
const other = () => { const o = getAll(`o_${f.id}`); if (o.length > 1 || o.some(v => typeof v !== 'string')) throw new AnswerError('badAnswer', f); return String(o[0] ?? '').trim(); };
|
|
18
24
|
if (f.type === 'checkboxes') {
|
|
19
|
-
if (values.length > f.options.length + 1) throw new AnswerError('
|
|
25
|
+
if (values.length > f.options.length + 1) throw new AnswerError('badAnswer', f);
|
|
20
26
|
let list = values as string[];
|
|
21
27
|
if (hasOther(f) && list.includes(OTHER)) { const text = other(); list = list.filter(v => v !== OTHER); if (text) list.push(text); }
|
|
22
28
|
answers[f.id] = list.join('\n');
|
|
23
29
|
} else {
|
|
24
|
-
if (values.length > 1) throw new AnswerError('
|
|
30
|
+
if (values.length > 1) throw new AnswerError('badAnswer', f);
|
|
25
31
|
const value = String(values[0] ?? '');
|
|
26
32
|
answers[f.id] = f.type === 'radio' && hasOther(f) && value === OTHER ? other() : value;
|
|
27
33
|
}
|
|
@@ -33,24 +39,24 @@ export function answersFrom(getAll: (name: string) => unknown[], definition: Pic
|
|
|
33
39
|
const TIME = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* Validates one answer and returns its stored form. Throws AnswerError
|
|
37
|
-
*
|
|
42
|
+
* Validates one answer and returns its stored form. Throws AnswerError (why,
|
|
43
|
+
* and the question). A choice may come in any of the form's languages;
|
|
38
44
|
* it's stored as the main-language choice.
|
|
39
45
|
*/
|
|
40
46
|
export function checkAnswer(f: FormField, raw: unknown): string {
|
|
41
|
-
if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError(
|
|
47
|
+
if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError('tooLong', f);
|
|
42
48
|
if (f.type === 'heading') return '';
|
|
43
49
|
if (f.type === 'checkboxes') {
|
|
44
50
|
const items = raw.split('\n').map(s => s.trim()).filter(Boolean).map(s => mainChoice(f, s) ?? s);
|
|
45
51
|
const extra = items.filter(i => !f.options.includes(i));
|
|
46
|
-
if (f.required && !items.length) throw new AnswerError(
|
|
52
|
+
if (f.required && !items.length) throw new AnswerError('required', f);
|
|
47
53
|
if (new Set(items).size !== items.length || extra.length > (hasOther(f) ? 1 : 0) || extra.some(e => e.length > 200)) throw invalid(f);
|
|
48
54
|
// Store in the form's choice order, with "Other" text last.
|
|
49
55
|
return [...f.options.filter(o => items.includes(o)), ...extra].join('\n');
|
|
50
56
|
}
|
|
51
57
|
let value = raw.trim();
|
|
52
58
|
const agree = f.type === 'checkbox' || f.type === 'consent';
|
|
53
|
-
if (f.required && (!value || (agree && value !== 'yes'))) throw new AnswerError(
|
|
59
|
+
if (f.required && (!value || (agree && value !== 'yes'))) throw new AnswerError('required', f);
|
|
54
60
|
if (!value) return value;
|
|
55
61
|
let valid = true;
|
|
56
62
|
if (f.type === 'email') valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
package/src/lib/forms/words.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// The public form's own words, around the ones its author wrote: the button
|
|
2
2
|
// and the thank-you when the form has none, the dropdown's empty choice,
|
|
3
|
-
// "Other" with its text box,
|
|
4
|
-
// language (/forms/<id> and /es/forms/<id>, an
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// "Other" with its text box, "Send another response", and why it refused
|
|
4
|
+
// what was sent. In the page's language (/forms/<id> and /es/forms/<id>, an
|
|
5
|
+
// event's RSVP questions) or the app's (?locale=); the author's own words
|
|
6
|
+
// come from the form and its translations. {label}: the question
|
|
7
|
+
// (live/words.ts `fill`). Other languages fall back to English.
|
|
7
8
|
import { DEFAULT_THANKS } from './schema';
|
|
9
|
+
import { fill } from '../live/words';
|
|
8
10
|
|
|
9
11
|
export interface FormStrings {
|
|
10
12
|
submit: string;
|
|
@@ -25,8 +27,64 @@ const STRINGS: Record<string, FormStrings> = {
|
|
|
25
27
|
pt: { submit: 'Enviar', thanks: 'Obrigado. Recebemos sua resposta.', choose: 'Escolha…', other: 'Outro', typeAnswer: 'Digite sua resposta', otherAnswer: 'Outra resposta para {label}', sendAnother: 'Enviar outra resposta' },
|
|
26
28
|
};
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
const base = (locale: string | null | undefined) => (locale || 'en').toLowerCase().split('-')[0];
|
|
31
|
+
export const formStrings = (locale: string | null | undefined): FormStrings => STRINGS[base(locale)] ?? STRINGS.en;
|
|
30
32
|
|
|
31
33
|
/** The form's thank-you, or the page's own words when it has none (or still has the default one). */
|
|
32
34
|
export const thanksText = (confirmation: string, w: FormStrings) => (confirmation.trim() && confirmation !== DEFAULT_THANKS ? confirmation : w.thanks);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Why a form refused what was sent: a question's answer (required, invalid,
|
|
38
|
+
* tooLong, badAnswer; validate.ts) or the whole submission (service.ts,
|
|
39
|
+
* http.ts, store.ts and the app API). The English words are the messages
|
|
40
|
+
* pages and apps always got.
|
|
41
|
+
*/
|
|
42
|
+
export type FormErrorReason = 'required' | 'invalid' | 'tooLong' | 'badAnswer' | 'tooManyAnswers' | 'changed' | 'changedAtSave' | 'resent'
|
|
43
|
+
| 'tooLarge' | 'unreadable' | 'otherSite' | 'spam' | 'tooFast' | 'reload' | 'saveFailed' | 'notFound' | 'closed' | 'appTooFast' | 'dayFull';
|
|
44
|
+
|
|
45
|
+
const ERRORS: Record<string, Record<FormErrorReason, string>> = {
|
|
46
|
+
en: {
|
|
47
|
+
required: 'Please answer “{label}”.', invalid: 'Please enter a valid answer for “{label}”.', tooLong: '“{label}” is too long.', badAnswer: 'Invalid form answer.',
|
|
48
|
+
tooManyAnswers: 'Too many answers.', changed: 'This form has changed. Review the updated questions and submit again.', changedAtSave: 'This form changed. Reload and try again.',
|
|
49
|
+
resent: 'This request was already submitted with different answers. Reload to send a new response.', tooLarge: 'This form is too large.', unreadable: 'Could not read this form.',
|
|
50
|
+
otherSite: 'Please submit from this site.', spam: 'Could not submit this form.', tooFast: 'Too many submissions. Please try again in a minute.', reload: 'Reload the form and try again.',
|
|
51
|
+
saveFailed: 'Could not save your response. Please try again.', notFound: 'Form not found.', closed: 'This form is no longer accepting responses.',
|
|
52
|
+
appTooFast: 'Too many responses. Please try again in a minute.', dayFull: 'This form has had a lot of responses today. Please try again tomorrow.',
|
|
53
|
+
},
|
|
54
|
+
es: {
|
|
55
|
+
required: 'Responde “{label}”.', invalid: 'Escribe una respuesta válida para “{label}”.', tooLong: 'Tu respuesta a “{label}” es demasiado larga.', badAnswer: 'Una de las respuestas no es válida.',
|
|
56
|
+
tooManyAnswers: 'Hay demasiadas respuestas.', changed: 'Este formulario cambió. Revisa las preguntas actualizadas y vuelve a enviarlo.', changedAtSave: 'Este formulario cambió. Vuelve a cargarlo e inténtalo de nuevo.',
|
|
57
|
+
resent: 'Esta solicitud ya se envió con otras respuestas. Vuelve a cargar la página para enviar una respuesta nueva.', tooLarge: 'Este formulario es demasiado grande.', unreadable: 'No pudimos leer este formulario.',
|
|
58
|
+
otherSite: 'Envía el formulario desde este sitio.', spam: 'No se pudo enviar este formulario.', tooFast: 'Demasiados envíos. Vuelve a intentarlo en un minuto.', reload: 'Vuelve a cargar el formulario e inténtalo de nuevo.',
|
|
59
|
+
saveFailed: 'No pudimos guardar tu respuesta. Inténtalo de nuevo.', notFound: 'No encontramos este formulario.', closed: 'Este formulario ya no acepta respuestas.',
|
|
60
|
+
appTooFast: 'Demasiadas respuestas. Vuelve a intentarlo en un minuto.', dayFull: 'Este formulario recibió muchas respuestas hoy. Vuelve a intentarlo mañana.',
|
|
61
|
+
},
|
|
62
|
+
fr: {
|
|
63
|
+
required: 'Veuillez répondre à « {label} ».', invalid: 'Veuillez saisir une réponse valide pour « {label} ».', tooLong: 'Votre réponse à « {label} » est trop longue.', badAnswer: 'Une des réponses n’est pas valide.',
|
|
64
|
+
tooManyAnswers: 'Trop de réponses.', changed: 'Ce formulaire a changé. Vérifiez les questions mises à jour et renvoyez-le.', changedAtSave: 'Ce formulaire a changé. Rechargez-le et réessayez.',
|
|
65
|
+
resent: 'Cette demande a déjà été envoyée avec d’autres réponses. Rechargez la page pour envoyer une nouvelle réponse.', tooLarge: 'Ce formulaire est trop volumineux.', unreadable: 'Impossible de lire ce formulaire.',
|
|
66
|
+
otherSite: 'Veuillez envoyer le formulaire depuis ce site.', spam: 'Impossible d’envoyer ce formulaire.', tooFast: 'Trop d’envois. Réessayez dans une minute.', reload: 'Rechargez le formulaire et réessayez.',
|
|
67
|
+
saveFailed: 'Impossible d’enregistrer votre réponse. Veuillez réessayer.', notFound: 'Formulaire introuvable.', closed: 'Ce formulaire n’accepte plus de réponses.',
|
|
68
|
+
appTooFast: 'Trop de réponses. Réessayez dans une minute.', dayFull: 'Ce formulaire a reçu beaucoup de réponses aujourd’hui. Réessayez demain.',
|
|
69
|
+
},
|
|
70
|
+
de: {
|
|
71
|
+
required: 'Bitte beantworten Sie „{label}“.', invalid: 'Bitte geben Sie eine gültige Antwort für „{label}“ ein.', tooLong: 'Ihre Antwort auf „{label}“ ist zu lang.', badAnswer: 'Eine der Antworten ist ungültig.',
|
|
72
|
+
tooManyAnswers: 'Zu viele Antworten.', changed: 'Dieses Formular wurde geändert. Prüfen Sie die aktualisierten Fragen und senden Sie es erneut.', changedAtSave: 'Dieses Formular wurde geändert. Laden Sie es neu und versuchen Sie es noch einmal.',
|
|
73
|
+
resent: 'Diese Anfrage wurde bereits mit anderen Antworten gesendet. Laden Sie die Seite neu, um eine neue Antwort zu senden.', tooLarge: 'Dieses Formular ist zu groß.', unreadable: 'Dieses Formular konnte nicht gelesen werden.',
|
|
74
|
+
otherSite: 'Bitte senden Sie das Formular über diese Website.', spam: 'Dieses Formular konnte nicht gesendet werden.', tooFast: 'Zu viele Einsendungen. Bitte versuchen Sie es in einer Minute erneut.', reload: 'Laden Sie das Formular neu und versuchen Sie es noch einmal.',
|
|
75
|
+
saveFailed: 'Ihre Antwort konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.', notFound: 'Formular nicht gefunden.', closed: 'Dieses Formular nimmt keine Antworten mehr an.',
|
|
76
|
+
appTooFast: 'Zu viele Antworten. Bitte versuchen Sie es in einer Minute erneut.', dayFull: 'Dieses Formular hat heute sehr viele Antworten erhalten. Bitte versuchen Sie es morgen erneut.',
|
|
77
|
+
},
|
|
78
|
+
pt: {
|
|
79
|
+
required: 'Responda “{label}”.', invalid: 'Digite uma resposta válida para “{label}”.', tooLong: 'Sua resposta a “{label}” é longa demais.', badAnswer: 'Uma das respostas não é válida.',
|
|
80
|
+
tooManyAnswers: 'Há respostas demais.', changed: 'Este formulário mudou. Revise as perguntas atualizadas e envie novamente.', changedAtSave: 'Este formulário mudou. Recarregue e tente novamente.',
|
|
81
|
+
resent: 'Este pedido já foi enviado com outras respostas. Recarregue a página para enviar uma nova resposta.', tooLarge: 'Este formulário é grande demais.', unreadable: 'Não foi possível ler este formulário.',
|
|
82
|
+
otherSite: 'Envie o formulário por este site.', spam: 'Não foi possível enviar este formulário.', tooFast: 'Envios demais. Tente novamente em um minuto.', reload: 'Recarregue o formulário e tente novamente.',
|
|
83
|
+
saveFailed: 'Não foi possível salvar sua resposta. Tente novamente.', notFound: 'Formulário não encontrado.', closed: 'Este formulário não aceita mais respostas.',
|
|
84
|
+
appTooFast: 'Respostas demais. Tente novamente em um minuto.', dayFull: 'Este formulário recebeu muitas respostas hoje. Tente novamente amanhã.',
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A refusal in `locale` (English when it's none of these); label: the question as the person read it. */
|
|
89
|
+
export const formErrorText = (reason: FormErrorReason, label = '', locale?: string | null) =>
|
|
90
|
+
fill((ERRORS[base(locale)] ?? ERRORS.en)[reason], { label });
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import type { Booking } from '../schema';
|
|
4
|
+
import type { FormDefinition } from '../forms/schema';
|
|
5
|
+
import { formError } from '../forms/service';
|
|
6
|
+
import { siteLang } from '../dates';
|
|
4
7
|
import { ApiError, readJson } from '../api';
|
|
5
8
|
import { sha256Hex } from '../api-keys';
|
|
6
9
|
import { siteOrigin } from '../seo';
|
|
@@ -55,6 +58,8 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
55
58
|
};
|
|
56
59
|
const existing = await replay();
|
|
57
60
|
if (existing) return existing;
|
|
61
|
+
// an RSVP's questions: their refusals name them as the app showed them, in its language
|
|
62
|
+
let definition: FormDefinition | undefined;
|
|
58
63
|
try {
|
|
59
64
|
const settings = await repo.getSettings();
|
|
60
65
|
const flow = { origin: siteOrigin(settings, new URL(request.url)), locals, waitUntil: waitUntilFrom(locals), idempotency };
|
|
@@ -64,6 +69,7 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
64
69
|
if ('event' in body) {
|
|
65
70
|
const occurrence = await rsvpOccurrence(repo, body.event, body.start);
|
|
66
71
|
const { event } = occurrence;
|
|
72
|
+
definition = event.rsvp.form;
|
|
67
73
|
if (body.seats > (event.rsvp.maxSeats || 1)) throw new ApiError(400, 'Too many seats for one RSVP.');
|
|
68
74
|
// the app must have seen the current questions: a changed form then answers 409, as on the website
|
|
69
75
|
// (closed dates fall through to createRsvp's own answer)
|
|
@@ -82,6 +88,6 @@ export async function submitBooking(repo: ContentRepository, request: Request, l
|
|
|
82
88
|
// or delivery may have failed after commit. The ledger is authoritative.
|
|
83
89
|
const existing = await replay();
|
|
84
90
|
if (existing) return existing;
|
|
85
|
-
throw error;
|
|
91
|
+
throw formError(error, locale || siteLang(await repo.getSettings()), definition);
|
|
86
92
|
}
|
|
87
93
|
}
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type
|
|
4
|
-
import { collectResponse, formVersion } from '../forms/service';
|
|
3
|
+
import { sameDefinition, sameResponse, type FormDefinition, type SavedForm } from '../forms/schema';
|
|
4
|
+
import { collectResponse, formError, formVersion, refuse } from '../forms/service';
|
|
5
5
|
import { saveResponse } from '../forms/alerts';
|
|
6
6
|
import { localizeForm } from '../forms/localize';
|
|
7
7
|
import { formStrings, thanksText } from '../forms/words';
|
|
@@ -20,15 +20,17 @@ import type { PublicContext } from './route';
|
|
|
20
20
|
// the web form (/forms/<id>), plus app limits: an Idempotency-Key per
|
|
21
21
|
// response (retries never duplicate), 10 responses per address per form per
|
|
22
22
|
// minute, and a per-form daily ceiling from Admin → Mobile app so one form
|
|
23
|
-
// can't be flooded.
|
|
23
|
+
// can't be flooded. Refusals a person reads come in the request's language
|
|
24
|
+
// (?locale=, else the site's main one), naming a question as the app showed
|
|
25
|
+
// it; their codes and statuses are the same in every language.
|
|
24
26
|
|
|
25
27
|
const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
28
|
|
|
27
29
|
async function publishedForm(repo: ContentRepository, id: string): Promise<SavedForm> {
|
|
28
30
|
// ids are UUIDs; anything else can't be a form
|
|
29
31
|
const form = id && id.length <= 100 ? await repo.forms.get(id) : null;
|
|
30
|
-
if (!form || form.kind !== 'form' || form.purpose !== 'general' || form.status === 'draft') throw
|
|
31
|
-
if (form.status === 'archived') throw
|
|
32
|
+
if (!form || form.kind !== 'form' || form.purpose !== 'general' || form.status === 'draft') throw refuse(404, 'notFound');
|
|
33
|
+
if (form.status === 'archived') throw refuse(410, 'closed');
|
|
32
34
|
return form;
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -64,8 +66,9 @@ async function ownWords(repo: ContentRepository, locale: string | null) {
|
|
|
64
66
|
|
|
65
67
|
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
66
68
|
const locale = formLocale(url, true);
|
|
67
|
-
await requireAccess(repo, 'forms');
|
|
68
|
-
|
|
69
|
+
const settings = await requireAccess(repo, 'forms');
|
|
70
|
+
// "not found" and "no longer accepting responses" in the request's language too
|
|
71
|
+
const form = await publishedForm(repo, id).catch((e) => { throw formError(e, locale ?? siteLang(settings)); });
|
|
69
72
|
const json = await formJson(form.definition, locale);
|
|
70
73
|
const w = await ownWords(repo, locale);
|
|
71
74
|
return { id: form.id, ...json, title: json.title || form.name, submitLabel: json.submitLabel || w.submit, confirmation: thanksText(json.confirmation, w) };
|
|
@@ -92,26 +95,29 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
92
95
|
const key = c.request.headers.get('idempotency-key') ?? '';
|
|
93
96
|
if (!UUID4.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
|
|
94
97
|
const body = formResponseInput.parse(await readJson(c.request, 50_000));
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
let definition: FormDefinition | undefined;
|
|
99
|
+
try {
|
|
100
|
+
if (body.website) throw refuse(400, 'spam');
|
|
101
|
+
const form = await publishedForm(c.repo, id);
|
|
102
|
+
definition = form.definition;
|
|
103
|
+
if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw refuse(429, 'appTooFast');
|
|
104
|
+
const answers = joinAnswers(body.answers);
|
|
105
|
+
// the key is the stored response id: a retry of a saved response answers
|
|
106
|
+
// as before and costs nothing (also after the form changed); the same key
|
|
107
|
+
// with different answers is a 409
|
|
108
|
+
const prior = await c.repo.forms.receipt(form.id, key.toLowerCase());
|
|
109
|
+
if (prior) {
|
|
110
|
+
const stored = JSON.parse(prior) as { definition: unknown };
|
|
111
|
+
if (sameDefinition(stored.definition, form.definition) && !sameResponse(await collectResponse(form.definition, answers, body.version), stored)) throw refuse(409, 'resent');
|
|
112
|
+
return receipt(c.repo, form, locale);
|
|
113
|
+
}
|
|
114
|
+
const response = await collectResponse(form.definition, answers, body.version);
|
|
115
|
+
// only valid new responses count toward the daily ceiling
|
|
116
|
+
const cap = settings.mobileAccess.responsesPerFormDay;
|
|
117
|
+
if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw refuse(429, 'dayFull', undefined, 86_400);
|
|
118
|
+
await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
|
|
107
119
|
return receipt(c.repo, form, locale);
|
|
108
|
-
}
|
|
109
|
-
const response = await collectResponse(form.definition, answers, body.version);
|
|
110
|
-
// only valid new responses count toward the daily ceiling
|
|
111
|
-
const cap = settings.mobileAccess.responsesPerFormDay;
|
|
112
|
-
if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw new ApiError(429, 'This form has had a lot of responses today. Please try again tomorrow.', 86_400);
|
|
113
|
-
await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
|
|
114
|
-
return receipt(c.repo, form, locale);
|
|
120
|
+
} catch (e) { throw formError(e, locale ?? siteLang(settings), definition); }
|
|
115
121
|
}
|
|
116
122
|
// the thank-you in the app's language (?locale=), as the web form shows it
|
|
117
123
|
async function receipt(repo: ContentRepository, form: SavedForm, locale: string | null) {
|
|
@@ -63,7 +63,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
63
63
|
['/announcements/{id}', 'One announcement that is up, in the requested language; requires announcements', localeOnlyQuery, ref('AnnouncementDetail')],
|
|
64
64
|
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
65
65
|
['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
|
|
66
|
-
['/forms/{id}', 'A published form’s questions, in the requested language (locale: its labels, help, choices, button and thank-you there; words it has no translation for stay in the main language; version is the same in every language); 410 when it stopped accepting responses (owner switch: forms)', localeOnlyQuery, ref('Form')],
|
|
66
|
+
['/forms/{id}', 'A published form’s questions, in the requested language (locale: its labels, help, choices, button and thank-you there; words it has no translation for stay in the main language; version is the same in every language); 410 when it stopped accepting responses (the 404 and 410 messages in the requested language too; owner switch: forms)', localeOnlyQuery, ref('Form')],
|
|
67
67
|
['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
|
|
68
68
|
];
|
|
69
69
|
for (const [path, summary, query, schema, cache = true] of reads) {
|
|
@@ -82,7 +82,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
82
82
|
paths['/api/v1/bookings'] = {
|
|
83
83
|
post: {
|
|
84
84
|
summary: 'Create a guest booking or an RSVP, or replay an existing request; RSVPs require calendar, room bookings also bookings',
|
|
85
|
-
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. An RSVP from an email that already has one for that date (a place on its waitlist too) answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410. A full date answers 409, unless the event keeps a waitlist (RsvpStatus.waitlist.enabled) and the RSVP sends waitlist: true: then it takes a place in line (status waitlisted, no seats) and is emailed when a spot opens.',
|
|
85
|
+
description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent. Checkbox groups send a list of answers. An RSVP to an event with questions needs formVersion from GET /events/{slug}/rsvp; a changed form answers 409. Refused answers (a required question, an invalid answer, a changed form) carry their message in the body’s locale, naming the question as GET /events/{slug}/rsvp showed it; codes and statuses don’t change. An RSVP from an email that already has one for that date (a place on its waitlist too) answers 409 with code duplicate (and emails that RSVP’s link again); past the event’s RSVP deadline, 410. A full date answers 409, unless the event keeps a waitlist (RsvpStatus.waitlist.enabled) and the RSVP sends waitlist: true: then it takes a place in line (status waitlisted, no seats) and is emailed when a spot opens.',
|
|
86
86
|
parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
|
|
87
87
|
requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
|
|
88
88
|
responses: { ...errors,
|
|
@@ -116,7 +116,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
116
116
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
117
117
|
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
118
118
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
119
|
-
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation in that language
|
|
119
|
+
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation, and the message of a refusal a person reads (a missing or invalid answer naming the question as shown in that language, a changed form, the limits), in that language; one the site doesn’t have gets the main language’s. Error codes and statuses are the same in every language. Leave website empty (spam trap).' }),
|
|
120
120
|
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'locale', in: 'query', required: false, schema: { type: 'string', minLength: 1, maxLength: 35 } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
121
121
|
const requestNote = 'Requires acms({ requests }) (site.features.requests); 404 when off. No account.';
|
|
122
122
|
paths['/api/v1/requests/options'] = { get: priv('What the event request form offers: open or closed, the types, requestable rooms and changeable events (with their dates), the site’s reply time and notice (in the requested language) and its phone', ref('RequestOptions'), { query: requestOptionsQuery,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { csv } from '../../../lib/forms/service';
|
|
2
|
+
import { csv, staffAnswers } from '../../../lib/forms/service';
|
|
3
3
|
import { readForm } from '../../../lib/forms/http';
|
|
4
4
|
import Admin from '../../../layouts/Admin.astro';
|
|
5
5
|
import BookingsHead from '../../../components/calendar/BookingsHead.astro';
|
|
@@ -16,6 +16,7 @@ import { emailReadiness } from '../../../lib/email/readiness';
|
|
|
16
16
|
import { siteOrigin } from '../../../lib/seo';
|
|
17
17
|
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
18
18
|
import { forbiddenUnless } from '../../../lib/permissions/access';
|
|
19
|
+
import { siteLocale } from '../../../lib/dates';
|
|
19
20
|
|
|
20
21
|
// Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
|
|
21
22
|
// requests first, a Day view with one column per resource, a details panel
|
|
@@ -366,7 +367,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
366
367
|
{b.seats > 1 && <div><dt>Seats</dt><dd>{b.seats}</dd></div>}
|
|
367
368
|
{b.status === 'waitlisted' && <div><dt>Waitlist</dt><dd>#{placeOf(b)} in line</dd></div>}
|
|
368
369
|
{b.status === 'offered' && <div><dt>Spot held</dt><dd>Until {heldUntil(b)} · they confirm it from their email</dd></div>}
|
|
369
|
-
{Object.entries(b
|
|
370
|
+
{Object.entries(staffAnswers(b, siteLocale(settings))).map(([k, v]) => <div><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
370
371
|
<div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {SOURCES[b.source] ?? b.source}</dd></div>
|
|
371
372
|
</dl>
|
|
372
373
|
<form method="post" class="bk-notes">
|
|
@@ -9,6 +9,7 @@ import { answerFields, relativeTime, versionLabeler, wideRows } from '../../../.
|
|
|
9
9
|
import type { FormSubmission } from '../../../../lib/forms/schema';
|
|
10
10
|
import '../../../../styles/forms-admin.css';
|
|
11
11
|
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
12
|
+
import { siteLocale } from '../../../../lib/dates';
|
|
12
13
|
|
|
13
14
|
const repo = await getRepo(Astro.locals);
|
|
14
15
|
{ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'forms.responses'); if (denied) return denied; }
|
|
@@ -67,9 +68,11 @@ const shortWhen = (ts: number) => {
|
|
|
67
68
|
return fmt(ts, { month: 'short', day: 'numeric', ...(new Date(ts).getFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {}) });
|
|
68
69
|
};
|
|
69
70
|
// displayAnswers owns per-type formatting; call it on subsets of the snapshot.
|
|
71
|
+
// Times read as the site writes them ("6:30 PM"); the CSV keeps them as stored.
|
|
72
|
+
const locale = siteLocale(settings);
|
|
70
73
|
const show = (r: FormSubmission, keep: (id: string) => boolean) => {
|
|
71
74
|
const fields = answerFields(r.response.definition.fields).filter(f => keep(f.id));
|
|
72
|
-
return fields.length ? Object.entries(displayAnswers({ ...r.response, definition: { ...r.response.definition, fields } })) : [];
|
|
75
|
+
return fields.length ? Object.entries(displayAnswers({ ...r.response, definition: { ...r.response.definition, fields } }, locale)) : [];
|
|
73
76
|
};
|
|
74
77
|
const data: ResponseRow[] = rows.slice(0, 100).map(r => ({
|
|
75
78
|
id: r.id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { ApiError } from '../../lib/api';
|
|
3
3
|
import { starter } from '../../lib/forms/schema';
|
|
4
|
-
import { formVersion } from '../../lib/forms/service';
|
|
4
|
+
import { formError, formVersion } from '../../lib/forms/service';
|
|
5
5
|
import { renderFields, hiddenFields } from '../../lib/forms/render';
|
|
6
6
|
import { localizeForm } from '../../lib/forms/localize';
|
|
7
7
|
import { formStrings } from '../../lib/forms/words';
|
|
@@ -50,7 +50,9 @@ const occurrence = featuredOccurrence(event, settings, now, on && /^\d{4}-\d{2}-
|
|
|
50
50
|
if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
51
51
|
const origin = siteOrigin(settings, Astro.url);
|
|
52
52
|
// RSVP: "Save my seat" posts back here and lands on the manage page, in the
|
|
53
|
-
// visitor's language (its emails speak it too
|
|
53
|
+
// visitor's language (its emails speak it too, and the form's errors name its
|
|
54
|
+
// questions as the page shows them)
|
|
55
|
+
const lang = (I18N.enabled ? locale : '') || siteLang(settings);
|
|
54
56
|
let rsvpError = '';
|
|
55
57
|
let rsvpDuplicate = false;
|
|
56
58
|
let formValues: Record<string,string> = {};
|
|
@@ -69,15 +71,15 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
|
69
71
|
const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) }, locale);
|
|
70
72
|
return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
|
|
71
73
|
} catch (err) {
|
|
72
|
-
const w = rsvpStrings(
|
|
74
|
+
const w = rsvpStrings(lang);
|
|
73
75
|
// the address already has one: a notice in the page's words (templates that don't know it show it as the error)
|
|
74
76
|
rsvpDuplicate = err instanceof BookingError && err.code === 'duplicate';
|
|
75
|
-
rsvpError = rsvpDuplicate ? `${w.duplicate} ${w.duplicateNote}` : err instanceof BookingError || err instanceof ApiError ? err.message : w.saveError;
|
|
77
|
+
rsvpError = rsvpDuplicate ? `${w.duplicate} ${w.duplicateNote}` : err instanceof BookingError || err instanceof ApiError ? formError(err, lang, definition).message : w.saveError;
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
|
|
79
81
|
// the questions in the page's language (main-language words where the form has none)
|
|
80
|
-
const fieldsHtml = renderFields(localizeForm(definition, isExtraLocale(locale) ? locale : ''), formValues, 'event-registration', formStrings(
|
|
82
|
+
const fieldsHtml = renderFields(localizeForm(definition, isExtraLocale(locale) ? locale : ''), formValues, 'event-registration', formStrings(lang));
|
|
81
83
|
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
82
84
|
// the event's RSVP deadline for this date (null: open until it ends)
|
|
83
85
|
const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { renderForm } from '../../lib/forms/render';
|
|
5
|
-
import { collectResponse } from '../../lib/forms/service';
|
|
5
|
+
import { collectResponse, formError } from '../../lib/forms/service';
|
|
6
6
|
import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
|
|
7
7
|
import { saveResponse } from '../../lib/forms/alerts';
|
|
8
8
|
import { localizeForm } from '../../lib/forms/localize';
|
|
9
|
-
import { formStrings, thanksText } from '../../lib/forms/words';
|
|
9
|
+
import { formErrorText, formStrings, thanksText } from '../../lib/forms/words';
|
|
10
|
+
import { sameDefinition } from '../../lib/forms/schema';
|
|
10
11
|
import { ApiError } from '../../lib/api';
|
|
11
12
|
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
12
13
|
import { isExtraLocale } from '../../lib/translations';
|
|
@@ -17,7 +18,8 @@ if (!form || form.status !== 'published' || form.kind !== 'form' || form.purpose
|
|
|
17
18
|
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
18
19
|
const embed = Astro.url.searchParams.has('embed');
|
|
19
20
|
// /es/forms/<id>: the form's Spanish words (main-language ones where it has
|
|
20
|
-
// none) and the page's own words in Spanish
|
|
21
|
+
// none) and the page's own words in Spanish, its errors too (naming the
|
|
22
|
+
// question as the page shows it); answers are stored the same
|
|
21
23
|
const locale = requestLocale(Astro.url, Astro.locals);
|
|
22
24
|
const formLocale = isExtraLocale(locale) ? locale : '';
|
|
23
25
|
const lang = (I18N.enabled ? locale : '') || siteLang(await repo.getSettings());
|
|
@@ -33,12 +35,12 @@ if (Astro.request.method === 'POST') {
|
|
|
33
35
|
// a resubmit of a saved response (refresh, double click) thanks them again,
|
|
34
36
|
// also after the form changed since; the same id with other answers is a 409
|
|
35
37
|
const prior = await repo.forms.receipt(form.id, requestId);
|
|
36
|
-
if (!prior ||
|
|
38
|
+
if (!prior || sameDefinition((JSON.parse(prior) as { definition: unknown }).definition, form.definition)) {
|
|
37
39
|
const response = await collectResponse(form.definition, values, prior ? undefined : String(data.get('formVersion') || ''));
|
|
38
40
|
await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
|
|
39
41
|
}
|
|
40
42
|
success = true;
|
|
41
|
-
} catch (e) { error = e instanceof ApiError ? e.message : '
|
|
43
|
+
} catch (e) { error = e instanceof ApiError ? formError(e, lang, form.definition).message : formErrorText('saveFailed', '', lang); Astro.response.status = e instanceof ApiError ? e.status : 500; }
|
|
42
44
|
}
|
|
43
45
|
const w = formStrings(lang);
|
|
44
46
|
const html = success ? '' : await renderForm(form.id, form.definition, values, error, requestId, embed, { locale: formLocale, lang, action: path });
|