@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.43
4
+
5
+ ### Patch Changes
6
+
7
+ - 8e9263a: Forms can email staff about new responses. On a form's **Share** tab, **Notifications** (off for every form, including existing ones) has **Email new responses** with a list of up to 10 addresses; the first time, it offers the contact email in Settings → Legal, or the owners' addresses. Each response sent from the website or the app emails everyone on the list, in the site's main language: "New response: Prayer requests", when it arrived, every question with its answer in order, an **Open responses in the admin** button, and where it came from and why they get it. When the form asks for an email address, replying writes to the person. A spam-trap, rate-limited or refused response, or a retry of one already saved, emails no one.
8
+
9
+ **Include the answers in the email** is on by default. Turn it off for forms that ask for sensitive information, such as prayer requests, health details or children's names: the email then only says a response arrived and when, with nothing about the person and no reply address. Turning it off also covers alerts still waiting to go out, and a deleted response or an address taken off the list drops its alert. With push notifications on, **Also notify their linked phones** sends staff on the list who linked a phone under Test phones a push, "New response: Prayer requests" / "Received Mar 14, 3:41 PM", that never carries answers and opens nothing; the delivery log shows it as "New form response". A form sends at most 50 alerts a day; the 50th says so, and later responses that day wait in Responses.
10
+
11
+ Alerts need email set up and **Staff notifications** on in Email settings, which now also turns these off (including ones already queued); the Share tab says when alerts can't go out. Saving them needs **Manage forms**, and emailing the answers also needs **View form responses**, so the list can't send anyone responses they couldn't read in the admin. Event registration forms are unchanged: their hosts get the RSVP email. Environment sync leaves the alert settings out; backups keep them. Push data `source` can now be `form`. Migration 63 adds `forms.alerts`.
12
+
13
+ The privacy notice now says that, on sites with email, responses or a notice that one arrived may be emailed to the staff who handle that form, and that staff phones linked for tests also get notices that a form response arrived, so its "Last updated" date moves.
14
+
15
+ ## 0.3.42
16
+
17
+ ### Patch Changes
18
+
19
+ - b5964fd: Staff can send a test notification to their own phone before anything goes out. Admin → Announcements and Admin → Mobile app have a **Test phones** panel: **Link my phone** shows a 6-digit code that works once for 10 minutes; in the app, open Notifications, choose **Staff? Link this phone** and enter it. The panel lists your linked phones (when linked, when the app was last opened) with **Remove**; each person sees only their own. Signed in with the owner password, there's no account to link a phone to, so the panel says to use a Team account with an email. When a phone is linked, its owner gets an email, "A phone was linked to your account", in the site's main language with a link back to the panel; it goes out whenever email is set up, even with staff notifications off, because it's a security notice.
20
+
21
+ **Send test to my phone**, under the preview in the announcement composer and the Mobile app composer, sends the draft as it stands (title, message, and the button's link or the app's home as the tap) to your linked phones only, titled "[Test] …" in the language chosen in the preview ("[Prueba]" in Spanish, "[Teste]" in Portuguese). An urgent draft uses the urgent channel, so you see how it looks. Tests show in the delivery log as "Test by …", never count toward the weekly pace, and are limited to 10 an hour per person. With no phone linked, the button opens the Test phones panel.
22
+
23
+ Codes are stored only as a hash. Guessing is capped: 5 tries per phone and 5 per address in 10 minutes, and after 20 wrong codes across the site every waiting code stops working (make a new one). The app API adds `POST /api/v1/devices/{token}/staff-link` (body `{ code }`, answers `{ linked, staff: { name } }`) and `DELETE` to unlink; device responses carry `staff` (the name only, never an email), test pushes carry `data.test: true` and `source: 'test'`, and turning notifications off unlinks the phone. Migration 62 adds `push_devices.staff_user_id` and `staff_linked_at` and the `push_link_codes` table.
24
+
25
+ The privacy notice now says staff can link their own phone to their account to receive test notifications and that the link is removed when they remove it or turn notifications off, so its "Last updated" date moves on sites with push; the app store answers say the device ID is linked to a staff member only then. The push recipe's Notifications screen gets the row (only while notifications are on) and a new `/link-phone` screen; update it with `npx acms mobile recipe add push --force`, which overwrites the recipe's files, including any changes you made to them, and needs this release's `@acms-subir/native`. Describe the screen in App Review Notes: "Notifications has a staff-only screen to receive test notifications; it needs a code from the website admin."
26
+
3
27
  ## 0.3.41
4
28
 
5
29
  ### Patch Changes
