@acms-subir/core 0.3.41 → 0.3.42

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.
@@ -41,3 +41,23 @@ export function canonicalOrigin(value: string): string {
41
41
  if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new EmailLinkError('Site URL in Settings → General must use HTTPS (HTTP is allowed for localhost), without a path, credentials or query.');
42
42
  return url.origin;
43
43
  }
44
+ // "A phone was linked" (lib/push/staff.ts): a security notice to the staff
45
+ // member, in the site's main language (usted in Spanish, like other emails)
46
+ const PHONE_LINKED: Record<string, { subject: string; intro: (phone: string, site: string, when: string) => string; notYou: string; button: string; noLink: string; ios: string; android: string }> = {
47
+ en: { subject: 'A phone was linked to your account', intro: (p, s, w) => `A phone (${p}) was linked to your account at ${s} on ${w}. It gets the test notifications you send from the admin.`,
48
+ notYou: 'If this wasn’t you, remove it in the admin', button: 'Test phones', noLink: 'If this wasn’t you, remove it in the admin under Test phones.', ios: 'iPhone or iPad', android: 'Android' },
49
+ es: { subject: 'Se vinculó un teléfono a su cuenta', intro: (p, s, w) => `Se vinculó un teléfono (${p}) a su cuenta de ${s} el ${w}. Recibe las notificaciones de prueba que usted envía desde el panel.`,
50
+ notYou: 'Si no fue usted, quítelo en el panel', button: 'Teléfonos de prueba', noLink: 'Si no fue usted, quítelo en el panel, en Teléfonos de prueba.', ios: 'iPhone o iPad', android: 'Android' },
51
+ fr: { subject: 'Un téléphone a été associé à votre compte', intro: (p, s, w) => `Un téléphone (${p}) a été associé à votre compte sur ${s} le ${w}. Il reçoit les notifications de test que vous envoyez depuis l’administration.`,
52
+ notYou: 'Si ce n’était pas vous, retirez-le dans l’administration', button: 'Téléphones de test', noLink: 'Si ce n’était pas vous, retirez-le dans l’administration, sous Téléphones de test.', ios: 'iPhone ou iPad', android: 'Android' },
53
+ de: { subject: 'Ein Telefon wurde mit Ihrem Konto verknüpft', intro: (p, s, w) => `Ein Telefon (${p}) wurde am ${w} mit Ihrem Konto bei ${s} verknüpft. Es erhält die Testbenachrichtigungen, die Sie im Adminbereich senden.`,
54
+ notYou: 'Wenn Sie das nicht waren, entfernen Sie es im Adminbereich', button: 'Testtelefone', noLink: 'Wenn Sie das nicht waren, entfernen Sie es im Adminbereich unter Testtelefone.', ios: 'iPhone oder iPad', android: 'Android' },
55
+ pt: { subject: 'Um telefone foi vinculado à sua conta', intro: (p, s, w) => `Um telefone (${p}) foi vinculado à sua conta em ${s} em ${w}. Ele recebe as notificações de teste que você envia pelo painel.`,
56
+ notYou: 'Se não foi você, remova-o no painel', button: 'Telefones de teste', noLink: 'Se não foi você, remova-o no painel, em Telefones de teste.', ios: 'iPhone ou iPad', android: 'Android' },
57
+ };
58
+ export function phoneLinkedMail(to: string, site: string, lang: string, platform: 'ios' | 'android', when: string, url: string): Mail {
59
+ const w = PHONE_LINKED[lang.toLowerCase().split('-')[0]] ?? PHONE_LINKED.en;
60
+ const intro = w.intro(platform === 'ios' ? w.ios : w.android, site, when);
61
+ return { to, subject: `${w.subject} · ${site}`, text: [w.subject, '', intro, '', url ? `${w.notYou}: ${url}` : w.noLink].join('\n'),
62
+ html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(w.subject)}</h1><p>${escape(intro)}</p>${url ? `<p>${escape(w.notYou)}:</p><p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(w.button)}</a></p>` : `<p>${escape(w.noLink)}</p>`}</div></body></html>` };
63
+ }
@@ -32,8 +32,8 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
32
32
  });
33
33
  if (f.push.enabled) rows.push({
34
34
  data: 'Push token, platform, language, chosen topics and Android notification channels', when: 'When someone turns on notifications',
35
- apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
36
- google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
35
+ apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user (to a staff member’s identity only when they link their own phone for test notifications). Not used for tracking.',
36
+ google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional. Linked to a staff member’s account only when they link their own phone for test notifications.',
37
37
  });
38
38
  rows.push({
39
39
  data: 'IP address', when: 'Every request (rate limiting only)',
@@ -52,7 +52,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
52
52
  'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
53
53
  ] });
54
54
  if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
55
- 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
55
+ 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging. Our staff can link their own phone to their account to receive test notifications; 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: [
@@ -53,7 +53,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
53
53
  'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
54
54
  ] });
55
55
  if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
56
- 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
56
+ 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging. Las personas de nuestro equipo pueden vincular su propio teléfono a su cuenta del sitio para recibir notificaciones de prueba; 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: [
@@ -44,7 +44,8 @@ async function requireGuest(c: PublicContext) {
44
44
  }
45
45
  const emailField = z.string().trim().toLowerCase().max(254).refine(validAddress, 'Enter a valid email address.');
46
46
  const hash = (email: string, code: string) => sha256Hex(`guest-code:${email}:${code}`);
47
- function sixDigits() {
47
+ /** A random 6-digit code (guest sign-in, staff phone links). */
48
+ export function sixDigits() {
48
49
  // rejection sampling keeps every code equally likely
49
50
  const buf = new Uint32Array(1);
50
51
  do crypto.getRandomValues(buf); while (buf[0] >= 4_294_000_000);
@@ -10,7 +10,7 @@ import { searchQuery } from './search';
10
10
  import { manageAvailabilityQuery, rescheduleInput } from './manage';
11
11
  import { formResponseInput } from './forms';
12
12
  import { codeInput, tokenInput, myBookingsQuery } from './guest';
13
- import { deviceInput, deviceUpdate } from '../push/api';
13
+ import { deviceInput, deviceUpdate, staffLinkInput } from '../push/api';
14
14
  import { announcementsQuery } from '../announcements/api';
15
15
  import { localeOnlyQuery } from './locale';
16
16
  import { pageQuery, queryParams } from './http';
@@ -129,7 +129,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
129
129
  paths['/api/v1/devices'] = {
130
130
  post: {
131
131
  summary: 'Register or refresh this device for push notifications; requires push',
132
- description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only.`,
132
+ description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only (plus, for a staff member's own test phone, who linked it). device.staff names that staff member, or is null.`,
133
133
  requestBody: { required: true, content: media(jsonSchema(deviceInput)) },
134
134
  responses: { ...writeErrors, ...Object.fromEntries([200, 201].map(code => [code, { description: code === 201 ? 'Registered.' : 'Refreshed.', headers: noStore, content: media(envelope(ref('DeviceRegistration'))) }])) },
135
135
  },
@@ -142,12 +142,27 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
142
142
  responses: { ...writeErrors, 200: { description: 'Updated.', headers: noStore, content: media(envelope(ref('Device'))) } },
143
143
  },
