@acms-subir/core 0.3.41 → 0.3.43
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 +24 -0
- package/cli/migrations.generated.mjs +15 -0
- package/docs/mobile.md +10 -1
- package/integration.mjs +2 -0
- package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
- package/mobile-template/recipes/push/recipe.json +4 -2
- package/package.json +1 -1
- package/src/components/Announcements.tsx +11 -2
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +20 -8
- package/src/components/TestPhones.tsx +177 -0
- package/src/lib/announcements/api.ts +19 -2
- package/src/lib/announcements/service.ts +1 -1
- package/src/lib/email/service.ts +19 -2
- package/src/lib/email/store.ts +9 -1
- package/src/lib/email/templates.ts +20 -0
- package/src/lib/forms/alert-mail.ts +124 -0
- package/src/lib/forms/alerts.ts +137 -0
- package/src/lib/forms/schema.ts +15 -1
- package/src/lib/forms/store.ts +26 -8
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +2 -2
- package/src/lib/legal/text-es.ts +2 -2
- package/src/lib/mobile/forms.ts +2 -1
- package/src/lib/mobile/guest.ts +2 -1
- package/src/lib/mobile/openapi.ts +18 -3
- package/src/lib/mobile/schemas.ts +4 -0
- package/src/lib/push/api.ts +37 -8
- package/src/lib/push/service.ts +6 -2
- package/src/lib/push/staff.ts +162 -0
- package/src/lib/push/store.ts +49 -2
- package/src/lib/push/types.ts +8 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +4 -1
- package/src/lib/sync/catalog.ts +2 -1
- package/src/pages/admin/forms/[id]/share.astro +49 -12
- package/src/pages/api/announcements.ts +2 -2
- package/src/pages/api/push/admin.ts +1 -1
- package/src/pages/api/push/phones.ts +25 -0
- package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
- package/src/pages/forms/[id].astro +2 -1
- package/src/styles/forms-admin.css +8 -0
- package/src/styles/test-phones.css +30 -0
package/src/lib/email/service.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, EmailLinkError } from './templates';
|
|
5
|
-
import { formatDate } from '../dates';
|
|
4
|
+
import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError } from './templates';
|
|
5
|
+
import { formatDate, siteLang } from '../dates';
|
|
6
|
+
import { I18N } from '../i18n';
|
|
6
7
|
import { bookingMail } from '../booking/service';
|
|
8
|
+
import { formAlertMail, type AlertPayload } from '../forms/alert-mail';
|
|
7
9
|
import type { EmailJob, Mail } from './types';
|
|
8
10
|
|
|
9
11
|
async function render(repo: ContentRepository, job: EmailJob, locals?: unknown, from = ''): Promise<Mail | null> {
|
|
@@ -16,6 +18,17 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
16
18
|
if (!current || current.createdAt !== payload.issuedAt || current.expiresAt <= Date.now()) return null;
|
|
17
19
|
return guestCodeMail(job.recipient, settings.siteTitle, decrypt<string>(payload.code, `guest-code:${job.dedupe}`, locals));
|
|
18
20
|
}
|
|
21
|
+
if (job.kind === 'phone-linked') {
|
|
22
|
+
const p = JSON.parse(job.payload) as { platform: 'ios' | 'android'; at: number; path: string };
|
|
23
|
+
const lang = (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
24
|
+
let when: string;
|
|
25
|
+
try { when = new Intl.DateTimeFormat(lang, { dateStyle: 'long', timeStyle: 'short', timeZone: settings.timezone || 'UTC' }).format(p.at); }
|
|
26
|
+
catch { when = new Date(p.at).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; }
|
|
27
|
+
// a security notice still goes out without the link while Site URL is unset
|
|
28
|
+
let origin = '';
|
|
29
|
+
try { origin = canonicalOrigin(settings.siteUrl); } catch { /* text names the panel instead */ }
|
|
30
|
+
return phoneLinkedMail(job.recipient, settings.siteTitle, lang, p.platform, when, origin ? `${origin}${p.path}` : '');
|
|
31
|
+
}
|
|
19
32
|
const origin = canonicalOrigin(settings.siteUrl);
|
|
20
33
|
if (job.kind === 'booking') {
|
|
21
34
|
const payload = JSON.parse(job.payload);
|
|
@@ -25,6 +38,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
25
38
|
const mail = await bookingMail(repo, b, payload.kind, origin, from, payload, job.recipient);
|
|
26
39
|
return { ...mail, to: job.recipient };
|
|
27
40
|
}
|
|
41
|
+
if (job.kind === 'form-alert') return formAlertMail(repo, job.recipient, JSON.parse(job.payload) as AlertPayload, origin);
|
|
28
42
|
if (job.kind === 'auth') {
|
|
29
43
|
const payload = JSON.parse(job.payload);
|
|
30
44
|
const token = await repo.email.token(payload.tokenId);
|
|
@@ -76,6 +90,9 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
76
90
|
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
77
91
|
}
|
|
78
92
|
}
|
|
93
|
+
if (j.kind === 'form-alert' && !current.config.staffNotifications) {
|
|
94
|
+
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
95
|
+
}
|
|
79
96
|
const mail = await render(repo,j,locals,resolved.config.from);
|
|
80
97
|
if (!mail) { await repo.email.finish(j,{state:'cancelled',error:'Notification no longer applies.'}); continue; }
|
|
81
98
|
// Retries must reuse exactly the same request body, including calendar
|
package/src/lib/email/store.ts
CHANGED
|
@@ -123,10 +123,18 @@ export class EmailStore {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
async rateLimit(key: string, max: number, windowMs: number): Promise<boolean> {
|
|
126
|
+
return (await this.rateHit(key, windowMs)) <= max;
|
|
127
|
+
}
|
|
128
|
+
/** counts one hit and returns how many the window holds with it */
|
|
129
|
+
async rateHit(key: string, windowMs: number): Promise<number> {
|
|
126
130
|
const now = Date.now(), d = await this.getDriver();
|
|
127
131
|
const rows = await d.all(`INSERT INTO email_rate_limits (key,count,expires_at) VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET
|
|
128
132
|
count=CASE WHEN expires_at <= ? THEN 1 ELSE count+1 END, expires_at=CASE WHEN expires_at <= ? THEN ? ELSE expires_at END RETURNING count`, [key,now+windowMs,now,now,now+windowMs]);
|
|
129
|
-
return Number(rows[0].count)
|
|
133
|
+
return Number(rows[0].count);
|
|
134
|
+
}
|
|
135
|
+
/** gives one hit back (an attempt counted up front that turned out fine) */
|
|
136
|
+
async rateRefund(key: string): Promise<void> {
|
|
137
|
+
await (await this.getDriver()).run('UPDATE email_rate_limits SET count=count-1 WHERE key=? AND count>0 AND expires_at>?', [key, Date.now()]);
|
|
130
138
|
}
|
|
131
139
|
/** true when key's current window already holds max hits (checks without counting) */
|
|
132
140
|
async rateLimited(key: string, max: number): Promise<boolean> {
|
|
@@ -41,3 +41,23 @@ export function canonicalOrigin(value: string): string {
|
|
|
41
41
|
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new EmailLinkError('Site URL in Settings → General must use HTTPS (HTTP is allowed for localhost), without a path, credentials or query.');
|
|
42
42
|
return url.origin;
|
|
43
43
|
}
|
|
44
|
+
// "A phone was linked" (lib/push/staff.ts): a security notice to the staff
|
|
45
|
+
// member, in the site's main language (usted in Spanish, like other emails)
|
|
46
|
+
const PHONE_LINKED: Record<string, { subject: string; intro: (phone: string, site: string, when: string) => string; notYou: string; button: string; noLink: string; ios: string; android: string }> = {
|
|
47
|
+
en: { subject: 'A phone was linked to your account', intro: (p, s, w) => `A phone (${p}) was linked to your account at ${s} on ${w}. It gets the test notifications you send from the admin.`,
|
|
48
|
+
notYou: 'If this wasn’t you, remove it in the admin', button: 'Test phones', noLink: 'If this wasn’t you, remove it in the admin under Test phones.', ios: 'iPhone or iPad', android: 'Android' },
|
|
49
|
+
es: { subject: 'Se vinculó un teléfono a su cuenta', intro: (p, s, w) => `Se vinculó un teléfono (${p}) a su cuenta de ${s} el ${w}. Recibe las notificaciones de prueba que usted envía desde el panel.`,
|
|
50
|
+
notYou: 'Si no fue usted, quítelo en el panel', button: 'Teléfonos de prueba', noLink: 'Si no fue usted, quítelo en el panel, en Teléfonos de prueba.', ios: 'iPhone o iPad', android: 'Android' },
|
|
51
|
+
fr: { subject: 'Un téléphone a été associé à votre compte', intro: (p, s, w) => `Un téléphone (${p}) a été associé à votre compte sur ${s} le ${w}. Il reçoit les notifications de test que vous envoyez depuis l’administration.`,
|
|
52
|
+
notYou: 'Si ce n’était pas vous, retirez-le dans l’administration', button: 'Téléphones de test', noLink: 'Si ce n’était pas vous, retirez-le dans l’administration, sous Téléphones de test.', ios: 'iPhone ou iPad', android: 'Android' },
|
|
53
|
+
de: { subject: 'Ein Telefon wurde mit Ihrem Konto verknüpft', intro: (p, s, w) => `Ein Telefon (${p}) wurde am ${w} mit Ihrem Konto bei ${s} verknüpft. Es erhält die Testbenachrichtigungen, die Sie im Adminbereich senden.`,
|
|
54
|
+
notYou: 'Wenn Sie das nicht waren, entfernen Sie es im Adminbereich', button: 'Testtelefone', noLink: 'Wenn Sie das nicht waren, entfernen Sie es im Adminbereich unter Testtelefone.', ios: 'iPhone oder iPad', android: 'Android' },
|
|
55
|
+
pt: { subject: 'Um telefone foi vinculado à sua conta', intro: (p, s, w) => `Um telefone (${p}) foi vinculado à sua conta em ${s} em ${w}. Ele recebe as notificações de teste que você envia pelo painel.`,
|
|
56
|
+
notYou: 'Se não foi você, remova-o no painel', button: 'Telefones de teste', noLink: 'Se não foi você, remova-o no painel, em Telefones de teste.', ios: 'iPhone ou iPad', android: 'Android' },
|
|
57
|
+
};
|
|
58
|
+
export function phoneLinkedMail(to: string, site: string, lang: string, platform: 'ios' | 'android', when: string, url: string): Mail {
|
|
59
|
+
const w = PHONE_LINKED[lang.toLowerCase().split('-')[0]] ?? PHONE_LINKED.en;
|
|
60
|
+
const intro = w.intro(platform === 'ios' ? w.ios : w.android, site, when);
|
|
61
|
+
return { to, subject: `${w.subject} · ${site}`, text: [w.subject, '', intro, '', url ? `${w.notYou}: ${url}` : w.noLink].join('\n'),
|
|
62
|
+
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(w.subject)}</h1><p>${escape(intro)}</p>${url ? `<p>${escape(w.notYou)}:</p><p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(w.button)}</a></p>` : `<p>${escape(w.noLink)}</p>`}</div></body></html>` };
|
|
63
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The staff alert about a new form response (queued by lib/forms/alerts.ts)
|
|
2
|
+
// and its push: in the site's main language, like other staff emails. With
|
|
3
|
+
// the answers, every question in order with its label; without them (forms
|
|
4
|
+
// that ask for sensitive things), only that a response arrived and when, so
|
|
5
|
+
// the email holds nothing about the person. Plain HTML like the staff
|
|
6
|
+
// booking emails, plus a text part. Other languages fall back to English.
|
|
7
|
+
import type { ContentRepository } from '../repo/types';
|
|
8
|
+
import type { Settings } from '../schema';
|
|
9
|
+
import type { Mail } from '../email/types';
|
|
10
|
+
import { validAddress } from '../email/config';
|
|
11
|
+
import { siteLang, siteLocale } from '../dates';
|
|
12
|
+
import { I18N } from '../i18n';
|
|
13
|
+
import { fill } from '../live/words';
|
|
14
|
+
import { answerFields, type FormField, type FormResponse } from './schema';
|
|
15
|
+
|
|
16
|
+
/** alerts one form sends in a day (the site's); the last one says so */
|
|
17
|
+
export const ALERTS_PER_DAY = 50;
|
|
18
|
+
|
|
19
|
+
/** what a queued alert job carries: ids and switches, never the answers */
|
|
20
|
+
export interface AlertPayload { formId: string; submissionId: string; answers: boolean; last: boolean; via: 'web' | 'app' }
|
|
21
|
+
|
|
22
|
+
// {form}, {when}, {email} and {max} slots (live/words.ts `fill`)
|
|
23
|
+
interface AlertWords {
|
|
24
|
+
subject: string; received: string; open: string; reply: string; last: string;
|
|
25
|
+
via: Record<AlertPayload['via'], string>; why: string; yes: string; no: string;
|
|
26
|
+
/** the push of the day's last alert */
|
|
27
|
+
pushLast: string;
|
|
28
|
+
}
|
|
29
|
+
const WORDS: Record<string, AlertWords> = {
|
|
30
|
+
en: { subject: 'New response: {form}', received: 'Received {when}', open: 'Open responses in the admin', reply: 'Reply to this email to write to {email}.',
|
|
31
|
+
last: 'That’s {max} alerts for this form today, so we’ll stop emailing about it until tomorrow. See Responses in the admin for the rest.',
|
|
32
|
+
via: { web: 'Sent from the website', app: 'Sent from the app' }, why: 'You get these because you’re on this form’s alert list.', yes: 'Yes', no: 'No',
|
|
33
|
+
pushLast: 'Received {when}. That’s {max} today; the rest are in the admin.' },
|
|
34
|
+
es: { subject: 'Nueva respuesta: {form}', received: 'Recibida el {when}', open: 'Ver las respuestas en el panel', reply: 'Responda a este correo para escribir a {email}.',
|
|
35
|
+
last: 'Ya son {max} avisos de este formulario hoy, así que no le escribiremos más sobre él hasta mañana. Vea las demás respuestas en el panel.',
|
|
36
|
+
via: { web: 'Enviada desde el sitio web', app: 'Enviada desde la app' }, why: 'Recibe estos correos porque está en la lista de avisos de este formulario.', yes: 'Sí', no: 'No',
|
|
37
|
+
pushLast: 'Recibida el {when}. Ya son {max} hoy; las demás están en el panel.' },
|
|
38
|
+
fr: { subject: 'Nouvelle réponse : {form}', received: 'Reçue le {when}', open: 'Ouvrir les réponses dans l’administration', reply: 'Répondez à cet e-mail pour écrire à {email}.',
|
|
39
|
+
last: 'Cela fait {max} alertes pour ce formulaire aujourd’hui : nous n’en enverrons plus avant demain. Les autres réponses sont dans l’administration.',
|
|
40
|
+
via: { web: 'Envoyée depuis le site', app: 'Envoyée depuis l’app' }, why: 'Vous recevez ces e-mails parce que vous êtes sur la liste d’alertes de ce formulaire.', yes: 'Oui', no: 'Non',
|
|
41
|
+
pushLast: 'Reçue le {when}. Cela fait {max} aujourd’hui ; les autres sont dans l’administration.' },
|
|
42
|
+
de: { subject: 'Neue Antwort: {form}', received: 'Eingegangen am {when}', open: 'Antworten in der Verwaltung öffnen', reply: 'Antworten Sie auf diese E-Mail, um {email} zu schreiben.',
|
|
43
|
+
last: 'Das sind heute {max} Benachrichtigungen für dieses Formular, daher schreiben wir bis morgen nicht mehr. Die übrigen Antworten finden Sie in der Verwaltung.',
|
|
44
|
+
via: { web: 'Über die Website gesendet', app: 'Über die App gesendet' }, why: 'Sie erhalten diese E-Mails, weil Sie auf der Benachrichtigungsliste dieses Formulars stehen.', yes: 'Ja', no: 'Nein',
|
|
45
|
+
pushLast: 'Eingegangen am {when}. Heute sind es {max}; die übrigen finden Sie in der Verwaltung.' },
|
|
46
|
+
pt: { subject: 'Nova resposta: {form}', received: 'Recebida em {when}', open: 'Abrir as respostas no painel', reply: 'Responda a este e-mail para escrever para {email}.',
|
|
47
|
+
last: 'Já são {max} alertas deste formulário hoje, então não enviaremos mais até amanhã. Veja as demais respostas no painel.',
|
|
48
|
+
via: { web: 'Enviada pelo site', app: 'Enviada pelo app' }, why: 'Você recebe estes e-mails porque está na lista de alertas deste formulário.', yes: 'Sim', no: 'Não',
|
|
49
|
+
pushLast: 'Recebida em {when}. Já são {max} hoje; as demais estão no painel.' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** the site's main language, and the locale for dates (keeping the site's region when it's the same language) */
|
|
53
|
+
function alertLanguage(settings: Settings) {
|
|
54
|
+
const lang = (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
55
|
+
const base = lang.toLowerCase().split('-')[0];
|
|
56
|
+
return { lang, w: WORDS[base] ?? WORDS.en, locale: base === siteLang(settings) ? siteLocale(settings) : lang };
|
|
57
|
+
}
|
|
58
|
+
// the email's "Oct 20, 2026, 3:41 PM"; the push's "Oct 20, 3:41 PM", in the site's zone
|
|
59
|
+
function whenText(at: number, locale: string, tz: string, year: boolean) {
|
|
60
|
+
const opts: Intl.DateTimeFormatOptions = year ? { dateStyle: 'medium', timeStyle: 'short' } : { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
|
|
61
|
+
try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz || 'UTC' }).format(at); }
|
|
62
|
+
catch { return new Date(at).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; }
|
|
63
|
+
}
|
|
64
|
+
// names can't break the subject line
|
|
65
|
+
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
66
|
+
|
|
67
|
+
function shown(f: FormField, value: string | undefined, w: AlertWords) {
|
|
68
|
+
if (f.type === 'checkbox' || f.type === 'consent') return value === 'yes' ? w.yes : w.no;
|
|
69
|
+
if (f.type === 'checkboxes') return value ? value.split('\n').join(', ') : '—';
|
|
70
|
+
return value || '—';
|
|
71
|
+
}
|
|
72
|
+
/** the answer to the first email question, when it's a usable address (else '') */
|
|
73
|
+
export function replyAddress(response: FormResponse): string {
|
|
74
|
+
for (const f of answerFields(response.definition)) {
|
|
75
|
+
const v = (response.answers[f.id] ?? '').trim();
|
|
76
|
+
if (f.type === 'email' && v && validAddress(v)) return v;
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The alert email for one queued job, or null when it no longer applies:
|
|
85
|
+
* the response was deleted, the form's alerts were turned off or this
|
|
86
|
+
* address was taken off the list. Turning the answers off also covers
|
|
87
|
+
* alerts still waiting to go out.
|
|
88
|
+
*/
|
|
89
|
+
export async function formAlertMail(repo: ContentRepository, to: string, p: AlertPayload, origin: string): Promise<Mail | null> {
|
|
90
|
+
const [form, s, settings] = await Promise.all([repo.forms.get(p.formId), repo.forms.submission(p.formId, p.submissionId), repo.getSettings()]);
|
|
91
|
+
if (!form || !s || !form.alerts.enabled || !form.alerts.to.includes(to.trim().toLowerCase())) return null;
|
|
92
|
+
const answers = p.answers && form.alerts.answers;
|
|
93
|
+
const { lang, w, locale } = alertLanguage(settings);
|
|
94
|
+
const name = oneLine(form.name);
|
|
95
|
+
const heading = fill(w.subject, { form: name });
|
|
96
|
+
const intro = fill(w.received, { when: whenText(s.createdAt, locale, settings.timezone, true) });
|
|
97
|
+
const rows: [string, string][] = answers ? answerFields(s.response.definition).map((f) => [f.label, shown(f, s.response.answers[f.id], w)]) : [];
|
|
98
|
+
// replies reach the person only when the email shows their answers
|
|
99
|
+
const replyTo = answers ? replyAddress(s.response) : '';
|
|
100
|
+
const notes = [...(replyTo ? [fill(w.reply, { email: replyTo })] : []), ...(p.last ? [fill(w.last, { max: String(ALERTS_PER_DAY) })] : [])];
|
|
101
|
+
const button = { href: `${origin}/admin/forms/${encodeURIComponent(form.id)}/submissions`, label: w.open };
|
|
102
|
+
const footer = `${w.via[p.via === 'app' ? 'app' : 'web']} · ${w.why}`;
|
|
103
|
+
const html = `<!doctype html><html lang="${esc(lang)}"><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
|
|
104
|
+
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
105
|
+
<p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(settings.siteTitle)}</p>
|
|
106
|
+
<h1 style="font-size:22px;margin:0 0 8px">${esc(heading)}</h1>
|
|
107
|
+
<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>
|
|
108
|
+
${rows.length ? `<table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
|
|
109
|
+
.map(([k, v]) => `<tr><td style="padding:10px 14px;border-bottom:1px solid #eeece8"><div style="font-size:13px;color:#6b6e78">${esc(k)}</div><div style="font-size:14px;margin-top:2px">${esc(v).replace(/\n/g, '<br>')}</div></td></tr>`)
|
|
110
|
+
.join('')}</table>` : ''}
|
|
111
|
+
${notes.map((n) => `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(n)}</p>`).join('')}
|
|
112
|
+
<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="display:inline-block;background:#1e1f24;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:10px 16px;border-radius:8px">${esc(button.label)}</a></p>
|
|
113
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(footer)}</p>
|
|
114
|
+
</div></body></html>`;
|
|
115
|
+
const text = [heading, '', intro, ...(rows.length ? ['', ...rows.map(([k, v]) => `${k}: ${v}`)] : []), ...notes.flatMap((n) => ['', n]), '', `${button.label}: ${button.href}`, '', footer].join('\n');
|
|
116
|
+
return { to, subject: heading, html, text, ...(replyTo ? { replyTo } : {}) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The push to staff phones: the form's name and when, never the answers or who sent it. */
|
|
120
|
+
export function alertPush(formName: string, at: number, last: boolean, settings: Settings) {
|
|
121
|
+
const { w, locale } = alertLanguage(settings);
|
|
122
|
+
const when = whenText(at, locale, settings.timezone, false);
|
|
123
|
+
return { title: fill(w.subject, { form: oneLine(formName) }), body: last ? fill(w.pushLast, { when, max: String(ALERTS_PER_DAY) }) : fill(w.received, { when }) };
|
|
124
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Staff alerts for new responses to a general form (Share → Notifications,
|
|
2
|
+
// SavedForm.alerts; off by default). A response accepted from the website or
|
|
3
|
+
// the app queues one email per address on the form's list in the same
|
|
4
|
+
// database batch as the response, so a retry, a refused or a failed
|
|
5
|
+
// submission emails no one. Staff on the list who linked a phone (lib/push/
|
|
6
|
+
// staff.ts) can also get a push saying only that one arrived. Needs working
|
|
7
|
+
// email and Staff notifications on (Email settings); at most ALERTS_PER_DAY
|
|
8
|
+
// alerts a form per site day, the last one saying so. Event RSVP forms
|
|
9
|
+
// don't use this: their hosts get the RSVP email.
|
|
10
|
+
import type { ContentRepository } from '../repo/types';
|
|
11
|
+
import type { SqlStatement } from '../repo/sql';
|
|
12
|
+
import type { Settings } from '../schema';
|
|
13
|
+
import { ApiError } from '../api';
|
|
14
|
+
import { resolveConfig, validAddress } from '../email/config';
|
|
15
|
+
import { emailReadiness } from '../email/readiness';
|
|
16
|
+
import { jobStatement } from '../email/store';
|
|
17
|
+
import { kickEmail } from '../email/service';
|
|
18
|
+
import { LEGAL } from '../legal/config';
|
|
19
|
+
import { deniedMessage, type Permission } from '../permissions/catalog';
|
|
20
|
+
import { PUSH } from '../push/config';
|
|
21
|
+
import { isStaffRole } from '../roles';
|
|
22
|
+
import { waitUntilFrom } from '../webhooks';
|
|
23
|
+
import { ALERTS_PER_DAY, alertPush, type AlertPayload } from './alert-mail';
|
|
24
|
+
import { AlertAddress, FormAlerts, MAX_ALERT_RECIPIENTS, type FormResponse, type SavedForm } from './schema';
|
|
25
|
+
|
|
26
|
+
const DAY = 86_400_000;
|
|
27
|
+
|
|
28
|
+
interface AlertPlan { to: string[]; answers: boolean; phones: boolean; last: boolean; key: string; configVersion: number }
|
|
29
|
+
|
|
30
|
+
// the site's calendar day, so "until tomorrow" means the site's tomorrow
|
|
31
|
+
function siteDay(at: number, tz: string) {
|
|
32
|
+
try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(at); }
|
|
33
|
+
catch { return new Date(at).toISOString().slice(0, 10); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether this response alerts anyone, counted toward the form's day (null: no alert). */
|
|
37
|
+
async function alertPlan(repo: ContentRepository, form: SavedForm, locals: unknown, now: number): Promise<AlertPlan | null> {
|
|
38
|
+
const a = form.alerts;
|
|
39
|
+
const to = [...new Set(a.to.map((x) => x.trim().toLowerCase()))].filter(validAddress);
|
|
40
|
+
if (!a.enabled || !to.length || form.kind !== 'form' || form.purpose !== 'general') return null;
|
|
41
|
+
const settings = await repo.getSettings();
|
|
42
|
+
const resolved = resolveConfig(await repo.email.config().catch(() => null), locals);
|
|
43
|
+
if (!resolved.config.staffNotifications || !emailReadiness(resolved, settings.siteUrl, locals).configured) return null;
|
|
44
|
+
// one counter per form and day: the key changes each day, the window only has to outlast it
|
|
45
|
+
const key = `forms:alerts:${form.id}:${siteDay(now, settings.timezone)}`;
|
|
46
|
+
const count = await repo.email.rateHit(key, 2 * DAY);
|
|
47
|
+
if (count > ALERTS_PER_DAY) return null;
|
|
48
|
+
return { to, answers: a.answers, phones: a.phones && PUSH.enabled, last: count === ALERTS_PER_DAY, key, configVersion: resolved.version };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// one job per address, inserted only alongside the response this batch stores
|
|
52
|
+
function alertJobs(formId: string, id: string, at: number, plan: AlertPlan, via: AlertPayload['via']): SqlStatement[] {
|
|
53
|
+
const payload: AlertPayload = { formId, submissionId: id, answers: plan.answers, last: plan.last, via };
|
|
54
|
+
return plan.to.map((to) => jobStatement({ dedupe: `form-alert:${id}:${to}`, kind: 'form-alert', recipient: to, payload: JSON.stringify(payload), configVersion: plan.configVersion, expiresAt: at + 3 * DAY },
|
|
55
|
+
'EXISTS (SELECT 1 FROM form_submissions WHERE id=? AND form_id=? AND created_at=?)', [id, formId, at]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// staff accounts on the list, to their linked phones; the site's main language
|
|
59
|
+
async function alertPhones(repo: ContentRepository, form: SavedForm, plan: AlertPlan, locals: unknown) {
|
|
60
|
+
const users = (await repo.listUsers()).filter((u) => u.email && isStaffRole(u.role) && plan.to.includes(u.email.trim().toLowerCase()));
|
|
61
|
+
const devices = (await Promise.all(users.map((u) => repo.push.staffDevices(u.id)))).flat();
|
|
62
|
+
if (!devices.length) return;
|
|
63
|
+
const { sendNotification } = await import('../push/service');
|
|
64
|
+
// no link: the app can't open the admin, and nothing about the person goes in
|
|
65
|
+
await sendNotification(repo, { ...alertPush(form.name, Date.now(), plan.last, await repo.getSettings()), url: '', audience: [], devices: devices.map((d) => d.id),
|
|
66
|
+
locale: null, source: 'form', sourceKey: `form:${form.id}` }, { locals });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stores a response to a published general form with its staff alerts (one
|
|
71
|
+
* request id stores it once). via: where it was sent from. True when this
|
|
72
|
+
* call stored it (false for a retry).
|
|
73
|
+
*/
|
|
74
|
+
export async function saveResponse(repo: ContentRepository, form: SavedForm, response: FormResponse, requestId: string, opts: { via: AlertPayload['via']; locals?: unknown; now?: number }) {
|
|
75
|
+
// a problem with alerts never loses a response
|
|
76
|
+
const plan = await alertPlan(repo, form, opts.locals, opts.now ?? Date.now()).catch((e) => { console.error('acms forms: alert check failed', e); return null; });
|
|
77
|
+
let saved = false;
|
|
78
|
+
try {
|
|
79
|
+
saved = await repo.forms.submit(form.id, response, requestId, plan ? (at) => alertJobs(form.id, requestId, at, plan, opts.via) : undefined);
|
|
80
|
+
} finally {
|
|
81
|
+
// a retry, or a response that didn't land, isn't one of the day's alerts
|
|
82
|
+
if (plan && !saved) await repo.email.rateRefund(plan.key).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
if (!plan || !saved) return saved;
|
|
85
|
+
const waitUntil = waitUntilFrom(opts.locals);
|
|
86
|
+
await kickEmail(repo, opts.locals, waitUntil);
|
|
87
|
+
if (plan.phones) {
|
|
88
|
+
const push = alertPhones(repo, form, plan, opts.locals).catch((e) => console.error('acms forms: alert push failed', e));
|
|
89
|
+
if (waitUntil) waitUntil(push); else await push;
|
|
90
|
+
}
|
|
91
|
+
return saved;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Share → Notifications ---------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/** Addresses offered when alerts were never set up: the contact email in Settings → Legal (then acms({ legal })), else the owners'. */
|
|
97
|
+
export async function suggestedRecipients(repo: ContentRepository, settings: Settings): Promise<string[]> {
|
|
98
|
+
const contact = (settings.legal.contactEmail || LEGAL.contactEmail).trim().toLowerCase();
|
|
99
|
+
if (contact) return [contact];
|
|
100
|
+
return [...new Set((await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email.trim().toLowerCase()))].slice(0, MAX_ALERT_RECIPIENTS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Why alerts wouldn't go out right now, with where to fix it (null: they would). */
|
|
104
|
+
export async function alertBlocker(repo: ContentRepository, settings: Settings, locals?: unknown): Promise<{ text: string; href: string; link: string } | null> {
|
|
105
|
+
const resolved = resolveConfig(await repo.email.config().catch(() => null), locals);
|
|
106
|
+
const status = emailReadiness(resolved, settings.siteUrl, locals);
|
|
107
|
+
const email = { href: '/admin/email', link: 'Email settings' };
|
|
108
|
+
if (!resolved.config.enabled || resolved.provider === 'none') return { text: 'Email is off, so no alerts go out.', ...email };
|
|
109
|
+
if (!status.configured) return { text: `Email needs attention, so no alerts go out: ${status.error || 'Choose a provider in Email settings.'}`, ...email };
|
|
110
|
+
if (!resolved.config.staffNotifications) return { text: 'Staff notifications are off in Email settings, so no alerts go out.', ...email };
|
|
111
|
+
if (status.linkError) return { text: status.linkError, href: '/admin/settings#site', link: 'Site settings' };
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Saves the Share page's Notifications form. Managing forms is enough, except
|
|
117
|
+
* that emailing the answers also needs "View form responses", so the list
|
|
118
|
+
* can't send someone responses they couldn't read in the admin. Push isn't
|
|
119
|
+
* on: the phones switch isn't shown, and its saved value stays.
|
|
120
|
+
*/
|
|
121
|
+
export async function saveFormAlerts(repo: ContentRepository, access: { can(permission: Permission): boolean } | null, form: SavedForm, data: FormData): Promise<FormAlerts> {
|
|
122
|
+
if (!access?.can('forms.manage')) throw new ApiError(403, deniedMessage('forms.manage'));
|
|
123
|
+
if (form.kind !== 'form' || form.purpose !== 'general') throw new ApiError(404, 'Form not found.');
|
|
124
|
+
const enabled = data.get('enabled') === '1', answers = data.get('answers') === '1';
|
|
125
|
+
const to: string[] = [];
|
|
126
|
+
for (const raw of String(data.get('to') ?? '').split(/[\s,;]+/).filter(Boolean)) {
|
|
127
|
+
const one = AlertAddress.safeParse(raw);
|
|
128
|
+
if (!one.success) throw new ApiError(400, `“${raw.slice(0, 80)}” isn’t an email address.`);
|
|
129
|
+
if (!to.includes(one.data)) to.push(one.data);
|
|
130
|
+
}
|
|
131
|
+
if (to.length > MAX_ALERT_RECIPIENTS) throw new ApiError(400, `Add up to ${MAX_ALERT_RECIPIENTS} addresses.`);
|
|
132
|
+
if (enabled && !to.length) throw new ApiError(400, 'Add at least one email address to send alerts to.');
|
|
133
|
+
if (enabled && answers && !access.can('forms.responses')) throw new ApiError(403, 'Emailing the answers also needs View form responses. Turn off “Include the answers in the email”, or ask an owner.');
|
|
134
|
+
const alerts = FormAlerts.parse({ enabled, to, answers, phones: PUSH.enabled ? data.get('phones') === '1' : form.alerts.phones });
|
|
135
|
+
await repo.forms.saveAlerts(form.id, alerts);
|
|
136
|
+
return alerts;
|
|
137
|
+
}
|
package/src/lib/forms/schema.ts
CHANGED
|
@@ -35,10 +35,24 @@ export const hasOther = (f: Pick<FormField, 'allowOther' | 'type'>) => Boolean(f
|
|
|
35
35
|
export const answerFields = (d: Pick<FormDefinition, 'fields'>) => d.fields.filter(f => f.type !== 'heading');
|
|
36
36
|
export const FormResponse = z.object({ version: z.string(), definition: FormDefinition, answers: z.record(z.string().max(2000)) });
|
|
37
37
|
export type FormResponse = z.infer<typeof FormResponse>;
|
|
38
|
+
export const MAX_ALERT_RECIPIENTS = 10;
|
|
39
|
+
export const AlertAddress = z.string().trim().toLowerCase().email().max(200);
|
|
40
|
+
// Staff alerts for new responses (Share → Notifications, lib/forms/alerts.ts).
|
|
41
|
+
// Kept apart from the definition, so it's never public, never part of a
|
|
42
|
+
// form's version and never copied into a response. Off for existing forms.
|
|
43
|
+
export const FormAlerts = z.object({
|
|
44
|
+
enabled: z.boolean().default(false),
|
|
45
|
+
to: z.array(AlertAddress).max(MAX_ALERT_RECIPIENTS).default([]),
|
|
46
|
+
/** false: the email only says a response arrived (sensitive forms) */
|
|
47
|
+
answers: z.boolean().default(true),
|
|
48
|
+
/** also a push to the linked phones of staff on the list (never the answers) */
|
|
49
|
+
phones: z.boolean().default(false),
|
|
50
|
+
});
|
|
51
|
+
export type FormAlerts = z.infer<typeof FormAlerts>;
|
|
38
52
|
export const SavedForm = z.object({
|
|
39
53
|
id: z.string(), name: z.string().trim().min(1).max(120), kind: z.enum(['form', 'template']), purpose: z.enum(['event', 'general']),
|
|
40
54
|
status: z.enum(['draft', 'published', 'archived']), definition: FormDefinition, draft: FormDefinition.nullable(),
|
|
41
|
-
revision: z.number().int(), createdAt: z.number(), updatedAt: z.number(),
|
|
55
|
+
revision: z.number().int(), createdAt: z.number(), updatedAt: z.number(), alerts: FormAlerts.default({}),
|
|
42
56
|
});
|
|
43
57
|
export type SavedForm = z.infer<typeof SavedForm>;
|
|
44
58
|
export const FormSubmission = z.object({ id: z.string(), formId: z.string(), response: FormResponse, createdAt: z.number() });
|
package/src/lib/forms/store.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
2
2
|
import { ApiError } from '../api';
|
|
3
|
-
import { FormDefinition, FormResponse, FormSubmission, SavedForm } from './schema';
|
|
4
|
-
const row = (r: Record<string, unknown>) => SavedForm.parse({ ...r, definition: JSON.parse(String(r.definition)), draft: r.draft ? JSON.parse(String(r.draft)) : null, createdAt: r.created_at, updatedAt: r.updated_at });
|
|
5
|
-
|
|
3
|
+
import { FormAlerts, FormDefinition, FormResponse, FormSubmission, SavedForm } from './schema';
|
|
4
|
+
const row = (r: Record<string, unknown>) => SavedForm.parse({ ...r, definition: JSON.parse(String(r.definition)), draft: r.draft ? JSON.parse(String(r.draft)) : null, createdAt: r.created_at, updatedAt: r.updated_at, alerts: JSON.parse(String(r.alerts || '{}')) });
|
|
5
|
+
const submission = (r: Record<string, unknown>) => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at });
|
|
6
|
+
export const formInsert = (f: SavedForm): SqlStatement => ({ sql: 'INSERT INTO forms (id,name,kind,purpose,status,definition,draft,revision,created_at,updated_at,alerts) VALUES (?,?,?,?,?,?,?,?,?,?,?)', args: [f.id,f.name,f.kind,f.purpose,f.status,JSON.stringify(f.definition),f.draft ? JSON.stringify(f.draft) : null,f.revision,f.createdAt,f.updatedAt,JSON.stringify(f.alerts)] });
|
|
6
7
|
export const submissionInsert = (s: FormSubmission): SqlStatement => ({ sql: 'INSERT INTO form_submissions (id,form_id,response,created_at) VALUES (?,?,?,?)', args: [s.id,s.formId,JSON.stringify(s.response),s.createdAt] });
|
|
7
8
|
export class FormStore {
|
|
8
9
|
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
@@ -22,23 +23,40 @@ export class FormStore {
|
|
|
22
23
|
[f.name, JSON.stringify(live ? current.definition : f.definition), live ? JSON.stringify(f.definition) : null, op === 'publish' ? 'published' : op === 'archive' ? 'archived' : current.status, Date.now(), id, revision]);
|
|
23
24
|
if (!rows.length) throw new ApiError(409, 'This form changed in another window. Reload before saving.');
|
|
24
25
|
}
|
|
26
|
+
// Share → Notifications. Not part of the definition, so it doesn't stage a
|
|
27
|
+
// draft or bump the revision (an open Build tab keeps saving).
|
|
28
|
+
async saveAlerts(id: string, alerts: FormAlerts) {
|
|
29
|
+
const rows = await (await this.driver()).all("UPDATE forms SET alerts=?,updated_at=? WHERE id=? AND kind='form' RETURNING id", [JSON.stringify(FormAlerts.parse(alerts)), Date.now(), id]);
|
|
30
|
+
if (!rows.length) throw new ApiError(404, 'Form not found.');
|
|
31
|
+
}
|
|
25
32
|
async submissions(formId?: string, limit = 100, offset = 0) {
|
|
26
|
-
return (await (await this.driver()).all(`SELECT * FROM form_submissions ${formId ? 'WHERE form_id=?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...(formId ? [formId] : []), limit, offset])).map(
|
|
33
|
+
return (await (await this.driver()).all(`SELECT * FROM form_submissions ${formId ? 'WHERE form_id=?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...(formId ? [formId] : []), limit, offset])).map(submission);
|
|
34
|
+
}
|
|
35
|
+
async submission(formId: string, id: string) {
|
|
36
|
+
const r = (await (await this.driver()).all('SELECT * FROM form_submissions WHERE id=? AND form_id=?', [id, formId]))[0];
|
|
37
|
+
return r ? submission(r) : null;
|
|
27
38
|
}
|
|
28
39
|
/** the response this request id already saved to this form, if any (as stored JSON) */
|
|
29
40
|
async receipt(formId: string, requestId: string) {
|
|
30
41
|
const r = (await (await this.driver()).all('SELECT response FROM form_submissions WHERE id=? AND form_id=?', [requestId, formId]))[0];
|
|
31
42
|
return r ? String(r.response) : null;
|
|
32
43
|
}
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Stores a response once per request id. `also` adds statements to the
|
|
46
|
+
* same batch (the staff alerts), given the time it's stored under. True
|
|
47
|
+
* when this call stored it; false for a retry of a saved response.
|
|
48
|
+
*/
|
|
49
|
+
async submit(formId: string, response: FormResponse, requestId: string, also?: (createdAt: number) => SqlStatement[]) {
|
|
34
50
|
response = FormResponse.parse(response);
|
|
35
51
|
const db = await this.driver();
|
|
36
52
|
// The receipt is also the unique request identity, so retries don't duplicate responses.
|
|
37
53
|
const old = (await db.all('SELECT form_id,response FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
38
54
|
if (old && (old.form_id !== formId || String(old.response) !== JSON.stringify(response))) throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
const at = Date.now();
|
|
56
|
+
if (!old) await db.batch([{ sql: 'INSERT OR IGNORE INTO form_submissions (id,form_id,response,created_at) SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM forms WHERE id=? AND status=\'published\' AND kind=\'form\' AND purpose=\'general\' AND definition=?)', args: [requestId,formId,JSON.stringify(response),at,formId,JSON.stringify(response.definition)] }, ...(also?.(at) ?? [])]);
|
|
57
|
+
const saved = (await db.all('SELECT form_id,response,created_at FROM form_submissions WHERE id=?', [requestId]))[0];
|
|
41
58
|
if (!saved || saved.form_id !== formId || String(saved.response) !== JSON.stringify(response)) throw new ApiError(409, 'This form changed. Reload and try again.');
|
|
59
|
+
return !old && Number(saved.created_at) === at;
|
|
42
60
|
}
|
|
43
61
|
// Archived → draft (the definition is kept; publishing reopens it).
|
|
44
62
|
async restore(id: string) { await (await this.driver()).run("UPDATE forms SET status='draft',revision=revision+1,updated_at=? WHERE id=? AND status='archived'", [Date.now(), id]); }
|
|
@@ -63,7 +81,7 @@ export class FormStore {
|
|
|
63
81
|
// instr, not LIKE: D1 rejects LIKE patterns over 50 bytes
|
|
64
82
|
if (q) { where.push("EXISTS (SELECT 1 FROM json_each(form_submissions.response,'$.answers') WHERE instr(lower(CAST(value AS TEXT)), lower(?)) > 0)"); args.push(q); }
|
|
65
83
|
if (opts.ids) { const ids = opts.ids.slice(0, 500); if (!ids.length) return []; where.push(`id IN (${ids.map(() => '?').join(',')})`); args.push(...ids); }
|
|
66
|
-
return (await (await this.driver()).all(`SELECT * FROM form_submissions WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...args, opts.limit ?? 100, opts.offset ?? 0])).map(
|
|
84
|
+
return (await (await this.driver()).all(`SELECT * FROM form_submissions WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...args, opts.limit ?? 100, opts.offset ?? 0])).map(submission);
|
|
67
85
|
}
|
|
68
86
|
async deleteSubmissions(formId: string, ids: string[]) { for (const id of ids.slice(0, 500)) await this.deleteSubmission(formId, id); }
|
|
69
87
|
// Posts, pages and events whose body (live or staged draft) embeds this form.
|
|
@@ -32,8 +32,8 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
|
|
|
32
32
|
});
|
|
33
33
|
if (f.push.enabled) rows.push({
|
|
34
34
|
data: 'Push token, platform, language, chosen topics and Android notification channels', when: 'When someone turns on notifications',
|
|
35
|
-
apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
36
|
-
google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
|
|
35
|
+
apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user (to a staff member’s identity only when they link their own phone for test notifications). Not used for tracking.',
|
|
36
|
+
google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional. Linked to a staff member’s account only when they link their own phone for test notifications.',
|
|
37
37
|
});
|
|
38
38
|
rows.push({
|
|
39
39
|
data: 'IP address', when: 'Every request (rate limiting only)',
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -46,13 +46,13 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
46
46
|
'Each booking has a private link to see, change or cancel it. Keep it private: anyone with the link can use it.',
|
|
47
47
|
] });
|
|
48
48
|
s.push({ id: 'forms', title: 'Forms', text: [
|
|
49
|
-
|
|
49
|
+
`When you send a form, we store your answers as you entered them (for example your name, email address or phone number, if the form asks for them) and when you sent it. Staff with access to form responses can read and export them. We don’t store your IP address with a response.${f.email ? ' Responses, or a notice that one arrived, may be emailed to the staff who handle that form.' : ''}`,
|
|
50
50
|
] });
|
|
51
51
|
if (f.guestSignIn) s.push({ id: 'guest', title: 'Signing in to see your bookings', text: [
|
|
52
52
|
'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
|
|
53
53
|
] });
|
|
54
54
|
if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
|
|
55
|
-
'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
|
|
55
|
+
'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging. Our staff can link their own phone to their account to receive test notifications and notices that a form response arrived; that link is removed when they remove it or turn notifications off.',
|
|
56
56
|
`You choose the topics in the app and can change them at any time; we never add a topic without asking you. ${f.announcements ? 'Urgent announcements, such as cancellations, go to every device with notifications turned on, whatever topics it chose.' : 'Important notices, such as cancellations, may go to every device with notifications turned on, whatever topics it chose.'} To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
|
|
57
57
|
] });
|
|
58
58
|
if (f.email) s.push({ id: 'email', title: 'Email', text: [
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -47,13 +47,13 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
47
47
|
'Cada reservación tiene un enlace privado para verla, cambiarla o cancelarla. Manténgalo en privado: cualquiera que lo tenga puede usarlo.',
|
|
48
48
|
] });
|
|
49
49
|
s.push({ id: 'forms', title: 'Formularios', text: [
|
|
50
|
-
|
|
50
|
+
`Cuando envía un formulario, guardamos sus respuestas tal como las escribió (por ejemplo su nombre, correo o teléfono, si el formulario los pide) y la fecha en que lo envió. El personal con acceso a las respuestas puede leerlas y exportarlas. No guardamos su dirección IP con la respuesta.${f.email ? ' Sus respuestas, o un aviso de que llegaron, pueden enviarse por correo al personal que atiende ese formulario.' : ''}`,
|
|
51
51
|
] });
|
|
52
52
|
if (f.guestSignIn) s.push({ id: 'guest', title: 'Inicio de sesión para ver sus reservaciones', text: [
|
|
53
53
|
'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
|
|
54
54
|
] });
|
|
55
55
|
if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
|
|
56
|
-
'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
|
|
56
|
+
'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging. Las personas de nuestro equipo pueden vincular su propio teléfono a su cuenta del sitio para recibir notificaciones de prueba y avisos de que llegó la respuesta a un formulario; ese vínculo se elimina cuando lo quitan o desactivan las notificaciones.',
|
|
57
57
|
`Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. ${f.announcements ? 'Los anuncios urgentes, como una cancelación, llegan a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.' : 'Los avisos importantes, como una cancelación, pueden llegar a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.'} Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
|
|
58
58
|
] });
|
|
59
59
|
if (f.email) s.push({ id: 'email', title: 'Correo electrónico', text: [
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import type { FormDefinition, SavedForm } from '../forms/schema';
|
|
4
4
|
import { collectResponse, formVersion } from '../forms/service';
|
|
5
|
+
import { saveResponse } from '../forms/alerts';
|
|
5
6
|
import { ApiError, readJson } from '../api';
|
|
6
7
|
import { sha256Hex } from '../api-keys';
|
|
7
8
|
import { queryParams } from './http';
|
|
@@ -82,6 +83,6 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
82
83
|
// only valid new responses count toward the daily ceiling
|
|
83
84
|
const cap = settings.mobileAccess.responsesPerFormDay;
|
|
84
85
|
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);
|
|
85
|
-
await c.repo
|
|
86
|
+
await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
|
|
86
87
|
return { received: true as const, confirmation: form.definition.confirmation };
|
|
87
88
|
}
|
package/src/lib/mobile/guest.ts
CHANGED
|
@@ -44,7 +44,8 @@ async function requireGuest(c: PublicContext) {
|
|
|
44
44
|
}
|
|
45
45
|
const emailField = z.string().trim().toLowerCase().max(254).refine(validAddress, 'Enter a valid email address.');
|
|
46
46
|
const hash = (email: string, code: string) => sha256Hex(`guest-code:${email}:${code}`);
|
|
47
|
-
|
|
47
|
+
/** A random 6-digit code (guest sign-in, staff phone links). */
|
|
48
|
+
export function sixDigits() {
|
|
48
49
|
// rejection sampling keeps every code equally likely
|
|
49
50
|
const buf = new Uint32Array(1);
|
|
50
51
|
do crypto.getRandomValues(buf); while (buf[0] >= 4_294_000_000);
|
|
@@ -10,7 +10,7 @@ import { searchQuery } from './search';
|
|
|
10
10
|
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
11
11
|
import { formResponseInput } from './forms';
|
|
12
12
|
import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
13
|
-
import { deviceInput, deviceUpdate } from '../push/api';
|
|
13
|
+
import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
|
|
14
14
|
import { announcementsQuery } from '../announcements/api';
|
|
15
15
|
import { localeOnlyQuery } from './locale';
|
|
16
16
|
import { pageQuery, queryParams } from './http';
|
|
@@ -129,7 +129,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
129
129
|
paths['/api/v1/devices'] = {
|
|
130
130
|
post: {
|
|
131
131
|
summary: 'Register or refresh this device for push notifications; requires push',
|
|
132
|
-
description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only.`,
|
|
132
|
+
description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only (plus, for a staff member's own test phone, who linked it). device.staff names that staff member, or is null.`,
|
|
133
133
|
requestBody: { required: true, content: media(jsonSchema(deviceInput)) },
|
|
134
134
|
responses: { ...writeErrors, ...Object.fromEntries([200, 201].map(code => [code, { description: code === 201 ? 'Registered.' : 'Refreshed.', headers: noStore, content: media(envelope(ref('DeviceRegistration'))) }])) },
|
|
135
135
|
},
|
|
@@ -142,12 +142,27 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
142
142
|
responses: { ...writeErrors, 200: { description: 'Updated.', headers: noStore, content: media(envelope(ref('Device'))) } },
|
|
143
143
|
},
|
|
144
144
|
delete: {
|
|
145
|
-
summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token. Unknown tokens also succeed.',
|
|
145
|
+
summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token, and a test-phone link with it. Unknown tokens also succeed.',
|
|
146
146
|
parameters: [tokenParam],
|
|
147
147
|
responses: { ...writeErrors, 200: { description: 'Removed.', headers: noStore, content: media(envelope(ref('DeviceRemoved'))) } },
|
|
148
148
|
},
|
|
149
149
|
options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
|
|
150
150
|
};
|
|
151
|
+
const staffNote = 'Requires push; 404 when off. A staff member gets a one-time 6-digit code in the admin (Test phones; 10 minutes, single use) and types it in the app, so this registered device gets their "Send test to my phone" pushes (data.test = true). staff carries their display name only.';
|
|
152
|
+
paths['/api/v1/devices/{token}/staff-link'] = {
|
|
153
|
+
post: {
|
|
154
|
+
summary: 'Link this registered device to a staff member for test pushes; requires push',
|
|
155
|
+
description: `${staffNote} 404 for an unregistered device. A wrong, used or expired code answers 400 with the same message. Tries are capped (429 with Retry-After): 5 per device token and 5 per client address in 10 minutes, and 20 failures across the site void every waiting code.`,
|
|
156
|
+
parameters: [tokenParam], requestBody: { required: true, content: media(jsonSchema(staffLinkInput)) },
|
|
157
|
+
responses: { ...writeErrors, 429: { ...writeErrors[429], description: 'Write limit, or too many link tries (see above).' }, 200: { description: 'Linked.', headers: noStore, content: media(envelope(ref('StaffLink'))) } },
|
|
158
|
+
},
|
|
159
|
+
delete: {
|
|
160
|
+
summary: 'Unlink this device from a staff member (idempotent); requires push', description: `${staffNote} Turning notifications off (DELETE /devices/{token}) also unlinks it.`,
|
|
161
|
+
parameters: [tokenParam],
|
|
162
|
+
responses: { ...writeErrors, 200: { description: 'Unlinked.', headers: noStore, content: media(envelope(ref('StaffLink'))) } },
|
|
163
|
+
},
|
|
164
|
+
options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
|
|
165
|
+
};
|
|
151
166
|
Object.assign(paths, editorPaths(jsonSchema));
|
|
152
167
|
return {
|
|
153
168
|
openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication, except a booking’s own routes (/bookings/manage/*: its private token in the Booking-Token header) and guest sign-in (/guest/*: a guest token from an emailed code). Owner switches in Admin → Mobile app turn app features off (404). Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
|