@@ -1121,6 +1121,21 @@ var MIGRATIONS = [
1121
1121
  // everything else, so older rows need no backfill
1122
1122
  { version: 61, statements: [
1123
1123
  `ALTER TABLE bookings ADD COLUMN hold_until INTEGER`
1124
+ ] },
1125
+ // staff test phones: a device a staff member linked with a one-time code
1126
+ // (only its SHA-256 is stored, keyed by it: the app sends the code alone)
1127
+ // gets their "Send test to my phone" pushes. Not public, so no cache trigger.
1128
+ { version: 62, statements: [
1129
+ `ALTER TABLE push_devices ADD COLUMN staff_user_id TEXT`,
1130
+ `ALTER TABLE push_devices ADD COLUMN staff_linked_at INTEGER`,
1131
+ `CREATE INDEX push_devices_staff ON push_devices (staff_user_id)`,
1132
+ `CREATE TABLE push_link_codes (code_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
1133
+ `CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`
1134
+ ] },
1135
+ // staff alerts for a form's new responses (SavedForm.alerts: who gets them,
1136
+ // with or without the answers); '{}' = off. Not public, so no cache trigger.
1137
+ { version: 63, statements: [
1138
+ `ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`
1124
1139
  ] }
1125
1140
  ];
1126
1141
  var CURRENT_CONTENT_VERSION_COLUMNS = {
package/docs/mobile.md CHANGED
@@ -289,6 +289,14 @@ plus automatic ones: new post, new event, "starting soon", event changes,
289
289
  `<AcmsProvider>` in `app/_layout.tsx`. Link to `/notifications`.
290
290
  3. Taps carry `data.url` / `data.path` and go through the provider's
291
291
  `onNavigate`, so route `/events/<slug>` and post paths to your screens there.
292
+ 4. Staff test phones: the recipe's Notifications screen ends with a visible
293
+ "Staff? Link this phone" row (only while notifications are on) that opens
294
+ `/link-phone`, where staff type the code from Admin → Announcements or
295
+ Mobile app → Test phones (`useLinkStaffPhone()`); then "Send test to my
296
+ phone" in the admin reaches their phone. Never hide it behind a long press
297
+ or gesture (App Review 2.3.1(a), 2.5.19). In App Store Connect's **App
298
+ Review Notes**, say: "Notifications has a staff-only screen to receive test
299
+ notifications; it needs a code from the website admin."
292
300
 
293
301
  Rules: ask for permission from a button after saying why (the recipe does),
294
302
  never on first launch. Topic ids and names come from the site
@@ -322,7 +330,8 @@ a pre-ticked choice doesn't count. So:
322
330
  completes it in **Settings → Legal**, which also lists the App Privacy and
323
331
  Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
324
332
  (and `.terms`). The site stores only the Expo token, platform, language and
325
- topics, with no name or email.
333
+ topics, with no name or email, except a staff member's own test phone, which
334
+ records who linked it (the notice and the store answers say so).
326
335
 
327
336
  ## Editor apps (staff sign-in)
328
337
 
package/integration.mjs CHANGED
@@ -765,8 +765,10 @@ export default function acms(options = {}) {
765
765
  routes.push(
766
766
  ['/api/v1/devices', 'api/v1/devices/index.ts'],
767
767
  ['/api/v1/devices/[token]', 'api/v1/devices/[token].ts'],
768
+ ['/api/v1/devices/[token]/staff-link', 'api/v1/devices/[token]/staff-link.ts'],
768
769
  ['/api/v1/push/topics', 'api/v1/push/topics.ts'],
769
770
  ['/api/push/admin', 'api/push/admin.ts'],
771
+ ['/api/push/phones', 'api/push/phones.ts'],
770
772
  );
771
773
  }
772
774
  // Admin → Mobile app: app access switches always; notifications when push is on
@@ -0,0 +1,43 @@
1
+ import { useState } from 'react';
2
+ import { ScrollView, Text, TextInput, View } from 'react-native';
3
+ import { Stack } from 'expo-router';
4
+ import { Button, fill, linkCodeInput, staffLinkProblemText, useLayout, useLinkStaffPhone, useStrings, useTheme } from '@acms-subir/native';
5
+
6
+ // Staff only: link this phone with the 6-digit code from the website admin
7
+ // (Announcements or Mobile app → Test phones), so "Send test to my phone"
8
+ // reaches it. Opened from the last row of /notifications while notifications
9
+ // are on; turning them off unlinks it. Words: useStrings() in the app's language.
10
+ export default function LinkPhoneScreen() {
11
+ const t = useTheme();
12
+ const { gutter } = useLayout();
13
+ const words = useStrings();
14
+ const staff = useLinkStaffPhone();
15
+ const [code, setCode] = useState('');
16
+ const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 22, letterSpacing: 4, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
17
+ const link = async () => { if (await staff.link(code)) setCode(''); };
18
+ return (
19
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }} keyboardShouldPersistTaps="handled">
20
+ <Stack.Screen options={{ title: words.linkPhoneTitle }} />
21
+ <Text style={t.styles.text.muted}>{words.linkPhoneIntro}</Text>
22
+
23
+ {staff.status === 'off' ? (
24
+ <Text style={t.styles.text.body}>{words.linkNeedsNotifications}</Text>
25
+ ) : staff.linked ? (
26
+ <View style={[t.styles.card, { flexDirection: 'row', alignItems: 'center', gap: 12 }]}>
27
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{fill(words.linkedTo, staff.linked)}</Text>
28
+ <Button variant="outline" label={words.unlinkPhone} disabled={staff.status !== 'ready'} onPress={() => { staff.unlink(); }} />
29
+ </View>
30
+ ) : (
31
+ <View style={{ gap: 6 }}>
32
+ <Text style={t.styles.text.label}>{words.linkCode}</Text>
33
+ <TextInput value={code} onChangeText={(v) => setCode(linkCodeInput(v))} onSubmitEditing={link} keyboardType="number-pad" textContentType="oneTimeCode"
34
+ autoComplete="one-time-code" maxLength={6} accessibilityLabel={words.linkCode} placeholder="000000" placeholderTextColor={t.colors.muted} style={input} />
35
+ </View>
36
+ )}
37
+ {!!staff.problem && <Text accessibilityRole="alert" style={[t.styles.text.small, { color: t.colors.ink, fontWeight: '600' }]}>{staffLinkProblemText(staff.problem, words)}</Text>}
38
+ {staff.status !== 'off' && !staff.linked && (
39
+ <Button label={staff.status === 'linking' ? words.linkingPhone : words.linkPhone} disabled={code.length !== 6 || staff.status !== 'ready'} onPress={link} />
40
+ )}
41
+ </ScrollView>
42
+ );
43
+ }
@@ -1,15 +1,19 @@
1
- import { Linking, ScrollView, Switch, Text, View } from 'react-native';
2
- import { Stack } from 'expo-router';
3
- import { Button, useLayout, usePushRegistration, usePushTopics, useTheme } from '@acms-subir/native';
1
+ import { Linking, Pressable, ScrollView, Switch, Text, View } from 'react-native';
2
+ import { Link, Stack } from 'expo-router';
3
+ import { Button, fill, useLayout, useLinkStaffPhone, usePushRegistration, usePushTopics, useStrings, useTheme } from '@acms-subir/native';
4
4
  import { STARTING_TOPICS } from '../lib/push';
5
5
 
6
6
  // Turn notifications on, choose topics, or turn them off. Topics and their
7
7
  // names come from the site, so new calendars or collections appear here
8
- // without an app update.
8
+ // without an app update. While notifications are on, the last row opens
9
+ // /link-phone, where staff link their own phone for test notifications (a
10
+ // visible row, so App Review can find it: describe it in the Review Notes).
9
11
  export default function NotificationsScreen() {
10
12
  const t = useTheme();
11
13
  const { gutter } = useLayout();
14
+ const words = useStrings();
12
15
  const push = usePushRegistration({ topics: STARTING_TOPICS });
16
+ const staff = useLinkStaffPhone();
13
17
  const offered = usePushTopics();
14
18
  const toggle = (id: string, on: boolean) => {
15
19
  const next = on ? [...push.topics, id] : push.topics.filter((x) => x !== id);
@@ -57,6 +61,12 @@ export default function NotificationsScreen() {
57
61
  </View>
58
62
  <Text style={t.styles.text.small}>Announcements from the site reach everyone with notifications on.</Text>
59
63
  <Button variant="outline" label="Turn off notifications" onPress={() => { push.unregister().catch(() => {}); }} />
64
+ <Link href="/link-phone" asChild>
65
+ <Pressable accessibilityRole="button" style={[t.styles.card, { gap: 2, minHeight: 52, justifyContent: 'center' }]}>
66
+ <Text style={t.styles.text.body}>{words.staffRow}</Text>
67
+ <Text style={t.styles.text.small}>{staff.linked ? fill(words.linkedTo, staff.linked) : words.staffRowNote}</Text>
68
+ </Pressable>
69
+ </Link>
60
70
  </>
61
71
  )}
62
72
  </ScrollView>
@@ -7,7 +7,8 @@
7
7
  "expo-device"
8
8
  ],
9
9
  "routes": [
10
- "/notifications"
10
+ "/notifications",
11
+ "/link-phone"
11
12
  ],
12
13
  "next": [
13
14
  "Turn push on for the site: acms({ mobile: { push: true } }) in astro.config.mjs, then restart or deploy",
@@ -15,6 +16,7 @@
15
16
  "Push tokens need an EAS project id: npx eas-cli init (saves extra.eas.projectId in app.json)",
16
17
  "Mount <PushSetup /> inside <AcmsProvider> in app/_layout.tsx (re-registers on start, opens tapped notifications)",
17
18
  "Link to the screen: <Link href=\"/notifications\">Notifications</Link> (expo-router)",
18
- "Send from the site: Admin → Mobile app"
19
+ "Send from the site: Admin → Mobile app",
20
+ "Staff test their notifications on their own phone: Notifications → \"Staff? Link this phone\" (/link-phone, shown while notifications are on) takes the code from Admin → Announcements or Mobile app → Test phones. Describe it in App Store Connect's App Review Notes: \"Notifications has a staff-only screen to receive test notifications; it needs a code from the website admin.\""
19
21
  ]
20
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -3,6 +3,7 @@ import type { AdminAnnouncementAction, AdminAnnouncementsState } from '../lib/an
3
3
  import type { Announcement, AnnouncementInput, AnnouncementKind, AnnouncementWords } from '../lib/announcements/types';
4
4
  import type { PushNotification } from '../lib/push/types';
5
5
  import { announcementStrings } from '../lib/announcements/words';
6
+ import TestPhones, { SendTest } from './TestPhones';
6
7
  import '../styles/announcements.css';
7
8
 
8
9
  // Admin → Announcements: write a message once, choose where it shows (the
@@ -274,6 +275,7 @@ function List({ state, setState, tab, setTab, flash, setFlash, focusTabs, onEdit
274
275
  {state.base && <a href={`${state.site.origin}${state.base}`} target="_blank" rel="noopener">View on the site ↗</a>}
275
276
  </p>
276
277
  )}
278
+ {state.push.enabled && <TestPhones />}
277
279
  <dialog className="modal" ref={dialog} aria-labelledby="an-del-h" onClose={() => setDoomed(null)}>
278
280
  <div className="modal-head"><span id="an-del-h">Delete this announcement?</span>
279
281
  <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
@@ -681,7 +683,12 @@ function Composer({ state, setState, startId, onClose }: {
681
683
  {tz && state.site.timezone && tz !== state.site.timezone && <p className="hint an-after">Times are in this device’s time zone ({tz}); the site uses {state.site.timezone}.</p>}
682
684
  </div>
683
685
  </div>
684
- <Preview state={state} form={form} lang={previewLang} setLang={setPreviewLang} />
686
+ <Preview state={state} form={form} lang={previewLang} setLang={setPreviewLang}>
687
+ {state.push.enabled && (
688
+ <SendTest problem={!localize(form, previewLang, main.code).title.trim() ? 'Add a title first.' : badLink || (form.linkRef === CUSTOM && !link) ? 'Fix the button’s address first.' : undefined}
689
+ send={() => api<{ notification: PushNotification }>({ action: 'test', announcement: input, locale: previewLang }).then((r) => r.notification)} />
690
+ )}
691
+ </Preview>
685
692
  </div>
686
693
 
687
694
  <dialog className="modal an-review" ref={review} aria-labelledby={`${uid}-review-h`}>
@@ -764,7 +771,8 @@ function Toggle({ label, note, checked, disabled, onChange }: { label: string; n
764
771
 
765
772
  // A phone lock screen with the push, and the website banner when pinned.
766
773
  // Device and site previews: fixed neutral colours, not admin tokens.
767
- function Preview({ state, form, lang, setLang }: { state: State; form: Form; lang: string; setLang: (l: string) => void }) {
774
+ // children: "Send test to my phone", under the phone
775
+ function Preview({ state, form, lang, setLang, children }: { state: State; form: Form; lang: string; setLang: (l: string) => void; children?: ReactNode }) {
768
776
  const [open, setOpen] = useState(false);
769
777
  const [time, setTime] = useState(() => new Date());
770
778
  useEffect(() => { const t = setInterval(() => setTime(new Date()), 30_000); return () => clearInterval(t); }, []);
@@ -803,6 +811,7 @@ function Preview({ state, form, lang, setLang }: { state: State; form: Form; lan
803
811
  </div>
804
812
  </div>
805
813
  <p className="hint an-cap">{push ? 'Phones shorten long text; keep the key words first.' : 'Not pushed. Tick Push to phones to send it.'}</p>
814
+ {children}
806
815
  {form.pinned && (
807
816
  <div className="an-site">
808
817
  <small className="an-site-label">Website banner</small>
@@ -477,7 +477,7 @@ export default function EmailSettings() {
477
477
  </label>
478
478
  <label className="ems-toggle">
479
479
  <input type="checkbox" checked={notify.staff} onChange={(e) => setNotify((n) => ({ ...n, staff: e.target.checked }))} />
480
- <span>Staff notifications<small>Tell site staff about new booking and event requests</small></span>
480
+ <span>Staff notifications<small>Tell site staff about new bookings, RSVPs, event requests and form responses</small></span>
481
481
  </label>
482
482
  {pwField(notify.pw, (v) => setNotify((n) => ({ ...n, pw: v })), 'pw-notify')}
483
483
  <div className="ems-actions">
@@ -1,6 +1,7 @@
1
- import { useEffect, useId, useMemo, useRef, useState } from 'react';
1
+ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import type { AdminPushState } from '../lib/push/api';
3
3
  import type { ChangeRule, PushAuto, PushNotification } from '../lib/push/types';
4
+ import TestPhones, { SendTest } from './TestPhones';
4
5
  import '../styles/mobile-app.css';
5
6
 
6
7
  // Admin → Mobile app: send a notification to the site's own app, choose the
@@ -37,7 +38,7 @@ function ago(ms: number, now: number) {
37
38
  : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
38
39
  }
39
40
 
40
- const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed' };
41
+ const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed', test: 'Test by', form: 'New form response' };
41
42
 
42
43
  export default function MobileApp() {
43
44
  const [state, setState] = useState<AdminPushState | null>(null);
@@ -74,6 +75,7 @@ function MobileAppView({ state, setState }: { state: AdminPushState; setState: (
74
75
  </div>
75
76
  )}
76
77
  <Composer state={state} setState={setState} topicLabel={topicLabel} />
78
+ <TestPhones />
77
79
  <div className="ma-grid ma-grid-2">
78
80
  <Automatic state={state} setState={setState} />
79
81
  <Audience state={state} topicLabel={topicLabel} />
@@ -116,8 +118,14 @@ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setS
116
118
  }, [mode, topics.join('|'), state.stats.devices]);
117
119
 
118
120
  const toggle = (id: string) => setTopics((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
119
- const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.'
120
- : linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
121
+ const badLink = linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
122
+ const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.' : badLink;
123
+ // a test goes to the sender's own phones: the audience doesn't matter
124
+ const sendTest = async () => {
125
+ const r = await api<{ notification: PushNotification; state: AdminPushState }>({ action: 'test', title: title.trim(), body: body.trim(), url });
126
+ setState(r.state);
127
+ return r.notification;
128
+ };
121
129
 
122
130
  const send = async () => {
123
131
  setBusy(true); setMsg({});
@@ -194,7 +202,9 @@ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setS
194
202
  <button type="button" className="btn primary" disabled={!!problem || busy} title={problem || undefined} onClick={() => dialog.current?.showModal()}>Send…</button>
195
203
  </div>
196
204
  </div>
197
- <LockScreen state={state} title={title} body={body} />
205
+ <LockScreen state={state} title={title} body={body}>
206
+ <SendTest send={sendTest} problem={!title.trim() ? 'Add a title.' : badLink || undefined} />
207
+ </LockScreen>
198
208
  <dialog className="modal" ref={dialog} aria-labelledby="ma-confirm-h">
199
209
  <div className="modal-head"><span id="ma-confirm-h">Send to {count === null ? 'these devices' : plural(count, 'device')}?</span>
200
210
  <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
@@ -217,7 +227,8 @@ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setS
217
227
 
218
228
  // A phone lock screen with the notification as it will show. Device preview:
219
229
  // fixed neutral colors (like the site previews), not admin tokens.
220
- function LockScreen({ state, title, body }: { state: AdminPushState; title: string; body: string }) {
230
+ // children: "Send test to my phone", under the phone
231
+ function LockScreen({ state, title, body, children }: { state: AdminPushState; title: string; body: string; children?: ReactNode }) {
221
232
  const [now, setNow] = useState(() => new Date());
222
233
  useEffect(() => { const t = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(t); }, []);
223
234
  return (
@@ -244,6 +255,7 @@ function LockScreen({ state, title, body }: { state: AdminPushState; title: stri
244
255
  </div>
245
256
  </div>
246
257
  <figcaption className="hint">Phones shorten long text; keep the key words first.</figcaption>
258
+ {children}
247
259
  </figure>
248
260
  );
249
261
  }
@@ -450,8 +462,8 @@ function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel:
450
462
  {n.body && <div className="ma-body">{n.body}</div>}
451
463
  {n.url && <div className="t-sub ma-url">{n.url}</div>}
452
464
  </td>
453
- <td data-l="Audience" className="ma-aud">{n.topics.map(topicLabel).join(', ')}</td>
454
- <td data-l="From">{SOURCE[n.source]}{n.source === 'manual' && n.author ? ` ${n.author}` : n.source === 'manual' ? ' an owner' : ''}</td>
465
+ <td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.source === 'form' ? 'Staff on the form’s alert list' : n.topics.map(topicLabel).join(', ')}</td>
466
+ <td data-l="From">{SOURCE[n.source]}{(n.source === 'manual' || n.source === 'test') && n.author ? ` ${n.author}` : n.source === 'manual' || n.source === 'test' ? ' an owner' : ''}</td>
455
467
  <td data-l="Delivered" className="num ma-result">
456
468
  {n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
457
469
  : !n.recipients ? <span className="ma-st">No devices</span>
@@ -0,0 +1,177 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import type { TestPhonesState } from '../lib/push/staff';
3
+ import type { PushNotification } from '../lib/push/types';
4
+ import '../styles/test-phones.css';
5
+
6
+ // Test phones (Admin → Announcements and Admin → Mobile app): each staff
7
+ // member links their own phone once with a one-time code typed into the app,
8
+ // then "Send test to my phone" in a composer reaches only their phones. The
9
+ // owner password has no account (or email) to link a phone to.
10
+
11
+ const ENDPOINT = '/api/push/phones';
12
+ // LINK_CODE_MS (lib/push/staff, which brings the server)
13
+ const CODE_MS = 10 * 60_000;
14
+ type State = TestPhonesState & { code?: string };
15
+
16
+ async function api<T>(body?: Record<string, unknown>): Promise<T> {
17
+ const res = await fetch(ENDPOINT, {
18
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
19
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ });
22
+ const data = await res.json().catch(() => null);
23
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
24
+ return data as T;
25
+ }
26
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
27
+ const PLATFORM = { ios: 'iPhone or iPad', android: 'Android phone' } as const;
28
+ const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
29
+ function when(ms: number, now: number) {
30
+ const s = Math.round((now - ms) / 1000);
31
+ if (s < 60) return 'just now';
32
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
33
+ const d = new Date(ms);
34
+ return new Date(now).toDateString() === d.toDateString() ? `today, ${clock(ms)}`
35
+ : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
36
+ }
37
+
38
+ /**
39
+ * The panel: this person's linked phones (Remove) and "Link my phone", which
40
+ * shows a code and waits for the app to use it. `embedded`: inside a dialog
41
+ * (no card, no anchor). `onPhones` hears how many are linked.
42
+ */
43
+ export default function TestPhones({ embedded = false, onPhones }: { embedded?: boolean; onPhones?: (n: number) => void }) {
44
+ const uid = useId();
45
+ const [state, setState] = useState<State | null>(null);
46
+ const [loadError, setLoadError] = useState('');
47
+ const [code, setCode] = useState<{ code: string; expiresAt: number } | null>(null);
48
+ const [busy, setBusy] = useState('');
49
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
50
+ const root = useRef<HTMLElement>(null);
51
+ const show = (s: State) => { setState(s); onPhones?.(s.phones.length); };
52
+
53
+ useEffect(() => { api<State>().then(show, (e) => setLoadError(errText(e))); }, []);
54
+ // the link in the "A phone was linked" email lands here
55
+ useEffect(() => { if (state && !embedded && location.hash === '#test-phones') root.current?.scrollIntoView({ block: 'start' }); }, [state === null]);
56
+ // while a code waits, look for the phone that uses it
57
+ useEffect(() => {
58
+ if (!code) return;
59
+ const t = setInterval(async () => {
60
+ if (Date.now() >= code.expiresAt) { setCode(null); setMsg({ err: 'That code expired. Make a new one when your phone is ready.' }); return; }
61
+ const next = await api<State>().catch(() => null);
62
+ if (!next || next.codeExpiresAt !== null) return;
63
+ setCode(null);
64
+ show(next);
65
+ // used (a phone linked since it was made), or voided after too many wrong codes on the site
66
+ setMsg(next.phones.some((p) => p.linkedAt >= code.expiresAt - CODE_MS) ? { ok: 'Phone linked. Tests you send now reach it.' } : { err: 'That code stopped working. Make a new one.' });
67
+ }, 4000);
68
+ return () => clearInterval(t);
69
+ }, [code]);
70
+
71
+ const run = async (body: Record<string, unknown>, label: string) => {
72
+ setBusy(label); setMsg({});
73
+ try {
74
+ const next = await api<State>(body);
75
+ show(next);
76
+ if (next.code && next.codeExpiresAt) setCode({ code: next.code, expiresAt: next.codeExpiresAt });
77
+ else if (body.action === 'remove') setMsg({ ok: 'Removed. It won’t get your tests.' });
78
+ } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(''); }
79
+ };
80
+
81
+ const now = Date.now();
82
+ return (
83
+ <section ref={root} className={embedded ? 'tp' : 'card card-pad tp'} id={embedded ? undefined : 'test-phones'} aria-labelledby={embedded ? undefined : `${uid}-h`}>
84
+ {!embedded && (
85
+ <header className="tp-head">
86
+ <h2 id={`${uid}-h`}>Test phones</h2>
87
+ <p>Link your own phone to see a notification on it before it goes out. Only you get your tests. Forms that email you about new responses can notify it too.</p>
88
+ </header>
89
+ )}
90
+ {loadError ? <p className="flash error" role="alert">{loadError}</p> : !state ? <p className="hint">Loading…</p> : !state.canLink ? (
91
+ <p className="hint tp-note">You’re signed in with the owner password, which has no account to link a phone to. Sign in with a Team account that has an email to link one. Owners create accounts in Team → Users.</p>
92
+ ) : (
93
+ <>
94
+ {state.phones.length > 0 ? (
95
+ <ul className="tp-list">
96
+ {state.phones.map((p) => (
97
+ <li key={p.id}>
98
+ <span className="tp-phone">
99
+ <b>{PLATFORM[p.platform] ?? p.platform}</b>
100
+ <small>Linked {when(p.linkedAt, now)} · App last opened {when(p.lastSeenAt, now)}</small>
101
+ </span>
102
+ <button type="button" className="btn sm" disabled={busy !== ''} onClick={() => run({ action: 'remove', id: p.id }, p.id)}>
103
+ {busy === p.id ? 'Removing…' : 'Remove'}</button>
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ ) : <p className="tp-empty">No phone linked yet.</p>}
108
+ {code ? (
109
+ <div className="tp-code" role="group" aria-label="Link code">
110
+ {/* read out digit by digit */}
111
+ <b className="tp-digits" aria-hidden="true">{code.code}</b>
112
+ <span className="sr-only">Code: {code.code.split('').join(' ')}.</span>
113
+ <p>In the app, open Notifications and choose Link this phone, then enter this code. It works once, until {clock(code.expiresAt)}.</p>
114
+ <button type="button" className="btn sm" disabled={busy !== ''} onClick={() => run({ action: 'code' }, 'code')}>New code</button>
115
+ </div>
116
+ ) : (
117
+ <button type="button" className="btn" disabled={busy !== ''} onClick={() => run({ action: 'code' }, 'code')}>
118
+ {busy === 'code' ? 'Making a code…' : state.phones.length ? 'Link another phone' : 'Link my phone'}</button>
119
+ )}
120
+ </>
121
+ )}
122
+ <div className="tp-flash" aria-live="polite">
123
+ {msg.ok && <p className="flash">{msg.ok}</p>}
124
+ {msg.err && <p className="flash error">{msg.err}</p>}
125
+ </div>
126
+ </section>
127
+ );
128
+ }
129
+
130
+ /** what a test send says: how many of the sender's phones it went to */
131
+ export function testResult(n: PushNotification): { ok?: string; err?: string } {
132
+ if (n.status === 'failed') return { err: `Not delivered: ${n.error || 'the push service refused it.'}` };
133
+ if (n.ok === n.sent) return { ok: n.sent === 1 ? 'Test sent to your phone.' : `Test sent to your ${n.sent} phones.` };
134
+ return { ok: `Test sent to ${n.ok} of your ${n.sent} phones.${n.error ? ` ${n.error}` : ''}` };
135
+ }
136
+
137
+ /**
138
+ * "Send test to my phone" for a composer. `send` posts the draft as it
139
+ * stands; `problem` disables it. With no phone linked yet it opens the Test
140
+ * phones panel in a dialog instead.
141
+ */
142
+ export function SendTest({ send, problem }: { send: () => Promise<PushNotification>; problem?: string }) {
143
+ const uid = useId();
144
+ const [phones, setPhones] = useState<number | null>(null);
145
+ const [busy, setBusy] = useState(false);
146
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
147
+ const dialog = useRef<HTMLDialogElement>(null);
148
+ const run = async () => {
149
+ setBusy(true); setMsg({});
150
+ try { setMsg(testResult(await send())); } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
151
+ };
152
+ return (
153
+ <div className="tp-send">
154
+ {phones === 0 ? (
155
+ <button type="button" className="btn" onClick={() => { setMsg({}); dialog.current?.showModal(); }}>Link a phone to send tests…</button>
156
+ ) : (
157
+ <button type="button" className="btn" disabled={busy || !!problem} title={problem || undefined} onClick={run}>{busy ? 'Sending test…' : 'Send test to my phone'}</button>
158
+ )}
159
+ <div className="tp-flash" aria-live="polite">
160
+ {msg.ok && <p className="flash">{msg.ok}</p>}
161
+ {msg.err && <p className="flash error">{msg.err}</p>}
162
+ </div>
163
+ <dialog className="modal" ref={dialog} aria-labelledby={`${uid}-h`}>
164
+ <div className="modal-head"><span id={`${uid}-h`}>Test phones</span>
165
+ <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
166
+ <div className="modal-body">
167
+ <p className="hint tp-intro">Link your own phone to see a notification on it before it goes out. Only you get your tests. Forms that email you about new responses can notify it too.</p>
168
+ {/* loads on mount (the dialog's content is always there) and says how many are linked */}
169
+ <TestPhones embedded onPhones={setPhones} />
170
+ <div className="modal-foot">
171
+ <button type="button" className="btn" onClick={() => dialog.current?.close()}>Done</button>
172
+ </div>
173
+ </div>
174
+ </dialog>
175
+ </div>
176
+ );
177
+ }
@@ -10,8 +10,9 @@ import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
10
10
  import { PUSH } from '../push/config';
11
11
  import { deviceLocale, knownTopics, pushTopics, siteOriginOf, sitePath } from '../push/service';
12
12
  import { linkTargets } from '../push/api';
13
+ import { sendTest } from '../push/staff';
13
14
  import { ANNOUNCEMENTS } from './config';
14
- import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
15
+ import { URGENT_CHANNEL, announcementJson, isLive, localizeAnnouncement, publishAnnouncement, type PublishOptions } from './service';
15
16
  import { announcementStrings } from './words';
16
17
  import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
17
18
 
@@ -115,6 +116,8 @@ export const AdminAnnouncementAction = z.discriminatedUnion('action', [
115
116
  z.object({ action: z.literal('archive'), id }).strict(),
116
117
  z.object({ action: z.literal('restore'), id }).strict(),
117
118
  z.object({ action: z.literal('delete'), id }).strict(),
119
+ // "Send test to my phone": the draft as it stands, in the preview's language, to the sender's linked phones
120
+ z.object({ action: z.literal('test'), announcement: AnnouncementInput, locale: z.string().max(35).optional() }).strict(),
118
121
  ]);
119
122
  export type AdminAnnouncementAction = z.infer<typeof AdminAnnouncementAction>;
120
123
 
@@ -156,7 +159,7 @@ export function scheduleProblem(a: Pick<Announcement, 'title' | 'message' | 'lis
156
159
  return null;
157
160
  }
158
161
 
159
- export interface AdminContext extends PublishOptions { url: URL; author: string }
162
+ export interface AdminContext extends PublishOptions { url: URL; author: string; userId?: string }
160
163
 
161
164
  export async function adminAnnouncementAction(repo: ContentRepository, input: AdminAnnouncementAction, ctx: AdminContext) {
162
165
  requireAnnouncements();
@@ -166,6 +169,7 @@ export async function adminAnnouncementAction(repo: ContentRepository, input: Ad
166
169
  return { recipients: await countRecipients(repo, input.kind === 'urgent' ? ['*'] : await checkAudience(repo, input.audience)) };
167
170
  }
168
171
  if (input.action === 'save') return save(repo, input, ctx, now);
172
+ if (input.action === 'test') return test(repo, input, ctx);
169
173
  const a = await repo.announcements.get(input.id);
170
174
  if (!a) throw new ApiError(404, 'Announcement not found.');
171
175
  if (input.action === 'archive') {
@@ -184,6 +188,19 @@ export async function adminAnnouncementAction(repo: ContentRepository, input: Ad
184
188
  return { deleted: a.id };
185
189
  }
186
190
 
191
+ // a tap opens the button's link, else the app (a draft has no page yet); urgent uses its channel so it looks the part
192
+ async function test(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'test' }>, ctx: AdminContext) {
193
+ if (!PUSH.enabled) throw new ApiError(400, 'Push notifications are off on this site (acms({ mobile: { push } })).');
194
+ const a = input.announcement;
195
+ const w = localizeAnnouncement({ ...a, translations: cleanTranslations(a.translations) }, input.locale ?? null);
196
+ if (!w.title) throw new ApiError(400, 'Add a title.');
197
+ const notification = await sendTest(repo, {
198
+ userId: ctx.userId ?? '', author: ctx.author, title: w.title, body: w.message, url: a.link, locale: input.locale ?? null,
199
+ channel: a.kind === 'urgent' ? URGENT_CHANNEL : undefined, data: { announcementKind: a.kind },
200
+ }, { ...ctx, origin: ctx.origin ?? siteOriginOf(await repo.getSettings(), ctx.url.origin) });
201
+ return { notification };
202
+ }
203
+
187
204
  async function save(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'save' }>, ctx: AdminContext, now: number) {
188
205
  const body = input.announcement;
189
206
  const existing = input.id ? await repo.announcements.get(input.id) : null;
@@ -15,7 +15,7 @@ import type { Announcement, AnnouncementWords } from './types';
15
15
  export const URGENT_CHANNEL = 'urgent';
16
16
 
17
17
  /** Each field in `locale`, falling back to the main words; `translated` = the language has its own title. */
18
- export function localizeAnnouncement(a: Announcement, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
18
+ export function localizeAnnouncement(a: AnnouncementWords & Pick<Announcement, 'translations'>, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
19
19
  const main = { title: a.title, message: a.message, details: a.details, buttonLabel: a.buttonLabel };
20
20
  if (!locale || locale === I18N.defaultLocale) return { ...main, translated: true };
21
21
  const t = a.translations[locale] ?? a.translations[locale.toLowerCase().split('-')[0]];