@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.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +18 -1
- package/docs/calendar.md +20 -2
- package/docs/mobile.md +1 -1
- package/docs/server.md +61 -1
- package/integration.mjs +11 -5
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/email/service.ts +12 -2
- package/src/lib/email/templates.ts +12 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +7 -0
- package/src/lib/repo/types.ts +6 -1
- package/src/lib/schema.ts +47 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +53 -0
- package/src/styles/forms.css +8 -0
- package/src/virtual.d.ts +1 -1
package/src/lib/forms/fields.ts
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
|
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 ||
|
|
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
|
+
|
package/src/lib/forms/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/forms/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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';
|
package/src/lib/forms/service.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 === '
|
|
52
|
-
if (f.type === '
|
|
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);
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
// limits: an Idempotency-Key per
|
|
15
|
-
// responses per address per form per
|
|
16
|
-
// from Admin → Mobile app so one form
|
|
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
|
-
/**
|
|
29
|
-
|
|
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(
|
|
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
|
-
|
|
66
|
+
const locale = formLocale(url, true);
|
|
42
67
|
await requireAccess(repo, 'forms');
|
|
43
68
|
const form = await publishedForm(repo, id);
|
|
44
|
-
const
|
|
45
|
-
|
|
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
|
|
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
|
|
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)',
|
|
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 };
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -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
|
|
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 ? { ...
|
|
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
|
-
|
|
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.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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)),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|