@acms-subir/core 0.3.41 → 0.3.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/cli/migrations.generated.mjs +15 -0
- package/docs/mobile.md +10 -1
- package/integration.mjs +2 -0
- package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
- package/mobile-template/recipes/push/recipe.json +4 -2
- package/package.json +1 -1
- package/src/components/Announcements.tsx +11 -2
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +20 -8
- package/src/components/TestPhones.tsx +177 -0
- package/src/lib/announcements/api.ts +19 -2
- package/src/lib/announcements/service.ts +1 -1
- package/src/lib/email/service.ts +19 -2
- package/src/lib/email/store.ts +9 -1
- package/src/lib/email/templates.ts +20 -0
- package/src/lib/forms/alert-mail.ts +124 -0
- package/src/lib/forms/alerts.ts +137 -0
- package/src/lib/forms/schema.ts +15 -1
- package/src/lib/forms/store.ts +26 -8
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +2 -2
- package/src/lib/legal/text-es.ts +2 -2
- package/src/lib/mobile/forms.ts +2 -1
- package/src/lib/mobile/guest.ts +2 -1
- package/src/lib/mobile/openapi.ts +18 -3
- package/src/lib/mobile/schemas.ts +4 -0
- package/src/lib/push/api.ts +37 -8
- package/src/lib/push/service.ts +6 -2
- package/src/lib/push/staff.ts +162 -0
- package/src/lib/push/store.ts +49 -2
- package/src/lib/push/types.ts +8 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +4 -1
- package/src/lib/sync/catalog.ts +2 -1
- package/src/pages/admin/forms/[id]/share.astro +49 -12
- package/src/pages/api/announcements.ts +2 -2
- package/src/pages/api/push/admin.ts +1 -1
- package/src/pages/api/push/phones.ts +25 -0
- package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
- package/src/pages/forms/[id].astro +2 -1
- package/src/styles/forms-admin.css +8 -0
- package/src/styles/test-phones.css +30 -0
|
@@ -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,
|
package/src/lib/push/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Public device endpoints (/api/v1/devices, /api/v1/push/topics
|
|
2
|
-
//
|
|
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
|
-
|
|
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));
|
package/src/lib/push/service.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/push/store.ts
CHANGED
|
@@ -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;
|
package/src/lib/push/types.ts
CHANGED
|
@@ -47,8 +47,15 @@ export interface PushDevice {
|
|
|
47
47
|
/** Android notification channels the app created (beyond its default one) */
|
|
48
48
|
channels: string[];
|
|
49
49
|
createdAt: number; updatedAt: number; lastSeenAt: number;
|
|
50
|
+
/** the staff member who linked this phone for test sends (null = not linked) */
|
|
51
|
+
staffUserId: string | null; staffLinkedAt: number | null;
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
/**
|
|
54
|
+
* test: "Send test to my phone", to the sender's linked phones only; form: a
|
|
55
|
+
* new form response, to the linked phones of staff on its alert list. Neither
|
|
56
|
+
* counts in the weekly pace.
|
|
57
|
+
*/
|
|
58
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form';
|
|
52
59
|
export interface PushNotification {
|
|
53
60
|
id: string; title: string; body: string; url: string;
|
|
54
61
|
/** ['*'] = everyone */
|
|
@@ -695,6 +695,21 @@ export const MIGRATIONS: Migration[] = [
|
|
|
695
695
|
{ version: 61, statements: [
|
|
696
696
|
`ALTER TABLE bookings ADD COLUMN hold_until INTEGER`,
|
|
697
697
|
] },
|
|
698
|
+
// staff test phones: a device a staff member linked with a one-time code
|
|
699
|
+
// (only its SHA-256 is stored, keyed by it: the app sends the code alone)
|
|
700
|
+
// gets their "Send test to my phone" pushes. Not public, so no cache trigger.
|
|
701
|
+
{ version: 62, statements: [
|
|
702
|
+
`ALTER TABLE push_devices ADD COLUMN staff_user_id TEXT`,
|
|
703
|
+
`ALTER TABLE push_devices ADD COLUMN staff_linked_at INTEGER`,
|
|
704
|
+
`CREATE INDEX push_devices_staff ON push_devices (staff_user_id)`,
|
|
705
|
+
`CREATE TABLE push_link_codes (code_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
706
|
+
`CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`,
|
|
707
|
+
] },
|
|
708
|
+
// staff alerts for a form's new responses (SavedForm.alerts: who gets them,
|
|
709
|
+
// with or without the answers); '{}' = off. Not public, so no cache trigger.
|
|
710
|
+
{ version: 63, statements: [
|
|
711
|
+
`ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`,
|
|
712
|
+
] },
|
|
698
713
|
];
|
|
699
714
|
|
|
700
715
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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;
|
package/src/lib/sync/catalog.ts
CHANGED
|
@@ -50,7 +50,8 @@ export const schemas = {
|
|
|
50
50
|
}).strict(),
|
|
51
51
|
booking: BookingLink.omit(common).strict(),
|
|
52
52
|
place: Place.omit(common).strict(),
|
|
53
|
-
form
|
|
53
|
+
// who gets a form's alerts belongs to one environment, like notifyEmails
|
|
54
|
+
form: SavedForm.omit({ ...common, draft: true, revision: true, alerts: true }).strict(),
|
|
54
55
|
media: Media.omit({ id: true, createdAt: true })
|
|
55
56
|
.extend({
|
|
56
57
|
objects: z
|
|
@@ -6,8 +6,10 @@ import { readForm } from '../../../../lib/forms/http';
|
|
|
6
6
|
import { escapeForm } from '../../../../lib/forms/fields';
|
|
7
7
|
import { siteOrigin } from '../../../../lib/seo';
|
|
8
8
|
import { CONTENT } from '../../../../lib/content';
|
|
9
|
+
import { alertBlocker, saveFormAlerts, suggestedRecipients } from '../../../../lib/forms/alerts';
|
|
10
|
+
import { PUSH } from '../../../../lib/push/config';
|
|
9
11
|
import '../../../../styles/forms-admin.css';
|
|
10
|
-
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
12
|
+
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
11
13
|
|
|
12
14
|
const repo = await getRepo(Astro.locals);
|
|
13
15
|
if (Astro.request.method === 'POST') { const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'forms.manage'); if (denied) return denied; }
|
|
@@ -15,20 +17,40 @@ let form = await repo.forms.get(Astro.params.id || '');
|
|
|
15
17
|
if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
|
|
16
18
|
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
17
19
|
|
|
18
|
-
let error = '', notice = '';
|
|
20
|
+
let error = '', notice = '', alertError = '', alertNotice = '';
|
|
21
|
+
// what the Notifications form shows: saved settings, or what was just posted when it didn't save
|
|
22
|
+
let posted: FormData | null = null;
|
|
19
23
|
if (Astro.request.method === 'POST') {
|
|
24
|
+
let op = '';
|
|
20
25
|
try {
|
|
21
26
|
const data = await readForm(Astro.request);
|
|
22
|
-
|
|
23
|
-
if (op
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
op = String(data.get('op') || '');
|
|
28
|
+
if (op === 'alerts') {
|
|
29
|
+
posted = data;
|
|
30
|
+
await saveFormAlerts(repo, await accessFor(Astro.request, Astro.cookies, repo), form, data);
|
|
31
|
+
form = (await repo.forms.get(form.id))!;
|
|
32
|
+
posted = null;
|
|
33
|
+
alertNotice = form.alerts.enabled ? 'Saved. New responses are emailed to this list.' : 'Saved. New responses aren’t emailed.';
|
|
34
|
+
} else {
|
|
35
|
+
if (op !== 'publish' && op !== 'archive') throw new Error('Unknown action.');
|
|
36
|
+
// Same store operation as the Build tab: publish promotes pending edits.
|
|
37
|
+
await repo.forms.save(form.id, Number(data.get('revision')), form.name, form.draft ?? form.definition, op);
|
|
38
|
+
form = (await repo.forms.get(form.id))!;
|
|
39
|
+
notice = op === 'publish' ? 'Published. The form is accepting responses.' : 'Archived. The form no longer accepts responses; existing responses are kept.';
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
const message = e instanceof Error ? e.message : 'Could not update the form.';
|
|
43
|
+
if (op === 'alerts') alertError = message; else error = message;
|
|
44
|
+
}
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
const [settings, stats, used] = await Promise.all([repo.getSettings(), repo.forms.submissionStats(form.id, 0), repo.forms.usedOn(form.id)]);
|
|
48
|
+
// never set up: offer the contact email (Settings → Legal), else the owners'
|
|
49
|
+
const fresh = !form.alerts.enabled && !form.alerts.to.length;
|
|
50
|
+
const alerts = posted
|
|
51
|
+
? { enabled: posted.get('enabled') === '1', to: String(posted.get('to') ?? ''), answers: posted.get('answers') === '1', phones: posted.get('phones') === '1' }
|
|
52
|
+
: { ...form.alerts, to: (fresh ? await suggestedRecipients(repo, settings) : form.alerts.to).join('\n') };
|
|
53
|
+
const blocker = alerts.enabled ? await alertBlocker(repo, settings, Astro.locals) : null;
|
|
32
54
|
const origin = siteOrigin(settings, Astro.url);
|
|
33
55
|
const link = `${origin}/forms/${form.id}`;
|
|
34
56
|
const title = escapeForm(form.definition.title || form.name);
|
|
@@ -101,9 +123,24 @@ const copyIcon = 'M9 9h10v10H9zM5 15V5h10';
|
|
|
101
123
|
</div>
|
|
102
124
|
</section>
|
|
103
125
|
|
|
104
|
-
<section class="fs-sec">
|
|
105
|
-
<div class="fs-label"><b>Notifications</b></div>
|
|
106
|
-
<div class="fs-body"
|
|
126
|
+
<section class="fs-sec" id="notifications">
|
|
127
|
+
<div class="fs-label"><b>Notifications</b><small>Email staff when someone sends this form, from the website or the app.</small></div>
|
|
128
|
+
<div class="fs-body">
|
|
129
|
+
{alertError && <p class="notice warn" role="alert">{alertError}</p>}
|
|
130
|
+
{alertNotice && <p class="notice" role="status">{alertNotice}</p>}
|
|
131
|
+
<form method="post" action="#notifications" class="fs-alerts">
|
|
132
|
+
<input type="hidden" name="op" value="alerts" />
|
|
133
|
+
<label class="check-row"><input type="checkbox" name="enabled" value="1" checked={alerts.enabled} /><span>Email new responses</span></label>
|
|
134
|
+
<div class="fs-alerts-more">
|
|
135
|
+
<label class="field"><span>Send to</span><textarea name="to" rows="3" spellcheck="false" placeholder="office@example.org">{alerts.to}</textarea><small class="hint">Up to 10 email addresses, one per line.</small></label>
|
|
136
|
+
<label class="check-row"><input type="checkbox" name="answers" value="1" checked={alerts.answers} /><span>Include the answers in the email<small class="hint">Turn this off for forms that ask for sensitive information, such as prayer requests, health details or children’s names. The email then only says a response arrived.</small></span></label>
|
|
137
|
+
{PUSH.enabled && <label class="check-row"><input type="checkbox" name="phones" value="1" checked={alerts.phones} /><span>Also notify their linked phones<small class="hint">People on this list with a Team account who linked a phone under Test phones get a notification with the form’s name and the time, never the answers.</small></span></label>}
|
|
138
|
+
<p class="hint">Up to 50 alerts a day for this form; the 50th says so, and later responses wait in Responses.</p>
|
|
139
|
+
</div>
|
|
140
|
+
{blocker && <p class="notice warn"><span>{blocker.text} <a href={blocker.href}>{blocker.link}</a></span></p>}
|
|
141
|
+
<div class="fs-row"><button class="btn primary">Save notifications</button></div>
|
|
142
|
+
</form>
|
|
143
|
+
</div>
|
|
107
144
|
</section>
|
|
108
145
|
</div>
|
|
109
146
|
</Admin>
|
|
@@ -26,7 +26,7 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
26
26
|
const input = AdminAnnouncementAction.parse(await readJson(request, 100_000));
|
|
27
27
|
const id = await validateRequestSession(request, cookies, repo);
|
|
28
28
|
const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
|
|
29
|
-
const result = await adminAnnouncementAction(repo, input, { url, author, locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
|
|
30
|
-
return json({ ...result, state: input.action === 'count' ? undefined : await adminAnnouncementsState(repo, url) });
|
|
29
|
+
const result = await adminAnnouncementAction(repo, input, { url, author, userId: id ?? '', locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
|
|
30
|
+
return json({ ...result, state: input.action === 'count' || input.action === 'test' ? undefined : await adminAnnouncementsState(repo, url) });
|
|
31
31
|
} catch (e) { return apiError(e); }
|
|
32
32
|
};
|
|
@@ -23,7 +23,7 @@ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
|
23
23
|
const input = AdminPushInput.parse(await readJson(request, 20_000));
|
|
24
24
|
const id = await validateRequestSession(request, cookies, repo);
|
|
25
25
|
const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
|
|
26
|
-
const result = await adminPushAction(repo, input, { url, locals, author });
|
|
26
|
+
const result = await adminPushAction(repo, input, { url, locals, author, userId: id ?? '' });
|
|
27
27
|
return json({ ...result, state: input.action === 'count' ? undefined : await adminPushState(repo, url, locals) });
|
|
28
28
|
} catch (e) { return apiError(e); }
|
|
29
29
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
4
|
+
import { requirePermission } from '../../../lib/permissions/access';
|
|
5
|
+
import { TestPhonesInput, testPhonesAction, testPhonesState } from '../../../lib/push/staff';
|
|
6
|
+
|
|
7
|
+
// Test phones (Admin → Announcements and Admin → Mobile app; registered only
|
|
8
|
+
// when push is enabled): each person links and removes their own phones.
|
|
9
|
+
export const GET: APIRoute = async ({ request, cookies, locals }) => {
|
|
10
|
+
try {
|
|
11
|
+
const repo = await getRepo(locals);
|
|
12
|
+
const access = await requirePermission(request, cookies, repo, 'services.manage');
|
|
13
|
+
return json(await testPhonesState(repo, access.userId));
|
|
14
|
+
} catch (e) { return apiError(e); }
|
|
15
|
+
};
|
|
16
|
+
export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
|
|
17
|
+
try {
|
|
18
|
+
const repo = await getRepo(locals);
|
|
19
|
+
const access = await requirePermission(request, cookies, repo, 'services.manage');
|
|
20
|
+
// a link code gets this person's test pushes: only from this site's admin
|
|
21
|
+
if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
|
|
22
|
+
const input = TestPhonesInput.parse(await readJson(request, 2_000));
|
|
23
|
+
return json(await testPhonesAction(repo, input, access.userId));
|
|
24
|
+
} catch (e) { return apiError(e); }
|
|
25
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../../../../lib/repo';
|
|
3
|
+
import { linkDevice, unlinkDevice } from '../../../../../lib/push/api';
|
|
4
|
+
import { failure, limitRequest, methodNotAllowed, response } from '../../../../../lib/mobile/http';
|
|
5
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
6
|
+
// POST: link this phone to a staff member (a code from the admin's Test phones) for test pushes. DELETE: unlink (idempotent).
|
|
7
|
+
function handler(run: (repo: Awaited<ReturnType<typeof getRepo>>, request: Request, token: string, address: string, locals: unknown) => Promise<unknown>): APIRoute {
|
|
8
|
+
return async context => {
|
|
9
|
+
try {
|
|
10
|
+
const repo = await getRepo(context.locals);
|
|
11
|
+
let address = 'unknown';
|
|
12
|
+
try { address = context.clientAddress || address; } catch { /* shared fallback bucket */ }
|
|
13
|
+
await limitRequest(repo, address, true);
|
|
14
|
+
return response(await run(repo, context.request, context.params.token ?? '', address, context.locals));
|
|
15
|
+
} catch (error) { return failure(error); }
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export const POST = handler((repo, request, token, address, locals) => linkDevice(repo, request, token, address, locals));
|
|
19
|
+
export const DELETE = handler((repo, _request, token) => unlinkDevice(repo, token));
|
|
20
|
+
export const ALL = () => methodNotAllowed('POST, DELETE, OPTIONS');
|