@acms-subir/core 0.3.52 → 0.3.54

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +18 -1
  3. package/docs/calendar.md +20 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/server.md +61 -1
  6. package/integration.mjs +11 -5
  7. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  8. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  9. package/package.json +1 -1
  10. package/src/components/ThemeSections.astro +2 -2
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/lib/api.ts +3 -0
  22. package/src/lib/backup/content.ts +4 -2
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/calendar/host-requests.ts +247 -0
  25. package/src/lib/calendar/member-form.ts +17 -11
  26. package/src/lib/calendar/member-words.ts +473 -0
  27. package/src/lib/calendar/members-config.ts +2 -2
  28. package/src/lib/calendar/moves.ts +81 -33
  29. package/src/lib/calendar/request-migration.ts +17 -0
  30. package/src/lib/calendar/request-store.ts +104 -0
  31. package/src/lib/calendar/requests.ts +24 -24
  32. package/src/lib/dynamicBlocks.ts +4 -2
  33. package/src/lib/email/service.ts +12 -2
  34. package/src/lib/email/templates.ts +12 -2
  35. package/src/lib/forms/fields.ts +14 -9
  36. package/src/lib/forms/localize.ts +68 -0
  37. package/src/lib/forms/render.ts +8 -2
  38. package/src/lib/forms/schema.ts +31 -2
  39. package/src/lib/forms/service.ts +9 -1
  40. package/src/lib/forms/validate.ts +16 -4
  41. package/src/lib/forms/words.ts +32 -0
  42. package/src/lib/mobile/forms.ts +45 -13
  43. package/src/lib/mobile/openapi.ts +3 -3
  44. package/src/lib/mobile/rsvp.ts +4 -2
  45. package/src/lib/mobile/schemas.ts +3 -1
  46. package/src/lib/repo/migrations.ts +4 -0
  47. package/src/lib/repo/sql.ts +7 -0
  48. package/src/lib/repo/types.ts +6 -1
  49. package/src/lib/schema.ts +47 -0
  50. package/src/pages/[...slug].astro +1 -1
  51. package/src/pages/admin/calendar/events/index.astro +82 -6
  52. package/src/pages/admin/forms/[id].astro +6 -0
  53. package/src/pages/admin/index.astro +6 -2
  54. package/src/pages/blog/[slug].astro +1 -1
  55. package/src/pages/events/[slug].astro +9 -3
  56. package/src/pages/forms/[id].astro +22 -4
  57. package/src/pages/index.astro +1 -1
  58. package/src/pages/members/_EventForm.astro +33 -27
  59. package/src/pages/members/_Layout.astro +25 -5
  60. package/src/pages/members/_http.ts +29 -6
  61. package/src/pages/members/events/[id].astro +21 -20
  62. package/src/pages/members/events/index.astro +295 -101
  63. package/src/pages/members/events/new.astro +11 -10
  64. package/src/server/index.ts +53 -0
  65. package/src/styles/forms.css +8 -0
  66. package/src/virtual.d.ts +1 -1
@@ -1,13 +1,18 @@
1
1
  import { hasOther, isHalf, type FormDefinition, type FormField } from './schema';
2
2
  import { OTHER } from './validate';
3
+ import { formStrings, type FormStrings } from './words';
4
+ import { fill } from '../live/words';
3
5
  export const escapeForm = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
4
6
  const e = escapeForm;
5
- const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date' };
7
+ const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date', time: 'time' };
6
8
  // Websites use a text box so "example.org" can be typed; the server adds https://.
7
9
  const autocomplete: Partial<Record<FormField['type'], string>> = { email: 'email', phone: 'tel', url: 'url' };
10
+ // Questions arrive in the page's language already (localize.ts localizeForm);
11
+ // `w` is the form's own words around them (words.ts), English by default.
12
+ const english = formStrings('en');
8
13
 
