@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/migrations.generated.mjs +15 -0
  3. package/docs/mobile.md +10 -1
  4. package/integration.mjs +2 -0
  5. package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
  6. package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
  7. package/mobile-template/recipes/push/recipe.json +4 -2
  8. package/package.json +1 -1
  9. package/src/components/Announcements.tsx +11 -2
  10. package/src/components/EmailSettings.tsx +1 -1
  11. package/src/components/MobileApp.tsx +20 -8
  12. package/src/components/TestPhones.tsx +177 -0
  13. package/src/lib/announcements/api.ts +19 -2
  14. package/src/lib/announcements/service.ts +1 -1
  15. package/src/lib/email/service.ts +19 -2
  16. package/src/lib/email/store.ts +9 -1
  17. package/src/lib/email/templates.ts +20 -0
  18. package/src/lib/forms/alert-mail.ts +124 -0
  19. package/src/lib/forms/alerts.ts +137 -0
  20. package/src/lib/forms/schema.ts +15 -1
  21. package/src/lib/forms/store.ts +26 -8
  22. package/src/lib/legal/app-privacy.ts +2 -2
  23. package/src/lib/legal/text-en.ts +2 -2
  24. package/src/lib/legal/text-es.ts +2 -2
  25. package/src/lib/mobile/forms.ts +2 -1
  26. package/src/lib/mobile/guest.ts +2 -1
  27. package/src/lib/mobile/openapi.ts +18 -3
  28. package/src/lib/mobile/schemas.ts +4 -0
  29. package/src/lib/push/api.ts +37 -8
  30. package/src/lib/push/service.ts +6 -2
  31. package/src/lib/push/staff.ts +162 -0
  32. package/src/lib/push/store.ts +49 -2
  33. package/src/lib/push/types.ts +8 -1
  34. package/src/lib/repo/migrations.ts +15 -0
  35. package/src/lib/repo/sql.ts +4 -1
  36. package/src/lib/sync/catalog.ts +2 -1
  37. package/src/pages/admin/forms/[id]/share.astro +49 -12
  38. package/src/pages/api/announcements.ts +2 -2
  39. package/src/pages/api/push/admin.ts +1 -1
  40. package/src/pages/api/push/phones.ts +25 -0
  41. package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
  42. package/src/pages/forms/[id].astro +2 -1
  43. package/src/styles/forms-admin.css +8 -0
  44. package/src/styles/test-phones.css +30 -0
@@ -120,11 +120,15 @@ export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'da
120
120
  export const BookingResult = z.object({ manageToken: str, booking: Booking.pick({
121
121
  id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true,
122
122
  }).extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date }) });
123
+ // a staff member's test phone: their display name only (never an email)
124
+ const LinkedStaff = z.object({ name: str }).nullable();
123
125
  export const Device = z.object({
124
126
  token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, channels: strings, registeredAt: date, lastSeenAt: date,
127
+ staff: LinkedStaff,
125
128
  });
126
129
  export const DeviceRegistration = z.object({ created: bool, device: Device });
127
130
  export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
