@acms-subir/core 0.3.42 → 0.3.44
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 +20 -0
- package/cli/migrations.generated.mjs +5 -0
- package/package.json +1 -1
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/TestPhones.tsx +2 -2
- package/src/lib/calendar/schedule.ts +20 -5
- package/src/lib/email/service.ts +5 -0
- package/src/lib/email/store.ts +5 -1
- 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/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/push/types.ts +6 -2
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +16 -3
- package/src/lib/repo/types.ts +7 -0
- package/src/lib/sync/catalog.ts +2 -1
- package/src/pages/admin/forms/[id]/share.astro +49 -12
- package/src/pages/forms/[id].astro +2 -1
- package/src/styles/forms-admin.css +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.44
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b011cee: Moving one date of a repeating event on the admin Schedule now keeps what belongs to that date. Dragging a single date still splits it off into a one-off copy, but the copy now keeps the series' hosts, room hold, translations and SEO title and description. Before, all of these were left behind, so the room looked free at the new time, the date dropped off the hosts' My events, and the Spanish title was gone. That date's RSVPs, including places in line, move to the copy at the new time. Everyone with a seat gets the "new time" email with the updated calendar file, while places in line move quietly. **Undo** moves the RSVPs back to the series' date (with the email again) before the copy is deleted, so undoing never cancels anyone. Undo now refuses with "This event has unpublished changes" before anything changes, instead of deleting the copy first.
|
|
8
|
+
|
|
9
|
+
Dates moved before this release keep their copies as they are: staff can still add the hosts and room on those by hand. `repo.createEvent` now stores `translations` (it ignored them before), and the repository has a new `moveOccurrenceRsvps(eventId, start, { eventId?, start, end })`.
|
|
10
|
+
|
|
11
|
+
## 0.3.43
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 8e9263a: Forms can email staff about new responses. On a form's **Share** tab, **Notifications** (off for every form, including existing ones) has **Email new responses** with a list of up to 10 addresses; the first time, it offers the contact email in Settings → Legal, or the owners' addresses. Each response sent from the website or the app emails everyone on the list, in the site's main language: "New response: Prayer requests", when it arrived, every question with its answer in order, an **Open responses in the admin** button, and where it came from and why they get it. When the form asks for an email address, replying writes to the person. A spam-trap, rate-limited or refused response, or a retry of one already saved, emails no one.
|
|
16
|
+
|
|
17
|
+
**Include the answers in the email** is on by default. Turn it off for forms that ask for sensitive information, such as prayer requests, health details or children's names: the email then only says a response arrived and when, with nothing about the person and no reply address. Turning it off also covers alerts still waiting to go out, and a deleted response or an address taken off the list drops its alert. With push notifications on, **Also notify their linked phones** sends staff on the list who linked a phone under Test phones a push, "New response: Prayer requests" / "Received Mar 14, 3:41 PM", that never carries answers and opens nothing; the delivery log shows it as "New form response". A form sends at most 50 alerts a day; the 50th says so, and later responses that day wait in Responses.
|
|
18
|
+
|
|
19
|
+
Alerts need email set up and **Staff notifications** on in Email settings, which now also turns these off (including ones already queued); the Share tab says when alerts can't go out. Saving them needs **Manage forms**, and emailing the answers also needs **View form responses**, so the list can't send anyone responses they couldn't read in the admin. Event registration forms are unchanged: their hosts get the RSVP email. Environment sync leaves the alert settings out; backups keep them. Push data `source` can now be `form`. Migration 63 adds `forms.alerts`.
|
|
20
|
+
|
|
21
|
+
The privacy notice now says that, on sites with email, responses or a notice that one arrived may be emailed to the staff who handle that form, and that staff phones linked for tests also get notices that a form response arrived, so its "Last updated" date moves.
|
|
22
|
+
|
|
3
23
|
## 0.3.42
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
|
@@ -1131,6 +1131,11 @@ var MIGRATIONS = [
|
|
|
1131
1131
|
`CREATE INDEX push_devices_staff ON push_devices (staff_user_id)`,
|
|
1132
1132
|
`CREATE TABLE push_link_codes (code_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
1133
1133
|
`CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`
|
|
1134
|
+
] },
|
|
1135
|
+
// staff alerts for a form's new responses (SavedForm.alerts: who gets them,
|
|
1136
|
+
// with or without the answers); '{}' = off. Not public, so no cache trigger.
|
|
1137
|
+
{ version: 63, statements: [
|
|
1138
|
+
`ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`
|
|
1134
1139
|
] }
|
|
1135
1140
|
];
|
|
1136
1141
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
package/package.json
CHANGED
|
@@ -477,7 +477,7 @@ export default function EmailSettings() {
|
|
|
477
477
|
</label>
|
|
478
478
|
<label className="ems-toggle">
|
|
479
479
|
<input type="checkbox" checked={notify.staff} onChange={(e) => setNotify((n) => ({ ...n, staff: e.target.checked }))} />
|
|
480
|
-
<span>Staff notifications<small>Tell site staff about new
|
|
480
|
+
<span>Staff notifications<small>Tell site staff about new bookings, RSVPs, event requests and form responses</small></span>
|
|
481
481
|
</label>
|
|
482
482
|
{pwField(notify.pw, (v) => setNotify((n) => ({ ...n, pw: v })), 'pw-notify')}
|
|
483
483
|
<div className="ems-actions">
|
|
@@ -38,7 +38,7 @@ function ago(ms: number, now: number) {
|
|
|
38
38
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed', test: 'Test by' };
|
|
41
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed', test: 'Test by', form: 'New form response' };
|
|
42
42
|
|
|
43
43
|
export default function MobileApp() {
|
|
44
44
|
const [state, setState] = useState<AdminPushState | null>(null);
|
|
@@ -462,7 +462,7 @@ function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel:
|
|
|
462
462
|
{n.body && <div className="ma-body">{n.body}</div>}
|
|
463
463
|
{n.url && <div className="t-sub ma-url">{n.url}</div>}
|
|
464
464
|
</td>
|
|
465
|
-
<td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.topics.map(topicLabel).join(', ')}</td>
|
|
465
|
+
<td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.source === 'form' ? 'Staff on the form’s alert list' : n.topics.map(topicLabel).join(', ')}</td>
|
|
466
466
|
<td data-l="From">{SOURCE[n.source]}{(n.source === 'manual' || n.source === 'test') && n.author ? ` ${n.author}` : n.source === 'manual' || n.source === 'test' ? ' an owner' : ''}</td>
|
|
467
467
|
<td data-l="Delivered" className="num ma-result">
|
|
468
468
|
{n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
|
|
@@ -84,7 +84,7 @@ export default function TestPhones({ embedded = false, onPhones }: { embedded?:
|
|
|
84
84
|
{!embedded && (
|
|
85
85
|
<header className="tp-head">
|
|
86
86
|
<h2 id={`${uid}-h`}>Test phones</h2>
|
|
87
|
-
<p>Link your own phone to see a notification on it before it goes out. Only you get your tests.</p>
|
|
87
|
+
<p>Link your own phone to see a notification on it before it goes out. Only you get your tests. Forms that email you about new responses can notify it too.</p>
|
|
88
88
|
</header>
|
|
89
89
|
)}
|
|
90
90
|
{loadError ? <p className="flash error" role="alert">{loadError}</p> : !state ? <p className="hint">Loading…</p> : !state.canLink ? (
|
|
@@ -164,7 +164,7 @@ export function SendTest({ send, problem }: { send: () => Promise<PushNotificati
|
|
|
164
164
|
<div className="modal-head"><span id={`${uid}-h`}>Test phones</span>
|
|
165
165
|
<button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
|
|
166
166
|
<div className="modal-body">
|
|
167
|
-
<p className="hint tp-intro">Link your own phone to see a notification on it before it goes out. Only you get your tests.</p>
|
|
167
|
+
<p className="hint tp-intro">Link your own phone to see a notification on it before it goes out. Only you get your tests. Forms that email you about new responses can notify it too.</p>
|
|
168
168
|
{/* loads on mount (the dialog's content is always there) and says how many are linked */}
|
|
169
169
|
<TestPhones embedded onPhones={setPhones} />
|
|
170
170
|
<div className="modal-foot">
|
|
@@ -121,7 +121,9 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
|
|
|
121
121
|
|
|
122
122
|
// Move one occurrence of a repeating event: skip that date in the series and
|
|
123
123
|
// add a one-off copy on the new date. No schema change, and the copy is a
|
|
124
|
-
// normal event the editor can open.
|
|
124
|
+
// normal event the editor can open. The copy keeps the series' hosts, room
|
|
125
|
+
// hold and translations, and that date's RSVPs move to it (people get the
|
|
126
|
+
// "new time" email).
|
|
125
127
|
export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
|
|
126
128
|
const e = await repo.getEvent(id);
|
|
127
129
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
@@ -139,16 +141,22 @@ export async function moveOccurrence(repo: ContentRepository, id: string, occurr
|
|
|
139
141
|
const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
|
|
140
142
|
const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
|
|
141
143
|
|
|
144
|
+
const settings = await repo.getSettings();
|
|
145
|
+
const was = findOccurrence(e, occurrence, settings);
|
|
142
146
|
await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
|
|
143
147
|
const copy = await repo.createEvent({
|
|
144
148
|
title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
|
|
145
149
|
timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
|
|
146
|
-
location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
|
|
150
|
+
seoTitle: e.seoTitle, seoDesc: e.seoDesc, location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags,
|
|
147
151
|
// a cancelled date stays cancelled on its new day
|
|
148
152
|
cancelled: e.cancelled || e.cancelledDates.includes(occurrence), rsvp: e.rsvp,
|
|
149
|
-
template: e.template, featured: false,
|
|
153
|
+
template: e.template, featured: false, roomHold: e.roomHold, hostIds: e.hostIds, translations: e.translations,
|
|
150
154
|
});
|
|
151
155
|
const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
|
|
156
|
+
const now = findOccurrence(event, event.start, settings);
|
|
157
|
+
if (was && now && await repo.moveOccurrenceRsvps(e.id, was.start, { eventId: event.id, start: now.start, end: now.end })) {
|
|
158
|
+
await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
|
|
159
|
+
}
|
|
152
160
|
return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
|
|
153
161
|
}
|
|
154
162
|
|
|
@@ -157,13 +165,20 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
|
|
|
157
165
|
await moveSeries(repo, undo.id, undo.days, hooks);
|
|
158
166
|
return;
|
|
159
167
|
}
|
|
160
|
-
|
|
168
|
+
let series = await repo.getEvent(undo.seriesId);
|
|
161
169
|
const copy = await repo.getEvent(undo.id);
|
|
170
|
+
if (series) series = await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
|
|
162
171
|
if (copy) {
|
|
172
|
+
// the RSVPs go back to the series' date before the copy (and its RSVPs) goes
|
|
173
|
+
const settings = await repo.getSettings();
|
|
174
|
+
const back = series && findOccurrence(series, undo.occurrence, settings);
|
|
175
|
+
const from = findOccurrence(copy, copy.start, settings);
|
|
176
|
+
if (back && from && await repo.moveOccurrenceRsvps(copy.id, from.start, { eventId: series!.id, start: back.start, end: back.end })) {
|
|
177
|
+
await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
|
|
178
|
+
}
|
|
163
179
|
await repo.deleteEvent(copy.id);
|
|
164
180
|
await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
|
|
165
181
|
}
|
|
166
|
-
if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
// ---- cancel one date ----------------------------------------------------------
|
package/src/lib/email/service.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLin
|
|
|
5
5
|
import { formatDate, siteLang } from '../dates';
|
|
6
6
|
import { I18N } from '../i18n';
|
|
7
7
|
import { bookingMail } from '../booking/service';
|
|
8
|
+
import { formAlertMail, type AlertPayload } from '../forms/alert-mail';
|
|
8
9
|
import type { EmailJob, Mail } from './types';
|
|
9
10
|
|
|
10
11
|
async function render(repo: ContentRepository, job: EmailJob, locals?: unknown, from = ''): Promise<Mail | null> {
|
|
@@ -37,6 +38,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
37
38
|
const mail = await bookingMail(repo, b, payload.kind, origin, from, payload, job.recipient);
|
|
38
39
|
return { ...mail, to: job.recipient };
|
|
39
40
|
}
|
|
41
|
+
if (job.kind === 'form-alert') return formAlertMail(repo, job.recipient, JSON.parse(job.payload) as AlertPayload, origin);
|
|
40
42
|
if (job.kind === 'auth') {
|
|
41
43
|
const payload = JSON.parse(job.payload);
|
|
42
44
|
const token = await repo.email.token(payload.tokenId);
|
|
@@ -88,6 +90,9 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
88
90
|
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
89
91
|
}
|
|
90
92
|
}
|
|
93
|
+
if (j.kind === 'form-alert' && !current.config.staffNotifications) {
|
|
94
|
+
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
95
|
+
}
|
|
91
96
|
const mail = await render(repo,j,locals,resolved.config.from);
|
|
92
97
|
if (!mail) { await repo.email.finish(j,{state:'cancelled',error:'Notification no longer applies.'}); continue; }
|
|
93
98
|
// Retries must reuse exactly the same request body, including calendar
|
package/src/lib/email/store.ts
CHANGED
|
@@ -123,10 +123,14 @@ 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);
|
|
130
134
|
}
|
|
131
135
|
/** gives one hit back (an attempt counted up front that turned out fine) */
|
|
132
136
|
async rateRefund(key: string): Promise<void> {
|
|
@@ -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.
|
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. Our staff can link their own phone to their account to receive test notifications; that link is removed when they remove it or turn notifications off.',
|
|
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. Las personas de nuestro equipo pueden vincular su propio teléfono a su cuenta del sitio para recibir notificaciones de prueba; ese vínculo se elimina cuando lo quitan o desactivan las notificaciones.',
|
|
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/push/types.ts
CHANGED
|
@@ -50,8 +50,12 @@ export interface PushDevice {
|
|
|
50
50
|
/** the staff member who linked this phone for test sends (null = not linked) */
|
|
51
51
|
staffUserId: string | null; staffLinkedAt: number | null;
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* test: "Send test to my phone", to the sender's linked phones only; form: a
|
|
55
|
+
* new form response, to the linked phones of staff on its alert list. Neither
|
|
56
|
+
* counts in the weekly pace.
|
|
57
|
+
*/
|
|
58
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form';
|
|
55
59
|
export interface PushNotification {
|
|
56
60
|
id: string; title: string; body: string; url: string;
|
|
57
61
|
/** ['*'] = everyone */
|
|
@@ -705,6 +705,11 @@ export const MIGRATIONS: Migration[] = [
|
|
|
705
705
|
`CREATE TABLE push_link_codes (code_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
706
706
|
`CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`,
|
|
707
707
|
] },
|
|
708
|
+
// staff alerts for a form's new responses (SavedForm.alerts: who gets them,
|
|
709
|
+
// with or without the answers); '{}' = off. Not public, so no cache trigger.
|
|
710
|
+
{ version: 63, statements: [
|
|
711
|
+
`ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`,
|
|
712
|
+
] },
|
|
708
713
|
];
|
|
709
714
|
|
|
710
715
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -847,8 +847,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
847
847
|
await this.driver.run(
|
|
848
848
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
849
849
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
850
|
-
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
|
|
851
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
850
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations)
|
|
851
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
852
852
|
[
|
|
853
853
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
854
854
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -857,7 +857,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
857
857
|
JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
|
|
858
858
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
859
859
|
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
|
|
860
|
-
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null,
|
|
860
|
+
JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
|
|
861
861
|
]
|
|
862
862
|
);
|
|
863
863
|
await this.syncEventIndex(id);
|
|
@@ -965,6 +965,19 @@ export class SqlRepository implements ContentRepository {
|
|
|
965
965
|
]);
|
|
966
966
|
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
|
|
967
967
|
}
|
|
968
|
+
async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }) {
|
|
969
|
+
await this.ready;
|
|
970
|
+
const target = to.eventId || id;
|
|
971
|
+
// one mutation id marks every moved row; only the seated ones are emailed
|
|
972
|
+
const mutation = crypto.randomUUID();
|
|
973
|
+
const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
|
|
974
|
+
await this.driver.batch([
|
|
975
|
+
{sql:`UPDATE bookings SET event_id=?,start_at=?,end_at=?,hold_start=?,hold_end=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
|
|
976
|
+
WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[target,to.start,to.end,to.start,to.end,mutation,Date.now(),id,start]},
|
|
977
|
+
...notifications,
|
|
978
|
+
]);
|
|
979
|
+
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [target, mutation]))?.n ?? 0);
|
|
980
|
+
}
|
|
968
981
|
async discardEventDraft(id: string) {
|
|
969
982
|
await this.ready;
|
|
970
983
|
await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -155,6 +155,13 @@ export interface ContentRepository {
|
|
|
155
155
|
setEventCancelledDates(id: string, dates: string[]): Promise<void>;
|
|
156
156
|
/** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
|
|
157
157
|
cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
|
|
158
|
+
/**
|
|
159
|
+
* Moves the RSVPs for one date of an event (those starting at `start`) to a
|
|
160
|
+
* new time, and to another event when `to.eventId` is set. Pending and
|
|
161
|
+
* confirmed ones get the "new time" email; places in line move quietly.
|
|
162
|
+
* Returns how many moved.
|
|
163
|
+
*/
|
|
164
|
+
moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
|
|
158
165
|
deleteEvent(id: string): Promise<void>;
|
|
159
166
|
// saved-place sync (lib/maps/places.ts): rewrite just the location of the
|
|
160
167
|
// live row and/or the staged draft, leaving every other field — and the
|
package/src/lib/sync/catalog.ts
CHANGED
|
@@ -50,7 +50,8 @@ export const schemas = {
|
|
|
50
50
|
}).strict(),
|
|
51
51
|
booking: BookingLink.omit(common).strict(),
|
|
52
52
|
place: Place.omit(common).strict(),
|
|
53
|
-
form
|
|
53
|
+
// who gets a form's alerts belongs to one environment, like notifyEmails
|
|
54
|
+
form: SavedForm.omit({ ...common, draft: true, revision: true, alerts: true }).strict(),
|
|
54
55
|
media: Media.omit({ id: true, createdAt: true })
|
|
55
56
|
.extend({
|
|
56
57
|
objects: z
|
|
@@ -6,8 +6,10 @@ import { readForm } from '../../../../lib/forms/http';
|
|
|
6
6
|
import { escapeForm } from '../../../../lib/forms/fields';
|
|
7
7
|
import { siteOrigin } from '../../../../lib/seo';
|
|
8
8
|
import { CONTENT } from '../../../../lib/content';
|
|
9
|
+
import { alertBlocker, saveFormAlerts, suggestedRecipients } from '../../../../lib/forms/alerts';
|
|
10
|
+
import { PUSH } from '../../../../lib/push/config';
|
|
9
11
|
import '../../../../styles/forms-admin.css';
|
|
10
|
-
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
12
|
+
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
11
13
|
|
|
12
14
|
const repo = await getRepo(Astro.locals);
|
|
13
15
|
if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'forms.manage'); if (denied) return denied; }
|
|
@@ -15,20 +17,40 @@ let form = await repo.forms.get(Astro.params.id || '');
|
|
|
15
17
|
if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
|
|
16
18
|
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
17
19
|
|
|
18
|
-
let error = '', notice = '';
|
|
20
|
+
let error = '', notice = '', alertError = '', alertNotice = '';
|
|
21
|
+
// what the Notifications form shows: saved settings, or what was just posted when it didn't save
|
|
22
|
+
let posted: FormData | null = null;
|
|
19
23
|
if (Astro.request.method === 'POST') {
|
|
24
|
+
let op = '';
|
|
20
25
|
try {
|
|
21
26
|
const data = await readForm(Astro.request);
|
|
22
|
-
|
|
23
|
-
if (op
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
op = String(data.get('op') || '');
|
|
28
|
+
if (op === 'alerts') {
|
|
29
|
+
posted = data;
|
|
30
|
+
await saveFormAlerts(repo, await accessFor(Astro.request, Astro.cookies, repo), form, data);
|
|
31
|
+
form = (await repo.forms.get(form.id))!;
|
|
32
|
+
posted = null;
|
|
33
|
+
alertNotice = form.alerts.enabled ? 'Saved. New responses are emailed to this list.' : 'Saved. New responses aren’t emailed.';
|
|
34
|
+
} else {
|
|
35
|
+
if (op !== 'publish' && op !== 'archive') throw new Error('Unknown action.');
|
|
36
|
+
// Same store operation as the Build tab: publish promotes pending edits.
|
|
37
|
+
await repo.forms.save(form.id, Number(data.get('revision')), form.name, form.draft ?? form.definition, op);
|
|
38
|
+
form = (await repo.forms.get(form.id))!;
|
|
39
|
+
notice = op === 'publish' ? 'Published. The form is accepting responses.' : 'Archived. The form no longer accepts responses; existing responses are kept.';
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
const message = e instanceof Error ? e.message : 'Could not update the form.';
|
|
43
|
+
if (op === 'alerts') alertError = message; else error = message;
|
|
44
|
+
}
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
const [settings, stats, used] = await Promise.all([repo.getSettings(), repo.forms.submissionStats(form.id, 0), repo.forms.usedOn(form.id)]);
|
|
48
|
+
// never set up: offer the contact email (Settings → Legal), else the owners'
|
|
49
|
+
const fresh = !form.alerts.enabled && !form.alerts.to.length;
|
|
50
|
+
const alerts = posted
|
|
51
|
+
? { enabled: posted.get('enabled') === '1', to: String(posted.get('to') ?? ''), answers: posted.get('answers') === '1', phones: posted.get('phones') === '1' }
|
|
52
|
+
: { ...form.alerts, to: (fresh ? await suggestedRecipients(repo, settings) : form.alerts.to).join('\n') };
|
|
53
|
+
const blocker = alerts.enabled ? await alertBlocker(repo, settings, Astro.locals) : null;
|
|
32
54
|
const origin = siteOrigin(settings, Astro.url);
|
|
33
55
|
const link = `${origin}/forms/${form.id}`;
|
|
34
56
|
const title = escapeForm(form.definition.title || form.name);
|
|
@@ -101,9 +123,24 @@ const copyIcon = 'M9 9h10v10H9zM5 15V5h10';
|
|
|
101
123
|
</div>
|
|
102
124
|
</section>
|
|
103
125
|
|
|
104
|
-
<section class="fs-sec">
|
|
105
|
-
<div class="fs-label"><b>Notifications</b></div>
|
|
106
|
-
<div class="fs-body"
|
|
126
|
+
<section class="fs-sec" id="notifications">
|
|
127
|
+
<div class="fs-label"><b>Notifications</b><small>Email staff when someone sends this form, from the website or the app.</small></div>
|
|
128
|
+
<div class="fs-body">
|
|
129
|
+
{alertError && <p class="notice warn" role="alert">{alertError}</p>}
|
|
130
|
+
{alertNotice && <p class="notice" role="status">{alertNotice}</p>}
|
|
131
|
+
<form method="post" action="#notifications" class="fs-alerts">
|
|
132
|
+
<input type="hidden" name="op" value="alerts" />
|
|
133
|
+
<label class="check-row"><input type="checkbox" name="enabled" value="1" checked={alerts.enabled} /><span>Email new responses</span></label>
|
|
134
|
+
<div class="fs-alerts-more">
|
|
135
|
+
<label class="field"><span>Send to</span><textarea name="to" rows="3" spellcheck="false" placeholder="office@example.org">{alerts.to}</textarea><small class="hint">Up to 10 email addresses, one per line.</small></label>
|
|
136
|
+
<label class="check-row"><input type="checkbox" name="answers" value="1" checked={alerts.answers} /><span>Include the answers in the email<small class="hint">Turn this off for forms that ask for sensitive information, such as prayer requests, health details or children’s names. The email then only says a response arrived.</small></span></label>
|
|
137
|
+
{PUSH.enabled && <label class="check-row"><input type="checkbox" name="phones" value="1" checked={alerts.phones} /><span>Also notify their linked phones<small class="hint">People on this list with a Team account who linked a phone under Test phones get a notification with the form’s name and the time, never the answers.</small></span></label>}
|
|
138
|
+
<p class="hint">Up to 50 alerts a day for this form; the 50th says so, and later responses wait in Responses.</p>
|
|
139
|
+
</div>
|
|
140
|
+
{blocker && <p class="notice warn"><span>{blocker.text} <a href={blocker.href}>{blocker.link}</a></span></p>}
|
|
141
|
+
<div class="fs-row"><button class="btn primary">Save notifications</button></div>
|
|
142
|
+
</form>
|
|
143
|
+
</div>
|
|
107
144
|
</section>
|
|
108
145
|
</div>
|
|
109
146
|
</Admin>
|
|
@@ -4,6 +4,7 @@ import { getRepo } from '../../lib/repo';
|
|
|
4
4
|
import { renderForm } from '../../lib/forms/render';
|
|
5
5
|
import { collectResponse } from '../../lib/forms/service';
|
|
6
6
|
import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
|
|
7
|
+
import { saveResponse } from '../../lib/forms/alerts';
|
|
7
8
|
import { ApiError } from '../../lib/api';
|
|
8
9
|
const repo = await getRepo(Astro.locals);
|
|
9
10
|
const form = await repo.forms.get(Astro.params.id || '');
|
|
@@ -18,7 +19,7 @@ if (Astro.request.method === 'POST') {
|
|
|
18
19
|
requestId = await submissionGuard(repo, data, Astro.clientAddress, `form:${form.id}`);
|
|
19
20
|
values = readAnswers(data, form.definition);
|
|
20
21
|
const response = await collectResponse(form.definition, values, String(data.get('formVersion') || ''));
|
|
21
|
-
await repo
|
|
22
|
+
await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
|
|
22
23
|
success = true;
|
|
23
24
|
} catch (e) { error = e instanceof ApiError ? e.message : 'Could not save your response. Please try again.'; Astro.response.status = e instanceof ApiError ? e.status : 500; }
|
|
24
25
|
}
|
|
@@ -153,6 +153,14 @@ input.mono { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12.5px;
|
|
|
153
153
|
.fs-used small { color: var(--muted); font-size: 12px; }
|
|
154
154
|
.fs-used .status { font-size: 12px; }
|
|
155
155
|
.fs-none { margin-top: 0; }
|
|
156
|
+
.fs-alerts { max-width: 560px; }
|
|
157
|
+
.fs-alerts .check-row { margin: 0 0 12px; }
|
|
158
|
+
.fs-alerts .check-row small { display: block; margin-top: 2px; }
|
|
159
|
+
.fs-alerts .notice { margin: 0 0 12px; }
|
|
160
|
+
/* the list and its options only matter while emails are on */
|
|
161
|
+
.fs-alerts:not(:has(input[name="enabled"]:checked)) .fs-alerts-more { display: none; }
|
|
162
|
+
.fs-alerts-more { padding-left: 23px; }
|
|
163
|
+
.fs-alerts-more > .hint { margin: 0 0 12px; }
|
|
156
164
|
|
|
157
165
|
@media (max-width: 767px) {
|
|
158
166
|
.fh-row { gap: 8px 10px; }
|