9
14
  /** 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 {
15
+ export function renderControl(f: FormField, value: string, id: string, w: FormStrings = english): string {
11
16
  const describe = f.help ? ` aria-describedby="${e(id)}-help"` : '';
12
17
  const attrs = `id="${e(id)}" name="q_${e(f.id)}"${f.required ? ' required' : ''}${describe}`;
13
18
  const ph = f.placeholder ? ` placeholder="${e(f.placeholder)}"` : '';
@@ -17,31 +22,31 @@ export function renderControl(f: FormField, value: string, id: string): string {
17
22
  // A required checkbox group can't be enforced natively; the server checks it.
18
23
  const req = radio && f.required ? ' required' : '';
19
24
  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>` : '';
25
+ 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>${e(w.other)}</span><input type="text" name="o_${e(f.id)}" maxlength="200" aria-label="${e(fill(w.otherAnswer, { label: f.label }))}" placeholder="${e(f.placeholder || w.typeAnswer)}" value="${e(extra)}"/></label>` : '';
21
26
  return `<div class="pc-bk-options">${f.options.map(option).join('')}${other}</div>`;
22
27
  }
23
28
  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>`;
29
+ if (f.type === 'select') return `<select ${attrs}><option value="">${e(f.placeholder || w.choose)}</option>${f.options.map(o => `<option value="${e(o)}" ${o === value ? 'selected' : ''}>${e(o)}</option>`).join('')}</select>`;
25
30
  const ac = (autocomplete[f.type] ? ` autocomplete="${autocomplete[f.type]}"` : '') + (f.type === 'url' ? ' inputmode="url" spellcheck="false"' : '');
26
31
  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
32
  }
28
33
 
29
- export function renderField(f: FormField, value: string, prefix: string): string {
34
+ export function renderField(f: FormField, value: string, prefix: string, w: FormStrings = english): string {
30
35
  const id = `${prefix}-${f.id}`, half = isHalf(f) ? ' is-half' : '';
31
36
  const help = f.help ? `<small id="${e(id)}-help">${e(f.help)}</small>` : '';
32
37
  const label = `${e(f.label)}${f.required ? ' *' : ''}`;
33
38
  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
39
  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>`;
40
+ 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, w)}${help}</fieldset>`;
41
+ return `<label class="pc-bk-field${half}" for="${e(id)}"><span>${label}</span>${renderControl(f, value, id, w)}${help}</label>`;
37
42
  }
38
43
 
39
44
  /** 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 {
45
+ export function renderFields(definition: FormDefinition, values: Record<string, string> = {}, prefix = 'form', w: FormStrings = english): string {
41
46
  const out: string[] = []; let row: string[] = [];
42
47
  const flush = () => { if (row.length) out.push(`<div class="pc-form-row">${row.join('')}</div>`); row = []; };
43
48
  for (const f of definition.fields) {
44
- const html = renderField(f, values[f.id] ?? '', prefix);
49
+ const html = renderField(f, values[f.id] ?? '', prefix, w);
45
50
  if (isHalf(f)) { row.push(html); if (row.length === 2) flush(); }
46
51
  else { flush(); out.push(html); }
47
52
  }
@@ -0,0 +1,68 @@
1
+ // A form in one of the site's other languages (schema.ts `translations`):
2
+ // the translated words laid over the main ones, word by word, so anything
3
+ // not translated yet stays in the main language. Pure (the builder uses it
4
+ // too); the caller picks the locale.
5
+ import type { FormDefinition, FormField } from './schema';
6
+
7
+ type Choices = Pick<FormField, 'options' | 'translations'>;
8
+ const pick = (v: string | undefined, fallback: string) => (v && v.trim() ? v : fallback);
9
+
10
+ /** A question's choices in one language, in the main order; untranslated ones stay as they are. */
11
+ export const choicesIn = (f: Choices, locale: string | null | undefined): string[] => {
12
+ const t = locale ? f.translations?.[locale]?.options : undefined;
13
+ return f.options.map((o, i) => t?.[i]?.trim() || o);
14
+ };
15
+
16
+ /** The main-language choice an answer stands for: itself, or the choice a translation of it sits in for. Undefined when it's neither. */
17
+ export function mainChoice(f: Choices, value: string): string | undefined {
18
+ if (f.options.includes(value)) return value;
19
+ for (const t of Object.values(f.translations ?? {})) {
20
+ const i = t.options?.findIndex(o => o.trim() === value) ?? -1;
21
+ if (i >= 0 && i < f.options.length) return f.options[i];
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /** A language in which a choice reads the same as a different choice (in any language), with that text; null when every text means one choice. */
27
+ export function choiceClash(f: Choices): { locale: string; text: string } | null {
28
+ const seen = new Map(f.options.map((o, i) => [o.trim(), i]));
29
+ for (const locale of Object.keys(f.translations ?? {})) {
30
+ for (const [i, text] of choicesIn(f, locale).entries()) {
31
+ const at = seen.get(text.trim());
32
+ if (at !== undefined && at !== i) return { locale, text };
33
+ seen.set(text.trim(), i);
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** The form as visitors in `locale` see it (main-language words where it has none), without any translations. */
40
+ export function localizeForm(d: FormDefinition, locale: string | null | undefined): FormDefinition {
41
+ const t = locale ? d.translations?.[locale] : undefined;
42
+ const { translations: _, ...rest } = d;
43
+ return {
44
+ ...rest,
45
+ title: pick(t?.title, d.title), intro: pick(t?.intro, d.intro),
46
+ submitLabel: pick(t?.submitLabel, d.submitLabel), confirmation: pick(t?.confirmation, d.confirmation),
47
+ fields: d.fields.map((f) => {
48
+ const w = locale ? f.translations?.[locale] : undefined;
49
+ const { translations: _, ...field } = f;
50
+ const placeholder = pick(w?.placeholder, f.placeholder ?? '');
51
+ return { ...field, label: pick(w?.label, f.label), help: pick(w?.help, f.help), options: choicesIn(f, locale), ...(placeholder ? { placeholder } : {}) };
52
+ }),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * What a form's version is made of (service.ts formVersion): the questions,
58
+ * and the choices in each language (an answer names one), but none of the
59
+ * other words. A form without translated choices hashes as it always did.
60
+ */
61
+ export function withoutWords(d: FormDefinition): FormDefinition {
62
+ const { translations: _, ...rest } = d;
63
+ return { ...rest, fields: d.fields.map(({ translations, ...f }) => {
64
+ const choices = Object.entries(translations ?? {}).filter(([, t]) => t.options?.some((o) => o.trim())).map(([l, t]) => [l, t.options!] as const);
65
+ return choices.length ? { ...f, choiceWords: Object.fromEntries(choices) } as FormField : f;
66
+ }) };
67
+ }
68
+
@@ -1,11 +1,17 @@
1
1
  import type { FormDefinition } from './schema';
2
2
  import { formVersion } from './service';
3
3
  import { escapeForm, renderFields } from './fields';
4
+ import { localizeForm } from './localize';
5
+ import { formStrings } from './words';
4
6
  export { escapeForm, renderFields } from './fields';
5
7
  export function hiddenFields(version: string, requestId: string) {
6
8
  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
9
  }
8
- export async function renderForm(id: string, definition: FormDefinition, values: Record<string, string> = {}, error = '', requestId: string = crypto.randomUUID(), embed = false) {
10
+ // locale: the form's language (its translations, '' = the main one); lang:
11
+ // the language of the page's own words; action: where it posts (/es/forms/<id>)
12
+ export async function renderForm(id: string, definition: FormDefinition, values: Record<string, string> = {}, error = '', requestId: string = crypto.randomUUID(), embed = false, o: { locale?: string; lang?: string; action?: string } = {}) {
9
13
  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>`;
14
+ const d = localizeForm(definition, o.locale), w = formStrings(o.lang);
15
+ const action = (o.action ?? `/forms/${encodeURIComponent(id)}`) + (embed ? '?embed=1' : '');
16
+ return `<section class="pc-simple-form">${d.title ? `<h2>${e(d.title)}</h2>` : ''}${d.intro ? `<p>${e(d.intro)}</p>` : ''}<form method="post" action="${e(action)}">${error ? `<p class="pc-bk-error" role="alert">${e(error)}</p>` : ''}${hiddenFields(await formVersion(definition), requestId)}${renderFields(d, values, `f-${id}`, w)}<button type="submit" class="pc-bk-submit">${e(d.submitLabel || w.submit)}</button></form></section>`;
11
17
  }
@@ -1,11 +1,34 @@
1
1
  import { z } from 'zod';
2
+ import { choiceClash } from './localize';
2
3
 
3
4
  // `checkbox` and `consent` both mean "must tick to agree" (consent is what the
4
5
  // builder inserts; checkbox stays valid for existing data). `heading` has no
5
6
  // 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
+ // `time` is stored as 24-hour HH:mm, like `date` as YYYY-MM-DD.
8
+ export const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading', 'time'] as const;
7
9
  export const CHOICE_TYPES: readonly string[] = ['select', 'radio', 'checkboxes'];
8
10
  export const FIELD_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,79}$/;
11
+ // The site's other languages (lib/i18n.ts), by locale like DocTranslations:
12
+ // question: translations: { es: { label, help, placeholder, options } }
13
+ // form: translations: { es: { title, intro, submitLabel, confirmation } }
14
+ // Every word is optional; a blank or missing one shows the main language's
15
+ // (lib/forms/localize.ts). A translated choice is the choice at the same
16
+ // position, and answers always store the main-language one, so Responses,
17
+ // CSV and alerts read the same whatever language someone answered in.
18
+ const byLocale = <T extends z.ZodTypeAny>(words: T) =>
19
+ z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), words).refine(r => Object.keys(r).length <= 20, 'Too many languages.');
20
+ export const FieldWords = z.object({
21
+ label: z.string().trim().max(200).optional(), help: z.string().max(300).optional(),
22
+ placeholder: z.string().max(120).optional(),
23
+ // by position; '' = this choice isn't translated yet
24
+ options: z.array(z.string().trim().max(200).regex(/^[^\r\n]*$/, 'Each choice must be one line.')).max(50).optional(),
25
+ });
26
+ export type FieldWords = z.infer<typeof FieldWords>;
27
+ export const FormWords = z.object({
28
+ title: z.string().max(120).optional(), intro: z.string().max(1000).optional(),
29
+ submitLabel: z.string().max(80).optional(), confirmation: z.string().max(1000).optional(),
30
+ });
31
+ export type FormWords = z.infer<typeof FormWords>;
9
32
  export const FormField = z.object({
10
33
  id: z.string().regex(FIELD_ID),
11
34
  type: z.enum(FIELD_TYPES), label: z.string().trim().min(1).max(200),
@@ -18,15 +41,21 @@ export const FormField = z.object({
18
41
  placeholder: z.string().max(120).optional(),
19
42
  width: z.enum(['full', 'half']).optional(),
20
43
  allowOther: z.boolean().optional(),
44
+ translations: byLocale(FieldWords).optional(),
21
45
  });
22
46
  export type FormField = z.infer<typeof FormField>;
47
+ /** a new form's thank-you; untouched, it shows in the page's language (words.ts thanksText) */
48
+ export const DEFAULT_THANKS = 'Thank you. Your response has been received.';
23
49
  export const FormDefinition = z.object({
24
50
  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.'),
51
+ submitLabel: z.string().max(80).default(''), confirmation: z.string().max(1000).default(DEFAULT_THANKS),
26
52
  fields: z.array(FormField).max(30).default([]),
53
+ translations: byLocale(FormWords).optional(),
27
54
  }).superRefine((d, ctx) => {
28
55
  if (new Set(d.fields.map(f => f.id)).size !== d.fields.length) ctx.addIssue({ code: 'custom', message: 'Field IDs must be unique.' });
29
56
  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.` });
57
+ // one text, one choice: a translated answer must map back to a single choice
58
+ for (const f of d.fields) if (CHOICE_TYPES.includes(f.type) && choiceClash(f)) ctx.addIssue({ code: 'custom', message: `“${f.label}” has a translated choice that reads the same as another choice.` });
30
59
  });
31
60
  export type FormDefinition = z.infer<typeof FormDefinition>;
32
61
  export const isHalf = (f: Pick<FormField, 'width' | 'type'>) => f.width === 'half' && f.type !== 'heading';
@@ -2,8 +2,16 @@ import { ApiError } from '../api';
2
2
  import { sha256Hex } from '../api-keys';
3
3
  import { FormDefinition, answerFields, type FormResponse } from './schema';
4
4
  import { AnswerError, checkAnswer } from './validate';
5
+ import { withoutWords } from './localize';
5
6
 
6
- export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.stringify(FormDefinition.parse(definition)));
7
+ // The version is the questions and their choices in each language, not the
8
+ // other translated words: rewording a label, help or title doesn't change
9
+ // what's asked, so a page or app that loaded the form before still sends its
10
+ // answers (no 409). A choice is answered in any language and stored as the
11
+ // main-language one, so rewording a translated choice does change the
12
+ // version: an answer in the old words gets the 409 and a reload, never a
13
+ // wrong answer. Definitions without translated choices hash as before.
14
+ export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.stringify(withoutWords(FormDefinition.parse(definition))));
7
15
  export async function collectResponse(definition: FormDefinition, input: Record<string, string>, version?: string): Promise<FormResponse> {
8
16
  definition = FormDefinition.parse(definition);
9
17
  const current = await formVersion(definition);
@@ -1,6 +1,7 @@
1
1
  // Pure answer reading + validation, shared by the server (collectResponse,
2
2
  // readAnswers) and the builder's preview. No server imports here.
3
3
  import { answerFields, hasOther, type FormDefinition, type FormField } from './schema';
4
+ import { mainChoice } from './localize';
4
5
 
5
6
  /** Value of the "Other" radio/checkbox; its text arrives as `o_<field id>`. */
6
7
  export const OTHER = '__other__';
@@ -28,12 +29,19 @@ export function answersFrom(getAll: (name: string) => unknown[], definition: Pic
28
29
  return answers;
29
30
  }
30
31
 
31
- /** Validates one answer and returns its stored form. Throws AnswerError with a visitor-facing message. */
32
+ /** HH:mm, 24-hour (an hour without its leading zero is fine: "9:05" is stored "09:05"). */
33
+ const TIME = /^([01]?\d|2[0-3]):([0-5]\d)$/;
34
+
35
+ /**
36
+ * Validates one answer and returns its stored form. Throws AnswerError with a
37
+ * visitor-facing message. A choice may come in any of the form's languages;
38
+ * it's stored as the main-language choice.
39
+ */
32
40
  export function checkAnswer(f: FormField, raw: unknown): string {
33
41
  if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError(`“${f.label}” is too long.`);
34
42
  if (f.type === 'heading') return '';
35
43
  if (f.type === 'checkboxes') {
36
- const items = raw.split('\n').map(s => s.trim()).filter(Boolean);
44
+ const items = raw.split('\n').map(s => s.trim()).filter(Boolean).map(s => mainChoice(f, s) ?? s);
37
45
  const extra = items.filter(i => !f.options.includes(i));
38
46
  if (f.required && !items.length) throw new AnswerError(`Please answer “${f.label}”.`);
39
47
  if (new Set(items).size !== items.length || extra.length > (hasOther(f) ? 1 : 0) || extra.some(e => e.length > 200)) throw invalid(f);
@@ -48,8 +56,12 @@ export function checkAnswer(f: FormField, raw: unknown): string {
48
56
  if (f.type === 'email') valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
49
57
  if (f.type === 'number') valid = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value) && Number.isFinite(Number(value));
50
58
  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'));
59
+ if (f.type === 'time') { const m = TIME.exec(value); valid = Boolean(m); if (m) value = `${m[1].padStart(2, '0')}:${m[2]}`; }
60
+ if (f.type === 'select' || f.type === 'radio') {
61
+ const main = mainChoice(f, value);
62
+ if (main !== undefined) value = main;
63
+ else valid = f.type === 'radio' && hasOther(f) && value.length <= 200 && !value.includes('\n');
64
+ }
53
65
  if (agree) valid = value === 'yes';
54
66
  if (f.type === 'phone') valid = value.length <= 40 && /^[+\d().\s-]+$/.test(value);
55
67
  if (f.type === 'url') {
@@ -0,0 +1,32 @@
1
+ // The public form's own words, around the ones its author wrote: the button
2
+ // and the thank-you when the form has none, the dropdown's empty choice,
3
+ // "Other" with its text box, and "Send another response". In the page's
4
+ // language (/forms/<id> and /es/forms/<id>, an event's RSVP questions); the
5
+ // author's own words come from the form and its translations. {label}: the
6
+ // question (live/words.ts `fill`). Other languages fall back to English.
7
+ import { DEFAULT_THANKS } from './schema';
8
+
9
+ export interface FormStrings {
10
+ submit: string;
11
+ thanks: string;
12
+ choose: string;
13
+ other: string;
14
+ typeAnswer: string;
15
+ /** read out for the "Other" text box */
16
+ otherAnswer: string;
17
+ sendAnother: string;
18
+ }
19
+
20
+ const STRINGS: Record<string, FormStrings> = {
21
+ en: { submit: 'Submit', thanks: 'Thank you. Your response has been received.', choose: 'Choose…', other: 'Other', typeAnswer: 'Type your answer', otherAnswer: 'Other answer for {label}', sendAnother: 'Send another response' },
22
+ es: { submit: 'Enviar', thanks: 'Gracias. Recibimos tu respuesta.', choose: 'Elige…', other: 'Otro', typeAnswer: 'Escribe tu respuesta', otherAnswer: 'Otra respuesta para {label}', sendAnother: 'Enviar otra respuesta' },
23
+ fr: { submit: 'Envoyer', thanks: 'Merci. Votre réponse a bien été reçue.', choose: 'Choisir…', other: 'Autre', typeAnswer: 'Saisissez votre réponse', otherAnswer: 'Autre réponse pour {label}', sendAnother: 'Envoyer une autre réponse' },
24
+ de: { submit: 'Senden', thanks: 'Danke. Ihre Antwort ist eingegangen.', choose: 'Auswählen…', other: 'Andere', typeAnswer: 'Ihre Antwort', otherAnswer: 'Andere Antwort für {label}', sendAnother: 'Weitere Antwort senden' },
25
+ 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
+ };
27
+
28
+ export const formStrings = (locale: string | null | undefined): FormStrings =>
29
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
30
+
31
+ /** The form's thank-you, or the page's own words when it has none (or still has the default one). */
32
+ export const thanksText = (confirmation: string, w: FormStrings) => (confirmation.trim() && confirmation !== DEFAULT_THANKS ? confirmation : w.thanks);
@@ -3,17 +3,24 @@ import type { ContentRepository } from '../repo/types';
3
3
  import type { FormDefinition, SavedForm } from '../forms/schema';
4
4
  import { collectResponse, formVersion } from '../forms/service';
5
5
  import { saveResponse } from '../forms/alerts';
6
+ import { localizeForm } from '../forms/localize';
7
+ import { formStrings, thanksText } from '../forms/words';
6
8
  import { ApiError, readJson } from '../api';
7
9
  import { sha256Hex } from '../api-keys';
10
+ import { I18N } from '../i18n';
11
+ import { isExtraLocale } from '../translations';
12
+ import { siteLang } from '../dates';
8
13
  import { queryParams } from './http';
9
14
  import { requireAccess } from './access';
15
+ import { localeOnlyQuery, mobileLocale } from './locale';
10
16
  import type { PublicContext } from './route';
11
17
 
12
- // Published general forms for apps: read the questions, send answers. The
13
- // same validation and storage as the web form (/forms/<id>), plus app
14
- // limits: an Idempotency-Key per response (retries never duplicate), 10
15
- // responses per address per form per minute, and a per-form daily ceiling
16
- // from Admin → Mobile app so one form can't be flooded.
18
+ // Published general forms for apps: read the questions (in the app's
19
+ // language with ?locale=), send answers. The same validation and storage as
20
+ // the web form (/forms/<id>), plus app limits: an Idempotency-Key per
21
+ // response (retries never duplicate), 10 responses per address per form per
22
+ // minute, and a per-form daily ceiling from Admin → Mobile app so one form
23
+ // can't be flooded.
17
24
 
18
25
  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;
19
26
 
@@ -25,10 +32,15 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
25
32
  return form;
26
33
  }
27
34
 
28
- /** A form's questions for apps; `version` is the one collectResponse accepts (also for event RSVPs). */
29
- export async function formJson(d: FormDefinition) {
35
+ /**
36
+ * A form's questions for apps, in `locale` (the main-language words where it
37
+ * has none; never the translations themselves); `version` is the one
38
+ * collectResponse accepts (also for event RSVPs), the same in every language.
39
+ */
40
+ export async function formJson(definition: FormDefinition, locale?: string | null) {
41
+ const d = localizeForm(definition, isExtraLocale(locale) ? locale : '');
30
42
  return {
31
- version: await formVersion(d),
43
+ version: await formVersion(definition),
32
44
  title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
33
45
  fields: d.fields.map((f) => ({
34
46
  id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
@@ -37,12 +49,26 @@ export async function formJson(d: FormDefinition) {
37
49
  };
38
50
  }
39
51
 
52
+ /** the language a request asks for (`?locale=`), or the site's main one */
53
+ function formLocale(url: URL, read: boolean) {
54
+ if (read) return mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
55
+ // sending answers never fails over the language: one the site doesn't
56
+ // have gets the main language's thank-you (the response is the same)
57
+ const locale = url.searchParams.get('locale');
58
+ return locale && I18N.locales.includes(locale) ? locale : I18N.defaultLocale || null;
59
+ }
60
+ // the button and thank-you when the form has none, in the request's language (else the site's)
61
+ async function ownWords(repo: ContentRepository, locale: string | null) {
62
+ return formStrings(locale ?? siteLang(await repo.getSettings()));
63
+ }
64
+
40
65
  export async function getForm(repo: ContentRepository, url: URL, id: string) {
41
- z.object({}).strict().parse(queryParams(url));
66
+ const locale = formLocale(url, true);
42
67
  await requireAccess(repo, 'forms');
43
68
  const form = await publishedForm(repo, id);
44
- const d = form.definition;
45
- return { id: form.id, ...await formJson(d), title: d.title || form.name, submitLabel: d.submitLabel || 'Submit' };
69
+ const json = await formJson(form.definition, locale);
70
+ const w = await ownWords(repo, locale);
71
+ return { id: form.id, ...json, title: json.title || form.name, submitLabel: json.submitLabel || w.submit, confirmation: thanksText(json.confirmation, w) };
46
72
  }
47
73
 
48
74
  // field id → answer; checkbox groups send a list. "Other" answers send the typed text.
@@ -60,6 +86,7 @@ export const formResponseInput = z.object({
60
86
  }).strict();
61
87
 
62
88
  export async function submitFormResponse(c: PublicContext) {
89
+ const locale = formLocale(c.url, false);
63
90
  const settings = await requireAccess(c.repo, 'forms');
64
91
  const id = c.params.id ?? '';
65
92
  const key = c.request.headers.get('idempotency-key') ?? '';
@@ -77,12 +104,17 @@ export async function submitFormResponse(c: PublicContext) {
77
104
  const unchanged = JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition);
78
105
  if (unchanged && JSON.stringify(await collectResponse(form.definition, answers, body.version)) !== prior)
79
106
  throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
80
- return { received: true as const, confirmation: form.definition.confirmation };
107
+ return receipt(c.repo, form, locale);
81
108
  }
82
109
  const response = await collectResponse(form.definition, answers, body.version);
83
110
  // only valid new responses count toward the daily ceiling
84
111
  const cap = settings.mobileAccess.responsesPerFormDay;
85
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);
86
113
  await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
87
- return { received: true as const, confirmation: form.definition.confirmation };
114
+ return receipt(c.repo, form, locale);
115
+ }
116
+ // the thank-you in the app's language (?locale=), as the web form shows it
117
+ async function receipt(repo: ContentRepository, form: SavedForm, locale: string | null) {
118
+ const d = localizeForm(form.definition, isExtraLocale(locale) ? locale : '');
119
+ return { received: true as const, confirmation: thanksText(d.confirmation, await ownWords(repo, locale)) };
88
120
  }
@@ -61,7 +61,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
61
61
  ['/announcements/{id}', 'One announcement that is up, in the requested language; requires announcements', localeOnlyQuery, ref('AnnouncementDetail')],
62
62
  ['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
63
63
  ['/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],
64
- ['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
64
+ ['/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')],
65
65
  ['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
66
66
  ];
67
67
  for (const [path, summary, query, schema, cache = true] of reads) {
@@ -114,8 +114,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
114
114
  paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
115
115
  responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
116
116
  paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
117
- 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. Leave website empty (spam trap).' }),
118
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
117
+ 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 (one the site doesn’t have gets the main language’s). Leave website empty (spam trap).' }),
118
+ 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 };
119
119
  const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
120
120
  paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
121
121
  description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
@@ -61,7 +61,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
61
61
  const closed = rsvpClosed(o, settings);
62
62
  const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
63
63
  const reason = closed ?? (left === 0 ? 'full' as const : null);
64
- const form = rsvpQuestions(o.event);
64
+ const questions = rsvpQuestions(o.event);
65
+ // the questions in the app's language, where the form has its words
66
+ const form = questions ? await formJson(questions, locale) : null;
65
67
  const title = localizeDoc(o.event, locale ?? undefined).title;
66
68
  // the built-in button speaks the app's language (the site's own without languages set up)
67
69
  let submitLabel = form?.submitLabel ?? '';
@@ -79,6 +81,6 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
79
81
  maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
80
82
  // a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
81
83
  waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
82
- form: form ? { ...await formJson(form), submitLabel } : null,
84
+ form: form ? { ...form, submitLabel } : null,
83
85
  };
84
86
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale } from '../schema';
3
+ import { FIELD_TYPES } from '../forms/schema';
3
4
 
4
5
  const str = z.string(), bool = z.boolean(), strings = z.array(str);
5
6
  const date = str.datetime(), locale = str.nullable();
@@ -73,7 +74,8 @@ export const Links = z.object({
73
74
  export const Form = z.object({
74
75
  id: str, version: str, title: str, intro: str, submitLabel: str, confirmation: str,
75
76
  fields: z.array(z.object({
76
- id: str, type: z.enum(['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading']),
77
+ // time: 24-hour HH:mm. An app that doesn't know a type can ask it as short text; the site checks the answer
78
+ id: str, type: z.enum(FIELD_TYPES),
77
79
  label: str, required: bool, help: str, options: strings, placeholder: str, width: z.enum(['full', 'half']), allowOther: bool,
78
80
  })),
79
81
  });
@@ -11,6 +11,7 @@ import { ROLES_MIGRATION } from '../permissions/migration';
11
11
  import { SYNC_MIGRATION } from '../sync/migration';
12
12
  import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
13
13
  import { SCREENS_MIGRATION } from '../display/store';
14
+ import { REQUEST_TABLE } from '../calendar/request-migration';
14
15
  // Versioned schema migrations shared by the two SQLite-dialect adapters
15
16
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
16
17
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -730,6 +731,9 @@ export const MIGRATIONS: Migration[] = [
730
731
  MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
731
732
  // saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
732
733
  SCREENS_MIGRATION,
734
+ // calendar requests on their own rows (RequestRecord): new events and
735
+ // changes asked for without an account, and hosts' date moves
736
+ { version: 68, statements: REQUEST_TABLE },
733
737
  ];
734
738
 
735
739
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -7,6 +7,7 @@ import { LiveStore } from '../live/store';
7
7
  import { PushStore } from '../push/store';
8
8
  import { AnnouncementStore, announcementInsert } from '../announcements/store';
9
9
  import { ScreenStore, screenInsert, screenKey } from '../display/store';
10
+ import { RequestStore, requestInsert } from '../calendar/request-store';
10
11
  import { EditorStore } from '../editor/store';
11
12
  import { PermissionStore } from '../permissions/store';
12
13
  import { BackupStore } from '../backup/store';
@@ -294,6 +295,7 @@ export class SqlRepository implements ContentRepository {
294
295
  readonly live: LiveStore;
295
296
  readonly push: PushStore;
296
297
  readonly announcements: AnnouncementStore;
298
+ readonly requests: RequestStore;
297
299
  readonly editor: EditorStore;
298
300
  readonly backups: BackupStore;
299
301
  readonly permissions: PermissionStore;
@@ -307,6 +309,7 @@ export class SqlRepository implements ContentRepository {
307
309
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
308
310
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
309
311
  this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
312
+ this.requests = new RequestStore(async () => { await this.ready; return this.driver; });
310
313
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
311
314
  this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
312
315
  this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
@@ -1023,6 +1026,8 @@ export class SqlRepository implements ContentRepository {
1023
1026
  ...notifications,
1024
1027
  { sql: 'DELETE FROM events WHERE id = ?', args: [id] },
1025
1028
  { sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
1029
+ // requests about it can't be answered any more: withdrawn (so they're pruned like answered ones)
1030
+ { sql: "UPDATE event_requests SET state = 'withdrawn', reviewed_at = ?, updated_at = MAX(?, updated_at + 1) WHERE event_id = ? AND state = 'pending'", args: [Date.now(), Date.now(), id] },
1026
1031
  ]);
1027
1032
  }
1028
1033
 
@@ -1943,6 +1948,8 @@ export class SqlRepository implements ContentRepository {
1943
1948
  ...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
1944
1949
  // screens come back with new keys (backups never hold them): each TV needs its new link
1945
1950
  ...(content.screens ? [{ sql: 'DELETE FROM display_screens' }, ...content.screens.map((s) => screenInsert({ ...s, key: screenKey(), lastSeenAt: null }))] : []),
1951
+ // calendar requests (a backup made before them leaves the current ones)
1952
+ ...(content.eventRequests ? [{ sql: 'DELETE FROM event_requests' }, ...content.eventRequests.map(requestInsert)] : []),
1946
1953
  ...(content.forms ?? []).map(formInsert),
1947
1954
  ...(content.formSubmissions ?? []).map(submissionInsert),
1948
1955
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -10,6 +10,7 @@ import type { EmailStore } from '../email/store';
10
10
  import type { RsvpStaffNotice } from '../email/types';
11
11
  import type { PushStore } from '../push/store';
12
12
  import type { AnnouncementStore } from '../announcements/store';
13
+ import type { RequestStore } from '../calendar/request-store';
13
14
  import type { Announcement } from '../announcements/types';
14
15
  import type { EditorStore } from '../editor/store';
15
16
  import type { PermissionStore } from '../permissions/store';
@@ -19,7 +20,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
19
20
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
20
21
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
21
22
 
22
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest } from '../schema';
23
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest, RequestRecord } from '../schema';
23
24
 
24
25
  export interface Stats {
25
26
  publishedPages: number;
@@ -82,6 +83,8 @@ export interface ContentRepository {
82
83
  readonly live: LiveStore;
83
84
  readonly push: PushStore;
84
85
  readonly announcements: AnnouncementStore;
86
+ // calendar requests on their own rows (lib/calendar/request-store.ts)
87
+ readonly requests: RequestStore;
85
88
  readonly editor: EditorStore;
86
89
  readonly permissions: PermissionStore;
87
90
  readonly screens: ScreenStore;
@@ -372,6 +375,8 @@ export interface ContentRepository {
372
375
  // saved TV screens, without their keys (each gets a new one); a backup
373
376
  // without them leaves the current ones
374
377
  screens?: Screen[];
378
+ // added with calendar requests; a backup without them leaves the current ones
379
+ eventRequests?: RequestRecord[];
375
380
  }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
376
381
 
377
382
  // imports (lib/import/apply.ts drives these). writeImported is the