@acms-subir/core 0.3.40 → 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.
- package/CHANGELOG.md +24 -0
- package/cli/migrations.generated.mjs +15 -0
- package/docs/mobile.md +12 -2
- package/integration.mjs +4 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- 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/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/recipe.json +2 -2
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/Announcements.tsx +11 -2
- package/src/components/EventPage.astro +16 -8
- package/src/components/MobileApp.tsx +20 -8
- package/src/components/TestPhones.tsx +177 -0
- package/src/components/editor/EventPanel.tsx +2 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +5 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/announcements/api.ts +19 -2
- package/src/lib/announcements/service.ts +1 -1
- package/src/lib/booking/agent.ts +1 -1
- package/src/lib/booking/emails.ts +20 -5
- package/src/lib/booking/service.ts +148 -14
- package/src/lib/calendar/rsvp-words.ts +69 -8
- package/src/lib/content-service.ts +11 -6
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/bookings.ts +5 -3
- package/src/lib/editor/documents.ts +3 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +26 -2
- package/src/lib/email/service.ts +14 -2
- package/src/lib/email/store.ts +6 -2
- package/src/lib/email/templates.ts +20 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +4 -4
- package/src/lib/legal/text-en.ts +2 -2
- package/src/lib/legal/text-es.ts +2 -2
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/guest.ts +2 -1
- package/src/lib/mobile/manage.ts +17 -3
- package/src/lib/mobile/openapi.ts +22 -5
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +14 -3
- 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 +4 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +57 -14
- package/src/lib/repo/types.ts +11 -4
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +10 -2
- package/src/pages/admin/calendar/bookings.astro +57 -13
- package/src/pages/api/announcements.ts +2 -2
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.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/bookings/manage/confirm.ts +6 -0
- package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
- package/src/pages/book/manage/[token].astro +57 -15
- package/src/pages/events/[slug].astro +3 -2
- package/src/styles/admin.css +3 -1
- package/src/styles/site.css +10 -1
- package/src/styles/test-phones.css +30 -0
- package/src/theme-sdk/index.ts +1 -1
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { CalEvent, Settings } from '../schema';
|
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { answerFields } from '../forms/schema';
|
|
6
6
|
import { eventIsPublic, expandEvents, rsvpDeadline, type Occurrence } from '../calendar/events';
|
|
7
|
-
import { seatsTaken } from '../booking/service';
|
|
7
|
+
import { seatsTaken, waitlistLine, waitlistOn } from '../booking/service';
|
|
8
8
|
import { localizeDoc } from '../translations';
|
|
9
9
|
import { siteLang } from '../dates';
|
|
10
10
|
import { rsvpStrings } from '../calendar/rsvp-words';
|
|
@@ -14,9 +14,11 @@ import { formJson } from './forms';
|
|
|
14
14
|
import { localeQuery, mobileLocale } from './locale';
|
|
15
15
|
|
|
16
16
|
// RSVP status of one date of an event, for apps: whether it takes RSVPs now,
|
|
17
|
-
// live seats and the questions with their version. The same
|
|
18
|
-
// event's web page: open until the event's deadline or the date
|
|
19
|
-
//
|
|
17
|
+
// live seats, its waitlist and the questions with their version. The same
|
|
18
|
+
// rules as the event's web page: open until the event's deadline or the date
|
|
19
|
+
// ends; a full date with a waitlist takes places in line (POST /bookings with
|
|
20
|
+
// waitlist: true). Never cached; seats change with every RSVP. POST /bookings
|
|
21
|
+
// saves one.
|
|
20
22
|
|
|
21
23
|
/** a public event (slug or id) and its occurrence starting exactly at `start` */
|
|
22
24
|
export async function rsvpOccurrence(repo: ContentRepository, ref: string, start: string): Promise<Occurrence> {
|
|
@@ -52,8 +54,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
52
54
|
const o = await rsvpOccurrence(repo, slug, q.start);
|
|
53
55
|
const settings = await repo.getSettings();
|
|
54
56
|
const { rsvp } = o.event;
|
|
55
|
-
// pending and
|
|
57
|
+
// pending, confirmed and held (waitlist) RSVPs take seats, as in the capacity check
|
|
56
58
|
const going = rsvp.enabled ? await seatsTaken(repo, o.event.id, o.start) : 0;
|
|
59
|
+
const line = waitlistOn(o.event) ? await waitlistLine(repo, o.event.id, o.start) : [];
|
|
57
60
|
const left = rsvp.capacity ? Math.max(0, rsvp.capacity - going) : null;
|
|
58
61
|
const closed = rsvpClosed(o, settings);
|
|
59
62
|
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
@@ -74,6 +77,8 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
74
77
|
closesAt: deadline === null ? null : new Date(deadline).toISOString(),
|
|
75
78
|
// seats one RSVP may take right now
|
|
76
79
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
80
|
+
// a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
|
|
81
|
+
waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
|
|
77
82
|
form: form ? { ...await formJson(form), submitLabel } : null,
|
|
78
83
|
};
|
|
79
84
|
}
|
|
@@ -77,13 +77,20 @@ export const RsvpStatus = z.object({
|
|
|
77
77
|
going: z.number().int(), left: z.number().int().nullable(), capacity: z.number().int(), maxSeats: z.number().int(), autoConfirm: bool,
|
|
78
78
|
// the event's RSVP deadline for this date (null: open until it ends)
|
|
79
79
|
closesAt: date.nullable(),
|
|
80
|
+
// enabled: a full date (reason full) takes places in line; waiting: people in line, seats: theirs; maxSeats: the most one may ask for
|
|
81
|
+
waitlist: z.object({ enabled: bool, waiting: z.number().int(), seats: z.number().int(), maxSeats: z.number().int() }),
|
|
80
82
|
form: Form.omit({ id: true }).nullable(),
|
|
81
83
|
});
|
|
82
|
-
|
|
84
|
+
// confirm: a spot offered from the waitlist is still held for them
|
|
85
|
+
const GuestChanges = z.object({ cancel: bool, reschedule: bool, confirm: bool, cutoffHours: z.number().int(), closed: bool });
|
|
86
|
+
// waitlisted: in line for a full date; offered: a freed spot held until holdUntil; expired: not confirmed in time
|
|
87
|
+
const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']);
|
|
83
88
|
export const ManagedBooking = z.object({
|
|
84
89
|
booking: z.object({
|
|
85
|
-
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status:
|
|
90
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: BookingStatus,
|
|
86
91
|
start: date, end: date, allDay: bool, timezone: str, seats: z.number().int(), name: str, email: str, phone: str, answers: z.record(str), createdAt: date,
|
|
92
|
+
// waitlisted: the place in line (1 = next); offered: until when the spot is held
|
|
93
|
+
position: z.number().int().nullable(), holdUntil: date.nullable(),
|
|
87
94
|
}),
|
|
88
95
|
title: str, location: str,
|
|
89
96
|
resource: z.object({ slug: str, name: str }).nullable(),
|
|
@@ -96,7 +103,7 @@ export const ManagedBooking = z.object({
|
|
|
96
103
|
export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
|
|
97
104
|
export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
|
|
98
105
|
export const GuestBookings = z.object({ email: str, pagination: Pagination, items: z.array(z.object({
|
|
99
|
-
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status:
|
|
106
|
+
id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: BookingStatus,
|
|
100
107
|
start: date, end: date, seats: z.number().int(), title: str, manageToken: str, manageUrl: str, can: GuestChanges,
|
|
101
108
|
})) });
|
|
102
109
|
export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
|
|
@@ -113,11 +120,15 @@ export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'da
|
|
|
113
120
|
export const BookingResult = z.object({ manageToken: str, booking: Booking.pick({
|
|
114
121
|
id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true,
|
|
115
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();
|
|
116
125
|
export const Device = z.object({
|
|
117
126
|
token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, channels: strings, registeredAt: date, lastSeenAt: date,
|
|
127
|
+
staff: LinkedStaff,
|
|
118
128
|
});
|
|
119
129
|
export const DeviceRegistration = z.object({ created: bool, device: Device });
|
|
120
130
|
export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
|
|
131
|
+
export const StaffLink = z.object({ linked: bool, staff: LinkedStaff });
|
|
121
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() })) });
|
|
122
133
|
export const Announcement = z.object({
|
|
123
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,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
|
-
|
|
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 */
|
|
@@ -690,6 +690,21 @@ export const MIGRATIONS: Migration[] = [
|
|
|
690
690
|
{ version: 60, statements: [
|
|
691
691
|
`ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`,
|
|
692
692
|
] },
|
|
693
|
+
// RSVP waitlists: an offered spot is held until hold_until (ms); null for
|
|
694
|
+
// everything else, so older rows need no backfill
|
|
695
|
+
{ version: 61, statements: [
|
|
696
|
+
`ALTER TABLE bookings ADD COLUMN hold_until INTEGER`,
|
|
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
|
+
] },
|
|
693
708
|
];
|
|
694
709
|
|
|
695
710
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|