144
144
  delete: {
145
- summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token. Unknown tokens also succeed.',
145
+ summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token, and a test-phone link with it. Unknown tokens also succeed.',
146
146
  parameters: [tokenParam],
147
147
  responses: { ...writeErrors, 200: { description: 'Removed.', headers: noStore, content: media(envelope(ref('DeviceRemoved'))) } },
148
148
  },
149
149
  options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
150
150
  };
151
+ const staffNote = 'Requires push; 404 when off. A staff member gets a one-time 6-digit code in the admin (Test phones; 10 minutes, single use) and types it in the app, so this registered device gets their "Send test to my phone" pushes (data.test = true). staff carries their display name only.';
152
+ paths['/api/v1/devices/{token}/staff-link'] = {
153
+ post: {
154
+ summary: 'Link this registered device to a staff member for test pushes; requires push',
155
+ description: `${staffNote} 404 for an unregistered device. A wrong, used or expired code answers 400 with the same message. Tries are capped (429 with Retry-After): 5 per device token and 5 per client address in 10 minutes, and 20 failures across the site void every waiting code.`,
156
+ parameters: [tokenParam], requestBody: { required: true, content: media(jsonSchema(staffLinkInput)) },
157
+ responses: { ...writeErrors, 429: { ...writeErrors[429], description: 'Write limit, or too many link tries (see above).' }, 200: { description: 'Linked.', headers: noStore, content: media(envelope(ref('StaffLink'))) } },
158
+ },
159
+ delete: {
160
+ summary: 'Unlink this device from a staff member (idempotent); requires push', description: `${staffNote} Turning notifications off (DELETE /devices/{token}) also unlinks it.`,
161
+ parameters: [tokenParam],
162
+ responses: { ...writeErrors, 200: { description: 'Unlinked.', headers: noStore, content: media(envelope(ref('StaffLink'))) } },
163
+ },
164
+ options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
165
+ };
151
166
  Object.assign(paths, editorPaths(jsonSchema));
152
167
  return {
153
168
  openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication, except a booking’s own routes (/bookings/manage/*: its private token in the Booking-Token header) and guest sign-in (/guest/*: a guest token from an emailed code). Owner switches in Admin → Mobile app turn app features off (404). Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
@@ -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,11 @@ 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
+ /** test: "Send test to my phone", to the sender's linked phones only (never counted in the weekly pace) */
54
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test';
52
55
  export interface PushNotification {
53
56
  id: string; title: string; body: string; url: string;
54
57
  /** ['*'] = everyone */
@@ -695,6 +695,16 @@ 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
+ ] },
698
708
  ];
699
709
 
700
710
  // 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;
@@ -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
+ };