131
+ export const StaffLink = z.object({ linked: bool, staff: LinkedStaff });
128
132
  export const PushTopics = z.object({ locale, topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar', 'site']), description: str.optional() })) });
129
133
  export const Announcement = z.object({
130
134
  id: str, kind: z.enum(['event', 'registration', 'help', 'info', 'urgent']), kindLabel: str,
@@ -1,5 +1,5 @@
1
- // Public device endpoints (/api/v1/devices, /api/v1/push/topics) and the
2
- // owner-only admin state/actions behind Admin → Mobile app.
1
+ // Public device endpoints (/api/v1/devices, /api/v1/push/topics, a staff
2
+ // member's test-phone link) and the admin state/actions behind Admin → Mobile app.
3
3
  import { z } from 'zod';
4
4
  import type { ContentRepository } from '../repo/types';
5
5
  import { ApiError, readJson } from '../api';
@@ -12,6 +12,7 @@ import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
12
12
  import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
13
13
  import { expoOptions } from './expo';
14
14
  import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
15
+ import { linkPhone, sendTest } from './staff';
15
16
  import { MAX_CHANGE_RULES, PushAuto, type PushDevice } from './types';
16
17
 
17
18
  export const pushToken = z.string().max(250).regex(/^Expo(?:nent)?PushToken\[[A-Za-z0-9_-]{8,200}\]$/, 'Use an Expo push token such as ExponentPushToken[…].');
@@ -29,11 +30,16 @@ export const deviceInput = z.object({
29
30
  }).strict();
30
31
  export const deviceUpdate = z.object({ locale: locale.optional(), topics: topicList.optional(), channels: channelList.optional() }).strict();
31
32
 
32
- export const deviceJson = (d: PushDevice) => ({
33
+ /** `staff`: who linked it as a test phone (their name only), or null */
34
+ export const deviceJson = (d: PushDevice, staff: { name: string } | null = null) => ({
33
35
  token: d.token, platform: d.platform, locale: d.locale || null, topics: d.topics, channels: d.channels,
34
- registeredAt: new Date(d.createdAt).toISOString(), lastSeenAt: new Date(d.lastSeenAt).toISOString(),
36
+ registeredAt: new Date(d.createdAt).toISOString(), lastSeenAt: new Date(d.lastSeenAt).toISOString(), staff,
35
37
  });
36
38
  const deviceId = (token: string) => sha256Hex(`expo-push:${token}`);
39
+ async function staffOf(repo: ContentRepository, d: PushDevice) {
40
+ const user = d.staffUserId ? await repo.getUser(d.staffUserId) : null;
41
+ return user ? { name: user.name } : null;
42
+ }
37
43
 
38
44
  async function checkTopics(repo: ContentRepository, topics: string[] | undefined) {
39
45
  if (!topics) return undefined;
@@ -56,7 +62,7 @@ export async function registerDevice(repo: ContentRepository, request: Request)
56
62
  const body = deviceInput.parse(await readJson(request, 8_000));
57
63
  const topics = await checkTopics(repo, body.topics);
58
64
  const { device, created } = await repo.push.upsertDevice({ id: await deviceId(body.token), token: body.token, platform: body.platform, locale: body.locale ?? '', topics, channels: body.channels && [...new Set(body.channels)] }, Date.now());
59
- return { created, device: deviceJson(device) };
65
+ return { created, device: deviceJson(device, await staffOf(repo, device)) };
60
66
  }
61
67
  export async function updateDevice(repo: ContentRepository, request: Request, rawToken: string) {
62
68
  requirePush();
@@ -65,15 +71,30 @@ export async function updateDevice(repo: ContentRepository, request: Request, ra
65
71
  if (body.locale === undefined && body.topics === undefined && body.channels === undefined) throw new ApiError(400, 'Send topics, locale, channels or a mix.');
66
72
  const device = await repo.push.updateDevice(await deviceId(token), { locale: body.locale, topics: await checkTopics(repo, body.topics), channels: body.channels && [...new Set(body.channels)] }, Date.now());
67
73
  if (!device) throw new ApiError(404, 'This device is not registered. Register it with POST /api/v1/devices.');
68
- return deviceJson(device);
74
+ return deviceJson(device, await staffOf(repo, device));
69
75
  }
70
76
  export async function unregisterDevice(repo: ContentRepository, rawToken: string) {
71
77
  requirePush();
72
78
  const token = tokenParam(rawToken);
73
- // idempotent: an unknown token is already opted out
79
+ // idempotent: an unknown token is already opted out (a test-phone link goes with the row)
74
80
  await repo.push.deleteDevices([await deviceId(token)]);
75
81
  return { token, deleted: true };
76
82
  }
83
+
84
+ export const staffLinkInput = z.object({ code: z.string().trim().regex(/^\d{6}$/, 'Enter the 6-digit code from the admin.') }).strict();
85
+ /** POST /devices/{token}/staff-link: this phone gets the code's staff member's test pushes. */
86
+ export async function linkDevice(repo: ContentRepository, request: Request, rawToken: string, address: string, locals?: unknown) {
87
+ requirePush();
88
+ const token = tokenParam(rawToken);
89
+ const { code } = staffLinkInput.parse(await readJson(request, 1_000));
90
+ return linkPhone({ repo, deviceId: await deviceId(token), address, locals }, code);
91
+ }
92
+ /** DELETE /devices/{token}/staff-link (idempotent). */
93
+ export async function unlinkDevice(repo: ContentRepository, rawToken: string) {
94
+ requirePush();
95
+ await repo.push.unlinkStaff(await deviceId(tokenParam(rawToken)));
96
+ return { linked: false as const, staff: null };
97
+ }
77
98
  export async function listPushTopics(repo: ContentRepository, url: URL) {
78
99
  requirePush();
79
100
  const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
@@ -128,6 +149,8 @@ const link = z.string().trim().max(2000).refine((v) => !v || (/^\/(?!\/)/.test(v
128
149
  export const AdminPushInput = z.discriminatedUnion('action', [
129
150
  z.object({ action: z.literal('count'), audience }).strict(),
130
151
  z.object({ action: z.literal('send'), title: z.string().trim().min(1, 'Add a title.').max(80), body: z.string().trim().max(240).default(''), url: link.default(''), audience }).strict(),
152
+ // "Send test to my phone": the sender's linked phones only
153
+ z.object({ action: z.literal('test'), title: z.string().trim().min(1, 'Add a title.').max(80), body: z.string().trim().max(240).default(''), url: link.default('') }).strict(),
131
154
  z.object({ action: z.literal('saveAuto'), auto: PushAuto }).strict(),
132
155
  ]);
133
156
 
@@ -139,7 +162,7 @@ async function checkAudience(repo: ContentRepository, list: string[]) {
139
162
  return [...new Set(list)];
140
163
  }
141
164
 
142
- export async function adminPushAction(repo: ContentRepository, input: z.infer<typeof AdminPushInput>, ctx: { url: URL; locals?: unknown; author: string; run?: PushRunOptions }) {
165
+ export async function adminPushAction(repo: ContentRepository, input: z.infer<typeof AdminPushInput>, ctx: { url: URL; locals?: unknown; author: string; userId?: string; run?: PushRunOptions }) {
143
166
  switch (input.action) {
144
167
  case 'count':
145
168
  return { recipients: await repo.push.countAudience(await checkAudience(repo, input.audience)) };
@@ -151,6 +174,12 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
151
174
  }, { locals: ctx.locals, origin: siteOriginOf(settings, ctx.url.origin), ...ctx.run });
152
175
  return { notification };
153
176
  }
177
+ case 'test': {
178
+ const settings = await repo.getSettings();
179
+ const notification = await sendTest(repo, { userId: ctx.userId ?? '', author: ctx.author, title: input.title, body: input.body, url: input.url },
180
+ { locals: ctx.locals, origin: siteOriginOf(settings, ctx.url.origin), ...ctx.run });
181
+ return { notification };
182
+ }
154
183
  case 'saveAuto': {
155
184
  const collections = new Set(Object.keys(CONTENT.collections));
156
185
  const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
@@ -133,6 +133,10 @@ export interface Composed { title: string; body: string; url: string }
133
133
  export interface SendInput extends Composed {
134
134
  /** topic ids, or ['*'] for every device */
135
135
  audience: string[];
136
+ /** these devices (ids) instead of the audience: a staff member's test phones */
137
+ devices?: string[];
138
+ /** every device gets this site language (null = the main one) instead of its own: a test in the language being previewed */
139
+ locale?: string | null;
136
140
  source: PushSource;
137
141
  sourceKey?: string;
138
142
  author?: string;
@@ -176,7 +180,7 @@ export function notificationLink(raw: string, origin: string, locale: string | n
176
180
  export async function sendNotification(repo: ContentRepository, input: SendInput, opts: PushRunOptions = {}): Promise<PushNotification> {
177
181
  const now = opts.now ?? Date.now();
178
182
  const origin = opts.origin ?? siteOriginOf(await repo.getSettings(), opts.fallbackOrigin);
179
- const devices = await repo.push.audience(input.audience);
183
+ const devices = input.devices ? await repo.push.devicesByIds(input.devices) : await repo.push.audience(input.audience);
180
184
  const n: PushNotification = {
181
185
  id: crypto.randomUUID(), title: input.title, body: input.body, url: input.url, topics: input.audience,
182
186
  source: input.source, sourceKey: input.sourceKey ?? '', status: 'sending', recipients: devices.length,
@@ -185,7 +189,7 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
185
189
  await repo.push.createNotification(n);
186
190
  const words = new Map<string, Composed>();
187
191
  const messages: ExpoMessage[] = devices.map((d) => {
188
- const locale = deviceLocale(d.locale);
192
+ const locale = input.locale !== undefined ? input.locale : deviceLocale(d.locale);
189
193
  let c: Composed = input;
190
194
  if (locale) {
191
195
  if (!words.has(locale)) words.set(locale, input.localize?.(locale) ?? input);
@@ -0,0 +1,162 @@
1
+ // Staff test phones: a staff member links their own phone once (a one-time
2
+ // code from the admin's Test phones panel, typed into the app), then "Send
3
+ // test to my phone" in the announcement and notification composers pushes the
4
+ // draft to their linked phones only. Codes are 6 digits, last 10 minutes and
5
+ // work once; only their SHA-256 is stored. Guessing is capped per device
6
+ // token, per client address and across the site.
7
+ import { z } from 'zod';
8
+ import type { ContentRepository } from '../repo/types';
9
+ import { ApiError } from '../api';
10
+ import { sha256Hex } from '../api-keys';
11
+ import { OWNER_ENV } from '../auth';
12
+ import { I18N } from '../i18n';
13
+ import { resolveConfig } from '../email/config';
14
+ import { emailReadiness } from '../email/readiness';
15
+ import { kickEmail } from '../email/service';
16
+ import { sixDigits } from '../mobile/guest';
17
+ import { waitUntilFrom } from '../webhooks';
18
+ import { ANNOUNCEMENTS } from '../announcements/config';
19
+ import { requirePush } from './config';
20
+ import { sendNotification, type PushRunOptions } from './service';
21
+ import type { PushDevice } from './types';
22
+
23
+ const MINUTE = 60_000, HOUR = 60 * MINUTE;
24
+ export const LINK_CODE_MS = 10 * MINUTE;
25
+ const WINDOW = 10 * MINUTE;
26
+ /** link attempts per device token and per client address in 10 minutes */
27
+ export const LINK_TRIES = 5;
28
+ /** wrong codes across the site in 10 minutes before every waiting code stops working */
29
+ export const SITE_LINK_TRIES = 20;
30
+ export const TESTS_PER_HOUR = 10;
31
+ const SITE_KEY = 'push:link:site';
32
+ // one answer for a wrong, used and expired code: nothing tells them apart
33
+ const BAD_CODE = 'That code didn’t work. Get a new one in the admin.';
34
+ const TOO_MANY = 'Too many tries. Try again in a few minutes.';
35
+ const NO_ACCOUNT = 'Test phones need a Team account with an email. You’re signed in with the owner password.';
36
+
37
+ const codeHash = (code: string) => sha256Hex(`staff-link:${code}`);
38
+ /** Where staff manage their test phones (linked from the "A phone was linked" email). */
39
+ export const testPhonesPath = () => `${ANNOUNCEMENTS.enabled ? '/admin/announcements' : '/admin/mobile'}#test-phones`;
40
+
41
+ // ---- admin: Test phones ----------------------------------------------------
42
+
43
+ export async function testPhonesState(repo: ContentRepository, userId: string, now = Date.now()) {
44
+ requirePush();
45
+ // the owner password has no user row (and no email) to link a phone to
46
+ if (userId === OWNER_ENV) return { canLink: false, phones: [], codeExpiresAt: null };
47
+ const [phones, codeExpiresAt] = await Promise.all([repo.push.staffDevices(userId), repo.push.linkCodeExpiry(userId, now)]);
48
+ return {
49
+ canLink: true, codeExpiresAt,
50
+ phones: phones.map((d) => ({ id: d.id, platform: d.platform, lastSeenAt: d.lastSeenAt, linkedAt: d.staffLinkedAt ?? d.createdAt })),
51
+ };
52
+ }
53
+ export type TestPhonesState = Awaited<ReturnType<typeof testPhonesState>>;
54
+
55
+ export const TestPhonesInput = z.discriminatedUnion('action', [
56
+ z.object({ action: z.literal('code') }).strict(),
57
+ z.object({ action: z.literal('remove'), id: z.string().min(1).max(100) }).strict(),
58
+ ]);
59
+
60
+ /** A new one-time code for this staff member (their earlier one stops working). */
61
+ export async function createLinkCode(repo: ContentRepository, userId: string, now = Date.now()) {
62
+ requirePush();
63
+ if (userId === OWNER_ENV) throw new ApiError(409, NO_ACCOUNT);
64
+ // a clash with someone else's waiting code just draws again
65
+ for (let i = 0; i < 5; i++) {
66
+ const code = sixDigits();
67
+ if (await repo.push.saveLinkCode(await codeHash(code), userId, now + LINK_CODE_MS, now)) return { code, expiresAt: now + LINK_CODE_MS };
68
+ }
69
+ throw new ApiError(503, 'Couldn’t make a code right now. Try again.');
70
+ }
71
+
72
+ export async function testPhonesAction(repo: ContentRepository, input: z.infer<typeof TestPhonesInput>, userId: string) {
73
+ if (input.action === 'code') {
74
+ const code = await createLinkCode(repo, userId);
75
+ return { ...(await testPhonesState(repo, userId)), code: code.code, codeExpiresAt: code.expiresAt };
76
+ }
77
+ if (!(await repo.push.unlinkStaff(input.id, userId))) throw new ApiError(404, 'That phone isn’t linked to you.');
78
+ return testPhonesState(repo, userId);
79
+ }
80
+
81
+ // ---- app: link this phone ------------------------------------------------------
82
+
83
+ export interface LinkContext { repo: ContentRepository; deviceId: string; address: string; locals?: unknown; now?: number }
84
+
85
+ /**
86
+ * Links a registered device to the staff member whose code it sends. Every
87
+ * try counts before the lookup (so parallel guesses can't slip past the caps)
88
+ * and a success gives its tries back: 5 per device token and per client
89
+ * address, and 20 across the site, in 10 minutes. Past the site-wide cap
90
+ * every waiting code stops working and tries are refused until the window ends.
91
+ */
92
+ export async function linkPhone(c: LinkContext, code: string) {
93
+ requirePush();
94
+ const now = c.now ?? Date.now();
95
+ const device = await c.repo.push.getDevice(c.deviceId);
96
+ if (!device) throw new ApiError(404, 'This device is not registered. Register it with POST /api/v1/devices.');
97
+ // the device id is already a hash of its token; without a real client address the per-token and site caps still apply
98
+ const keys = [`push:link:token:${device.id}`, ...(c.address && c.address !== 'unknown' ? [`push:link:ip:${await sha256Hex(c.address)}`] : [])];
99
+ for (const key of keys) if (!(await c.repo.email.rateLimit(key, LINK_TRIES, WINDOW))) throw new ApiError(429, TOO_MANY, WINDOW / 1000);
100
+ if (!(await c.repo.email.rateLimit(SITE_KEY, SITE_LINK_TRIES, WINDOW))) {
101
+ // someone is guessing across many phones or addresses: staff make a new code
102
+ await c.repo.push.revokeLinkCodes();
103
+ throw new ApiError(429, TOO_MANY, WINDOW / 1000);
104
+ }
105
+ const userId = await c.repo.push.consumeLinkCode(await codeHash(code), now);
106
+ const user = userId ? await c.repo.getUser(userId) : null;
107
+ if (!user) throw new ApiError(400, BAD_CODE);
108
+ const linked = await c.repo.push.linkStaff(device.id, user.id, now);
109
+ if (!linked) throw new ApiError(404, 'This device is not registered. Register it with POST /api/v1/devices.');
110
+ for (const key of [...keys, SITE_KEY]) await c.repo.email.rateRefund(key);
111
+ await noticeLinked(c.repo, user.email, linked, now, c.locals).catch((e) => console.error('acms push: linked-phone email failed', e));
112
+ return { linked: true as const, staff: { name: user.name } };
113
+ }
114
+
115
+ // A security notice, so it ignores the "Staff notifications" switch; it needs
116
+ // working email (and the key that protects queued mail), else it's skipped.
117
+ async function noticeLinked(repo: ContentRepository, email: string, device: PushDevice, now: number, locals?: unknown) {
118
+ if (!email) return;
119
+ const resolved = resolveConfig(await repo.email.config().catch(() => null), locals);
120
+ if (!emailReadiness(resolved, (await repo.getSettings()).siteUrl, locals).configured) return;
121
+ await repo.email.enqueue({ dedupe: `phone-linked:${device.id}:${now}`, kind: 'phone-linked', recipient: email, configVersion: resolved.version,
122
+ payload: JSON.stringify({ platform: device.platform, at: now, path: testPhonesPath() }), expiresAt: now + 24 * HOUR });
123
+ await kickEmail(repo, locals, waitUntilFrom(locals));
124
+ }
125
+
126
+ // ---- send a test -----------------------------------------------------------------
127
+
128
+ // the title's prefix, in the language the test is in
129
+ const TEST_WORD: Record<string, string> = { en: '[Test]', es: '[Prueba]', fr: '[Test]', de: '[Test]', pt: '[Teste]' };
130
+ export const testWord = (locale: string) => TEST_WORD[locale.toLowerCase().split('-')[0]] ?? TEST_WORD.en;
131
+
132
+ export interface TestInput {
133
+ userId: string; author: string; title: string; body: string;
134
+ /** what a tap opens ('' = the app's home) */
135
+ url: string;
136
+ /** a site language to send it in (the composer's preview); the main one otherwise */
137
+ locale?: string | null;
138
+ /** Android channel (urgent announcements) */
139
+ channel?: string;
140
+ data?: Record<string, unknown>;
141
+ }
142
+
143
+ /**
144
+ * The draft as a push to the sender's own linked phones, titled "[Test] …" in
145
+ * its language. Logged as source `test` (never counted in the weekly pace);
146
+ * 10 an hour per person.
147
+ */
148
+ export async function sendTest(repo: ContentRepository, input: TestInput, opts: PushRunOptions = {}) {
149
+ requirePush();
150
+ if (input.userId === OWNER_ENV) throw new ApiError(409, NO_ACCOUNT);
151
+ const phones = await repo.push.staffDevices(input.userId);
152
+ if (!phones.length) throw new ApiError(409, 'No phone linked yet. Link one first.');
153
+ if (!(await repo.email.rateLimit(`push:test:${input.userId}`, TESTS_PER_HOUR, HOUR))) throw new ApiError(429, `That’s ${TESTS_PER_HOUR} tests in an hour. Try again later.`, 3600);
154
+ const settings = await repo.getSettings();
155
+ const main = (I18N.enabled && I18N.defaultLocale) || settings.locale || 'en';
156
+ const locale = input.locale && I18N.enabled && input.locale !== I18N.defaultLocale && I18N.locales.includes(input.locale) ? input.locale : null;
157
+ return sendNotification(repo, {
158
+ title: `${testWord(locale ?? main)} ${input.title}`, body: input.body, url: input.url,
159
+ audience: [], devices: phones.map((d) => d.id), locale, source: 'test', author: input.author, channel: input.channel,
160
+ data: { ...input.data, test: true },
161
+ }, opts);
162
+ }
@@ -5,7 +5,8 @@ type Row = Record<string, unknown>;
5
5
  const list = (raw: unknown): string[] => { try { const v = JSON.parse(String(raw ?? '[]')); return Array.isArray(v) ? v.map(String) : []; } catch { return []; } };
6
6
  function device(r: Row): PushDevice {
7
7
  return { id: String(r.id), token: String(r.token), platform: r.platform as PushPlatform, locale: String(r.locale ?? ''), topics: list(r.topics),
8
- channels: list(r.channels), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at), lastSeenAt: Number(r.last_seen_at) };
8
+ channels: list(r.channels), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at), lastSeenAt: Number(r.last_seen_at),
9
+ staffUserId: r.staff_user_id == null ? null : String(r.staff_user_id), staffLinkedAt: r.staff_linked_at == null ? null : Number(r.staff_linked_at) };
9
10
  }
10
11
  function notification(r: Row): PushNotification {
11
12
  return { id: String(r.id), title: String(r.title), body: String(r.body ?? ''), url: String(r.url ?? ''), topics: list(r.topics),
@@ -47,6 +48,14 @@ export class PushStore {
47
48
  return rows[0] ? device(rows[0]) : null;
48
49
  }
49
50
  async getDevice(id: string) { const r = await this.all('SELECT * FROM push_devices WHERE id=?', [id]); return r[0] ? device(r[0]) : null; }
51
+ async devicesByIds(ids: string[]): Promise<PushDevice[]> {
52
+ const out: PushDevice[] = [];
53
+ for (let i = 0; i < ids.length; i += 90) {
54
+ const chunk = ids.slice(i, i + 90);
55
+ out.push(...(await this.all(`SELECT * FROM push_devices WHERE id IN (${chunk.map(() => '?').join(',')}) ORDER BY created_at`, chunk)).map(device));
56
+ }
57
+ return out;
58
+ }
50
59
  async deleteDevices(ids: string[]) {
51
60
  for (let i = 0; i < ids.length; i += 90) {
52
61
  const chunk = ids.slice(i, i + 90);
@@ -82,6 +91,43 @@ export class PushStore {
82
91
  return Number((await this.all(`SELECT COUNT(*) AS n FROM push_notifications WHERE created_at>=? AND status!='failed' AND source IN (${sources.map(() => '?').join(',')})`, [since, ...sources]))[0]?.n ?? 0);
83
92
  }
84
93
 
94
+ // ---- staff test phones ----
95
+ /** Phones a staff member linked, most recently linked first. */
96
+ async staffDevices(userId: string): Promise<PushDevice[]> {
97
+ return (await this.all('SELECT * FROM push_devices WHERE staff_user_id=? ORDER BY staff_linked_at DESC LIMIT 50', [userId])).map(device);
98
+ }
99
+ async linkStaff(id: string, userId: string, now: number): Promise<PushDevice | null> {
100
+ const rows = await this.all('UPDATE push_devices SET staff_user_id=?, staff_linked_at=? WHERE id=? RETURNING *', [userId, now, id]);
101
+ return rows[0] ? device(rows[0]) : null;
102
+ }
103
+ /** `userId`: only while it's linked to them (the admin's Remove). */
104
+ async unlinkStaff(id: string, userId?: string): Promise<boolean> {
105
+ const rows = await this.all(`UPDATE push_devices SET staff_user_id=NULL, staff_linked_at=NULL WHERE id=?${userId ? ' AND staff_user_id=?' : ''} RETURNING id`, userId ? [id, userId] : [id]);
106
+ return rows.length > 0;
107
+ }
108
+ /**
109
+ * A staff member's one link code (an earlier one is voided). False when an
110
+ * unexpired code of someone else's has the same hash: draw another.
111
+ */
112
+ async saveLinkCode(codeHash: string, userId: string, expiresAt: number, now: number): Promise<boolean> {
113
+ await this.run('DELETE FROM push_link_codes WHERE user_id=? OR expires_at<=?', [userId, now]);
114
+ const rows = await this.all('INSERT INTO push_link_codes (code_hash,user_id,expires_at,created_at) VALUES (?,?,?,?) ON CONFLICT(code_hash) DO NOTHING RETURNING code_hash',
115
+ [codeHash, userId, expiresAt, now]);
116
+ return rows.length > 0;
117
+ }
118
+ /** Deletes the code (single use, expired or not) and returns whose it was while it was still valid. */
119
+ async consumeLinkCode(codeHash: string, now: number): Promise<string | null> {
120
+ const r = (await this.all('DELETE FROM push_link_codes WHERE code_hash=? RETURNING user_id, expires_at', [codeHash]))[0];
121
+ return r && Number(r.expires_at) > now ? String(r.user_id) : null;
122
+ }
123
+ /** When this staff member's code stops working (null: none waiting). */
124
+ async linkCodeExpiry(userId: string, now: number): Promise<number | null> {
125
+ const r = (await this.all('SELECT expires_at FROM push_link_codes WHERE user_id=? AND expires_at>?', [userId, now]))[0];
126
+ return r ? Number(r.expires_at) : null;
127
+ }
128
+ /** Every waiting code stops working (too many wrong ones across the site). */
129
+ async revokeLinkCodes() { await this.run('DELETE FROM push_link_codes'); }
130
+
85
131
  // ---- delivery log ----
86
132
  async createNotification(n: PushNotification) {
87
133
  await this.run(`INSERT INTO push_notifications (id,title,body,url,topics,source,source_key,status,recipients,sent,ok,failed,removed,error,author,created_at,finished_at)
@@ -175,12 +221,13 @@ export class PushStore {
175
221
  async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
176
222
 
177
223
  // ---- retention ----
178
- /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence, broadcast and change keys. */
224
+ /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets and link codes, and old occurrence, broadcast and change keys. */
179
225
  async purge(now: number, devicesBefore: number): Promise<number> {
180
226
  const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
181
227
  await (await this.driver()).batch([
182
228
  { sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
183
229
  { sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
230
+ { sql: 'DELETE FROM push_link_codes WHERE expires_at<=?', args: [now] },
184
231
  { sql: `DELETE FROM push_queue WHERE kind IN ('soon','live','change') AND updated_at<?`, args: [now - 30 * 86400000] },
185
232
  ]);
186
233
  return gone.length;
@@ -47,8 +47,15 @@ export interface PushDevice {
47
47
  /** Android notification channels the app created (beyond its default one) */
48
48
  channels: string[];
49
49
  createdAt: number; updatedAt: number; lastSeenAt: number;
50
+ /** the staff member who linked this phone for test sends (null = not linked) */
51
+ staffUserId: string | null; staffLinkedAt: number | null;
50
52
  }
51
- export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change';
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';
52
59
  export interface PushNotification {
53
60
  id: string; title: string; body: string; url: string;
54
61
  /** ['*'] = everyone */
@@ -695,6 +695,21 @@ export const MIGRATIONS: Migration[] = [
695
695
  { version: 61, statements: [
696
696
  `ALTER TABLE bookings ADD COLUMN hold_until INTEGER`,
697
697
  ] },
698
+ // staff test phones: a device a staff member linked with a one-time code
699
+ // (only its SHA-256 is stored, keyed by it: the app sends the code alone)
700
+ // gets their "Send test to my phone" pushes. Not public, so no cache trigger.
701
+ { version: 62, statements: [
702
+ `ALTER TABLE push_devices ADD COLUMN staff_user_id TEXT`,
703
+ `ALTER TABLE push_devices ADD COLUMN staff_linked_at INTEGER`,
704
+ `CREATE INDEX push_devices_staff ON push_devices (staff_user_id)`,
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
+ `CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`,
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
+ ] },
698
713
  ];
699
714
 
700
715
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -1708,7 +1708,10 @@ export class SqlRepository implements ContentRepository {
1708
1708
  }
1709
1709
  async deleteUser(id: string) {
1710
1710
  await this.ready;
1711
- await this.guardedUserWrite([...this.ownerGuard(id), { sql: 'DELETE FROM users WHERE id = ?', args: [id] }]);
1711
+ await this.guardedUserWrite([...this.ownerGuard(id), { sql: 'DELETE FROM users WHERE id = ?', args: [id] },
1712
+ // their test phones stop getting tests, and a waiting link code stops working
1713
+ { sql: 'UPDATE push_devices SET staff_user_id = NULL, staff_linked_at = NULL WHERE staff_user_id = ?', args: [id] },
1714
+ { sql: 'DELETE FROM push_link_codes WHERE user_id = ?', args: [id] }]);
1712
1715
  }
1713
1716
  async getUserAuth(email: string) {
1714
1717
  await this.ready;
@@ -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: SavedForm.omit({ ...common, draft: true, revision: true }).strict(),
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
- const op = String(data.get('op') || '');
23
- if (op !== 'publish' && op !== 'archive') throw new Error('Unknown action.');
24
- // Same store operation as the Build tab: publish promotes pending edits.
25
- await repo.forms.save(form.id, Number(data.get('revision')), form.name, form.draft ?? form.definition, op);
26
- form = (await repo.forms.get(form.id))!;
27
- notice = op === 'publish' ? 'Published. The form is accepting responses.' : 'Archived. The form no longer accepts responses; existing responses are kept.';
28
- } catch (e) { error = e instanceof Error ? e.message : 'Could not update the form.'; }
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"><p class="hint">Email alerts and webhooks for new responses are coming later. For now, check Responses or export a CSV.</p></div>
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>
@@ -26,7 +26,7 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
26
26
  const input = AdminAnnouncementAction.parse(await readJson(request, 100_000));
27
27
  const id = await validateRequestSession(request, cookies, repo);
28
28
  const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
29
- const result = await adminAnnouncementAction(repo, input, { url, author, locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
30
- return json({ ...result, state: input.action === 'count' ? undefined : await adminAnnouncementsState(repo, url) });
29
+ const result = await adminAnnouncementAction(repo, input, { url, author, userId: id ?? '', locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
30
+ return json({ ...result, state: input.action === 'count' || input.action === 'test' ? undefined : await adminAnnouncementsState(repo, url) });
31
31
  } catch (e) { return apiError(e); }
32
32
  };
@@ -23,7 +23,7 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
23
23
  const input = AdminPushInput.parse(await readJson(request, 20_000));
24
24
  const id = await validateRequestSession(request, cookies, repo);
25
25
  const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
26
- const result = await adminPushAction(repo, input, { url, locals, author });
26
+ const result = await adminPushAction(repo, input, { url, locals, author, userId: id ?? '' });
27
27
  return json({ ...result, state: input.action === 'count' ? undefined : await adminPushState(repo, url, locals) });
28
28
  } catch (e) { return apiError(e); }
29
29
  };
@@ -0,0 +1,25 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { ApiError, apiError, json, readJson } from '../../../lib/api';
4
+ import { requirePermission } from '../../../lib/permissions/access';
5
+ import { TestPhonesInput, testPhonesAction, testPhonesState } from '../../../lib/push/staff';
6
+
7
+ // Test phones (Admin → Announcements and Admin → Mobile app; registered only
8
+ // when push is enabled): each person links and removes their own phones.
9
+ export const GET: APIRoute = async ({ request, cookies, locals }) => {
10
+ try {
11
+ const repo = await getRepo(locals);
12
+ const access = await requirePermission(request, cookies, repo, 'services.manage');
13
+ return json(await testPhonesState(repo, access.userId));
14
+ } catch (e) { return apiError(e); }
15
+ };
16
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
17
+ try {
18
+ const repo = await getRepo(locals);
19
+ const access = await requirePermission(request, cookies, repo, 'services.manage');
20
+ // a link code gets this person's test pushes: only from this site's admin
21
+ if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
22
+ const input = TestPhonesInput.parse(await readJson(request, 2_000));
23
+ return json(await testPhonesAction(repo, input, access.userId));
24
+ } catch (e) { return apiError(e); }
25
+ };
@@ -0,0 +1,20 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../../lib/repo';
3
+ import { linkDevice, unlinkDevice } from '../../../../../lib/push/api';
4
+ import { failure, limitRequest, methodNotAllowed, response } from '../../../../../lib/mobile/http';
5
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
6
+ // POST: link this phone to a staff member (a code from the admin's Test phones) for test pushes. DELETE: unlink (idempotent).
7
+ function handler(run: (repo: Awaited<ReturnType<typeof getRepo>>, request: Request, token: string, address: string, locals: unknown) => Promise<unknown>): APIRoute {
8
+ return async context => {
9
+ try {
10
+ const repo = await getRepo(context.locals);
11
+ let address = 'unknown';
12
+ try { address = context.clientAddress || address; } catch { /* shared fallback bucket */ }
13
+ await limitRequest(repo, address, true);
14
+ return response(await run(repo, context.request, context.params.token ?? '', address, context.locals));
15
+ } catch (error) { return failure(error); }
16
+ };
17
+ }
18
+ export const POST = handler((repo, request, token, address, locals) => linkDevice(repo, request, token, address, locals));
19
+ export const DELETE = handler((repo, _request, token) => unlinkDevice(repo, token));
20
+ export const ALL = () => methodNotAllowed('POST, DELETE, OPTIONS');