@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/repo/sql.ts
CHANGED
|
@@ -212,9 +212,14 @@ function rowToBooking(r: Row): Booking {
|
|
|
212
212
|
formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
|
|
213
213
|
answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web', locale: r.locale ?? '',
|
|
214
214
|
notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
|
|
215
|
+
holdUntil: r.hold_until === null || r.hold_until === undefined ? null : Number(r.hold_until),
|
|
215
216
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
216
217
|
});
|
|
217
218
|
}
|
|
219
|
+
// RSVP statuses that take seats (a held offer too), and those that are the
|
|
220
|
+
// address's RSVP for a date (a place in line too)
|
|
221
|
+
const SEATED = "('pending','confirmed','offered')";
|
|
222
|
+
const RSVP_ACTIVE = "('pending','confirmed','offered','waitlisted')";
|
|
218
223
|
function rowToApiKey(r: Row): ApiKey {
|
|
219
224
|
return ApiKey.parse({
|
|
220
225
|
id: r.id, name: r.name, prefix: r.prefix, scopes: json(r.scopes, []),
|
|
@@ -446,7 +451,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
446
451
|
await this.ready;
|
|
447
452
|
// One SELECT snapshots bookings and their retry ledger together. No UI
|
|
448
453
|
// list limit; include tombstones for deleted bookings as well.
|
|
449
|
-
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
454
|
+
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','locale','reminder_sent_at','hold_until','notification_version','created_at','updated_at'];
|
|
450
455
|
const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
|
|
451
456
|
UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
|
|
452
457
|
return {
|
|
@@ -917,6 +922,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
917
922
|
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
918
923
|
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
919
924
|
WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`,args:[id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
925
|
+
// places in line and held spots move with the date too; only its cancellation emails them
|
|
926
|
+
{sql:`UPDATE bookings SET notification_version=notification_version+?,
|
|
927
|
+
timezone=COALESCE(NULLIF((SELECT timezone FROM events WHERE id=?),''),?),
|
|
928
|
+
status=CASE WHEN ? THEN 'cancelled' ELSE status END,
|
|
929
|
+
start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
|
|
930
|
+
end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
|
|
931
|
+
WHERE event_id=? AND kind='rsvp' AND status IN ('waitlisted','offered')`,args:[cancelled?1:0,id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
|
|
920
932
|
...notifications]);
|
|
921
933
|
} else await this.driver.batch([eventWrite]);
|
|
922
934
|
await this.syncEventIndex(id);
|
|
@@ -948,7 +960,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
948
960
|
const mutation = crypto.randomUUID();
|
|
949
961
|
const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
|
|
950
962
|
await this.driver.batch([
|
|
951
|
-
{sql
|
|
963
|
+
{sql:`UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=? WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[mutation,Date.now(),id,start]},
|
|
952
964
|
...notifications,
|
|
953
965
|
]);
|
|
954
966
|
return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
|
|
@@ -972,7 +984,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
972
984
|
const mutation = crypto.randomUUID();
|
|
973
985
|
const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
|
|
974
986
|
await this.driver.batch([
|
|
975
|
-
{sql
|
|
987
|
+
{sql:`UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL WHERE event_id=? AND kind='rsvp' AND status IN ${RSVP_ACTIVE}`,args:[mutation,id]},
|
|
976
988
|
...notifications,
|
|
977
989
|
{ sql: 'DELETE FROM events WHERE id = ?', args: [id] },
|
|
978
990
|
{ sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
|
|
@@ -1078,9 +1090,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1078
1090
|
private bookingInsert(b: Booking): SqlStatement {
|
|
1079
1091
|
return {
|
|
1080
1092
|
sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
|
|
1081
|
-
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1093
|
+
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response, locale, hold_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1082
1094
|
args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
|
|
1083
|
-
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale],
|
|
1095
|
+
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null, b.locale, b.holdUntil],
|
|
1084
1096
|
};
|
|
1085
1097
|
}
|
|
1086
1098
|
async listResources() {
|
|
@@ -1221,12 +1233,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
1221
1233
|
const holds = b.status === 'pending' || b.status === 'confirmed';
|
|
1222
1234
|
let write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE 1=1`, args: ins.args };
|
|
1223
1235
|
if (b.kind === 'rsvp') {
|
|
1224
|
-
|
|
1236
|
+
// a place in line takes no seats
|
|
1237
|
+
const cap = holds ? guard.capacity ?? 0 : 0;
|
|
1225
1238
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
|
|
1226
|
-
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN
|
|
1239
|
+
WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ${SEATED}) + ? <= ?)`,
|
|
1227
1240
|
args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
|
|
1228
1241
|
// same statement, so two at once from one address can't both get in
|
|
1229
|
-
if (guard.oncePerEmail && holds && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN
|
|
1242
|
+
if (guard.oncePerEmail && (holds || b.status === 'waitlisted') && b.email) write = { sql: write.sql + ` AND NOT EXISTS (SELECT 1 FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND email=? AND status IN ${RSVP_ACTIVE})`,
|
|
1230
1243
|
args: [...write.args!,b.eventId,b.start,b.email] };
|
|
1231
1244
|
} else if (holds && b.resourceId) {
|
|
1232
1245
|
write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
|
|
@@ -1241,7 +1254,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1241
1254
|
SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
|
|
1242
1255
|
}
|
|
1243
1256
|
// Booking, retry ledger and email outbox commit together on D1 and libSQL.
|
|
1244
|
-
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
|
|
1257
|
+
await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : b.status === 'waitlisted' ? 'waitlisted' : 'received', 0, true), ...rsvpStaffJobs(b.id, 'staff-rsvp', guard.staff, 0)]);
|
|
1245
1258
|
return this.getBooking(b.id);
|
|
1246
1259
|
}
|
|
1247
1260
|
async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
|
|
@@ -1276,10 +1289,37 @@ export class SqlRepository implements ContentRepository {
|
|
|
1276
1289
|
if (data.answers !== undefined) { sets.push('answers = ?'); args.push(JSON.stringify(data.answers)); }
|
|
1277
1290
|
if (data.reminderSentAt !== undefined) { sets.push('reminder_sent_at = ?'); args.push(data.reminderSentAt); }
|
|
1278
1291
|
args.push(id);
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1292
|
+
if (data.from && data.from !== current.status) return;
|
|
1293
|
+
// the guest's email: leaving the line (or a held spot) isn't a cancelled RSVP; a spot that ran out sends none
|
|
1294
|
+
const waiting = current.status === 'waitlisted' || current.status === 'offered';
|
|
1295
|
+
const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received'
|
|
1296
|
+
: data.status === 'offered' ? 'offered' : data.status === 'expired' ? '' : waiting ? 'left' : 'cancelled';
|
|
1297
|
+
// staff: a confirmed offer is a new RSVP to them
|
|
1298
|
+
const staff = data.status === 'cancelled' ? 'staff-rsvp-cancelled' as const : data.status === 'confirmed' || data.status === 'pending' ? 'staff-rsvp' as const : null;
|
|
1299
|
+
await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?${data.from ? ' AND status = ?' : ''}`,args:[...args,current.notificationVersion,...(data.from ? [data.from] : [])]},
|
|
1300
|
+
...(changed && kind ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : []),
|
|
1301
|
+
...(changed && staff ? rsvpStaffJobs(id,staff,data.staff,current.notificationVersion+1,mutation) : [])]);
|
|
1302
|
+
}
|
|
1303
|
+
// waitlisted → offered only while its seats still fit: the seat count is in
|
|
1304
|
+
// the same statement, like createBooking's, so two offers (or an offer and a
|
|
1305
|
+
// new RSVP) can't both take the last seats. The email is queued with it.
|
|
1306
|
+
async offerBooking(id: string, holdUntil: number, capacity: number) {
|
|
1307
|
+
await this.ready;
|
|
1308
|
+
const mutation = crypto.randomUUID();
|
|
1309
|
+
await this.driver.batch([
|
|
1310
|
+
{ sql: `UPDATE bookings SET status='offered',hold_until=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
|
|
1311
|
+
WHERE id=? AND kind='rsvp' AND status='waitlisted' AND (? = 0 OR (SELECT COALESCE(SUM(t.seats),0) FROM bookings t WHERE t.kind='rsvp' AND t.event_id=bookings.event_id AND t.start_at=bookings.start_at AND t.status IN ${SEATED}) + seats <= ?)`,
|
|
1312
|
+
args: [holdUntil, mutation, Date.now(), id, capacity, capacity] },
|
|
1313
|
+
...bookingJobs(id, 'offered', undefined, false, mutation),
|
|
1314
|
+
]);
|
|
1315
|
+
return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?', [id, mutation]);
|
|
1316
|
+
}
|
|
1317
|
+
async expireBooking(id: string, now: number) {
|
|
1318
|
+
await this.ready;
|
|
1319
|
+
const mutation = crypto.randomUUID();
|
|
1320
|
+
await this.driver.run(`UPDATE bookings SET status='expired',notification_version=notification_version+1,email_mutation=?,updated_at=?
|
|
1321
|
+
WHERE id=? AND kind='rsvp' AND status='offered' AND hold_until <= ?`, [mutation, Date.now(), id, now]);
|
|
1322
|
+
return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?', [id, mutation]);
|
|
1283
1323
|
}
|
|
1284
1324
|
async deleteBooking(id: string) {
|
|
1285
1325
|
await this.ready;
|
|
@@ -1668,7 +1708,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1668
1708
|
}
|
|
1669
1709
|
async deleteUser(id: string) {
|
|
1670
1710
|
await this.ready;
|
|
1671
|
-
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] }]);
|
|
1672
1715
|
}
|
|
1673
1716
|
async getUserAuth(email: string) {
|
|
1674
1717
|
await this.ready;
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -198,15 +198,22 @@ export interface ContentRepository {
|
|
|
198
198
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
199
199
|
// bookings + RSVPs (never staff blocks) this address made since `since`
|
|
200
200
|
countBookingsByEmail(email: string, since: number): Promise<number>;
|
|
201
|
-
// locale: the booker's language; '' (the main language) when left out
|
|
201
|
+
// locale: the booker's language; '' (the main language) when left out.
|
|
202
|
+
// A waitlisted RSVP takes no seats, so only oncePerEmail guards it.
|
|
202
203
|
createBooking(
|
|
203
|
-
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
|
|
204
|
+
data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale' | 'holdUntil'> & { locale?: string },
|
|
204
205
|
guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string }; staff?: RsvpStaffNotice }
|
|
205
206
|
): Promise<Booking | null>;
|
|
206
207
|
// moves a booking only if the new held range is free (reschedule)
|
|
207
208
|
moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
|
|
208
|
-
// staff: who hears that the person cancelled their RSVP
|
|
209
|
-
|
|
209
|
+
// staff: who hears that the person cancelled their RSVP, or confirmed a spot
|
|
210
|
+
// offered from the waitlist; from: change it only if it still has that status
|
|
211
|
+
updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { from?: BookingStatus; notificationMessage?: string; staff?: RsvpStaffNotice }): Promise<void>;
|
|
212
|
+
// a waitlisted RSVP becomes offered (held until holdUntil, its email queued
|
|
213
|
+
// with it) only if its seats still fit `capacity` (0 = no limit); false when they don't
|
|
214
|
+
offerBooking(id: string, holdUntil: number, capacity: number): Promise<boolean>;
|
|
215
|
+
// an offered RSVP whose hold ended by `now` becomes expired (no email); false when it wasn't
|
|
216
|
+
expireBooking(id: string, now: number): Promise<boolean>;
|
|
210
217
|
deleteBooking(id: string): Promise<void>;
|
|
211
218
|
// confirmed bookings starting in [from, to) that haven't had a reminder
|
|
212
219
|
listDueReminders(from: number, to: number): Promise<Booking[]>;
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { syncDueLive } from './live/service';
|
|
|
2
2
|
import type { ContentRepository } from './repo/types';
|
|
3
3
|
import { processDueDeliveries } from './webhooks';
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
|
-
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
5
|
+
import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
7
|
import { runAnnouncementJobs } from './announcements/service';
|
|
8
8
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
@@ -21,6 +21,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
21
21
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
22
22
|
// pending bookings past their approval deadline (Calendar settings → Bookings)
|
|
23
23
|
approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
24
|
+
// held waitlist spots that ran out, and freed seats offered to those in line (before email, so offers go out in this run)
|
|
25
|
+
waitlist: () => sweepWaitlist(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
24
26
|
email: () => dispatchEmail(repo, locals, 10),
|
|
25
27
|
live: () => syncDueLive(repo, locals),
|
|
26
28
|
// scheduled announcements go up (with their push) and ended ones come down
|
package/src/lib/schema.ts
CHANGED
|
@@ -419,7 +419,9 @@ export const CalEvent = z.object({
|
|
|
419
419
|
// (wall time in the event's zone, like start; every date), else closeBefore
|
|
420
420
|
// minutes before each date's start, else when the date ends. notifyStaff:
|
|
421
421
|
// the hosts (else the church's contact email) hear about each new RSVP and
|
|
422
|
-
// each one the person cancels (lib/booking/service.ts rsvpStaff).
|
|
422
|
+
// each one the person cancels (lib/booking/service.ts rsvpStaff). waitlist:
|
|
423
|
+
// a full date takes places in line, and freed seats are offered in turn
|
|
424
|
+
// (lib/booking/service.ts offerWaitlist); only with a capacity.
|
|
423
425
|
rsvp: z.object({
|
|
424
426
|
enabled: z.boolean().default(false),
|
|
425
427
|
capacity: z.number().int().default(0),
|
|
@@ -428,6 +430,7 @@ export const CalEvent = z.object({
|
|
|
428
430
|
closesAt: z.string().nullable().default(null),
|
|
429
431
|
closeBefore: z.number().int().default(0),
|
|
430
432
|
notifyStaff: z.boolean().default(true),
|
|
433
|
+
waitlist: z.boolean().default(false),
|
|
431
434
|
form: FormDefinition.optional(),
|
|
432
435
|
}).default({}),
|
|
433
436
|
// rooms/people this event uses: they can't be booked while it happens (or
|
|
@@ -653,7 +656,10 @@ export const BookingLink = z.object({
|
|
|
653
656
|
});
|
|
654
657
|
export type BookingLink = z.infer<typeof BookingLink>;
|
|
655
658
|
|
|
656
|
-
|
|
659
|
+
// RSVPs only: waitlisted = in line for a full date (takes no seats); offered =
|
|
660
|
+
// a freed spot held for them until holdUntil (takes its seats); expired = they
|
|
661
|
+
// didn't confirm it in time
|
|
662
|
+
export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']);
|
|
657
663
|
export type BookingStatus = z.infer<typeof BookingStatus>;
|
|
658
664
|
|
|
659
665
|
export const Booking = z.object({
|
|
@@ -685,6 +691,8 @@ export const Booking = z.object({
|
|
|
685
691
|
// emails and links speak it. '' = the site's main language, as on page rows
|
|
686
692
|
locale: z.string().default(''),
|
|
687
693
|
reminderSentAt: z.number().nullable().default(null),
|
|
694
|
+
// an offered spot is theirs until then (ms)
|
|
695
|
+
holdUntil: z.number().nullable().default(null),
|
|
688
696
|
notificationVersion: z.number().default(0),
|
|
689
697
|
createdAt: z.number(),
|
|
690
698
|
updatedAt: z.number(),
|
|
@@ -19,7 +19,9 @@ import { forbiddenUnless } from '../../../lib/permissions/access';
|
|
|
19
19
|
|
|
20
20
|
// Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
|
|
21
21
|
// requests first, a Day view with one column per resource, a details panel
|
|
22
|
-
// (?b=<id>) with private notes, plus Block time and Add booking.
|
|
22
|
+
// (?b=<id>) with private notes, plus Block time and Add booking. RSVPs on a
|
|
23
|
+
// full date's waitlist (in line, or a spot offered and held) list apart from
|
|
24
|
+
// the confirmed ones, under Waitlist.
|
|
23
25
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
24
26
|
const repo = await getRepo(Astro.locals);
|
|
25
27
|
{ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.bookings'); if (denied) return denied; }
|
|
@@ -108,7 +110,7 @@ if (Astro.request.method === 'POST') {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// ---- data ----
|
|
111
|
-
const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
113
|
+
const tab = (['upcoming', 'unconfirmed', 'waitlist', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
112
114
|
const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
|
|
113
115
|
const eventFilter = Astro.url.searchParams.get('event') ?? '';
|
|
114
116
|
const resFilter = Astro.url.searchParams.get('r') ?? '';
|
|
@@ -119,7 +121,7 @@ const titleOf = (b: Booking) =>
|
|
|
119
121
|
: b.kind === 'rsvp' ? b.name
|
|
120
122
|
: b.name || 'Booking';
|
|
121
123
|
const contextOf = (b: Booking) =>
|
|
122
|
-
b.kind === 'rsvp' ? `RSVP · ${evById.get(b.eventId)?.title ?? 'event'}
|
|
124
|
+
b.kind === 'rsvp' ? [`RSVP · ${evById.get(b.eventId)?.title ?? 'event'}`, ...(b.seats > 1 ? [`${b.seats} seats`] : []), ...(waiting(b) ? [waitOf(b)] : [])].join(' · ')
|
|
123
125
|
: [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
|
|
124
126
|
const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
|
|
125
127
|
const matches = (b: Booking) =>
|
|
@@ -127,17 +129,31 @@ const matches = (b: Booking) =>
|
|
|
127
129
|
(!resFilter || b.resourceId === resFilter) &&
|
|
128
130
|
(!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
|
|
129
131
|
const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
|
|
132
|
+
// in line for a full date, or a spot offered and held for them
|
|
133
|
+
const waiting = (b: Booking) => b.status === 'waitlisted' || b.status === 'offered';
|
|
130
134
|
const counts = {
|
|
131
135
|
upcoming: all.filter((b) => active(b) && b.end > now && matches(b)).length,
|
|
132
136
|
unconfirmed: all.filter((b) => b.status === 'pending' && b.end > now && matches(b)).length,
|
|
137
|
+
waitlist: all.filter((b) => waiting(b) && b.end > now && matches(b)).length,
|
|
133
138
|
past: all.filter((b) => active(b) && b.end <= now && matches(b)).length,
|
|
134
|
-
cancelled: all.filter((b) => !active(b) && matches(b)).length,
|
|
139
|
+
cancelled: all.filter((b) => !active(b) && !waiting(b) && matches(b)).length,
|
|
135
140
|
};
|
|
136
141
|
const list = all
|
|
137
142
|
.filter(matches)
|
|
138
|
-
.filter((b) => tab === 'upcoming' ? active(b) && b.end > now : tab === 'unconfirmed' ? b.status === 'pending' && b.end > now
|
|
139
|
-
|
|
143
|
+
.filter((b) => tab === 'upcoming' ? (active(b) || waiting(b)) && b.end > now : tab === 'unconfirmed' ? b.status === 'pending' && b.end > now
|
|
144
|
+
: tab === 'waitlist' ? waiting(b) && b.end > now : tab === 'past' ? active(b) && b.end <= now : !active(b) && !waiting(b))
|
|
145
|
+
.sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start || a.createdAt - b.createdAt))
|
|
140
146
|
.slice(0, 300);
|
|
147
|
+
// each date's line: who joined first, then by id (lib/booking/service.ts waitlistLine)
|
|
148
|
+
const lines = new Map<string, Booking[]>();
|
|
149
|
+
for (const b of all.filter((x) => x.status === 'waitlisted').sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))) {
|
|
150
|
+
const k = `${b.eventId}:${b.start}`;
|
|
151
|
+
lines.set(k, [...(lines.get(k) ?? []), b]);
|
|
152
|
+
}
|
|
153
|
+
const placeOf = (b: Booking) => (lines.get(`${b.eventId}:${b.start}`) ?? []).findIndex((x) => x.id === b.id) + 1;
|
|
154
|
+
const heldUntil = (b: Booking) => (b.holdUntil ? `${f.day.format(b.holdUntil)} ${f.time.format(b.holdUntil)}` : '');
|
|
155
|
+
// "#2 in line" / "held until Fri, Oct 24 9:00 PM"
|
|
156
|
+
const waitOf = (b: Booking) => (b.status === 'waitlisted' ? `#${placeOf(b)} in line` : b.status === 'offered' ? `held until ${heldUntil(b)}` : '');
|
|
141
157
|
if (Astro.url.searchParams.get('format') === 'csv') {
|
|
142
158
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
143
159
|
const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
|
|
@@ -150,7 +166,9 @@ if (Astro.url.searchParams.get('format') === 'csv') {
|
|
|
150
166
|
});
|
|
151
167
|
}
|
|
152
168
|
const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
|
|
153
|
-
|
|
169
|
+
// on Upcoming, the waitlist is a group of its own (like requests to approve), apart from those going
|
|
170
|
+
const queued = tab === 'upcoming' ? list.filter(waiting) : [];
|
|
171
|
+
const agendaItems = tab === 'upcoming' ? list.filter((b) => b.status !== 'pending' && !waiting(b)) : list;
|
|
154
172
|
const days = new Map<string, Booking[]>();
|
|
155
173
|
for (const b of agendaItems) {
|
|
156
174
|
const k = ymd(b.start, tz);
|
|
@@ -183,8 +201,13 @@ const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider =
|
|
|
183
201
|
? 'Emails are turned off. Enable email so guests can receive confirmations and reminders.'
|
|
184
202
|
: !emailStatus.configured ? `Email needs attention: ${emailStatus.error || 'Choose a provider in Email settings.'}`
|
|
185
203
|
: emailStatus.linkError;
|
|
186
|
-
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
187
|
-
const
|
|
204
|
+
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['waitlist', 'Waitlist'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
205
|
+
const LABELS: Partial<Record<Booking['status'], string>> = { waitlisted: 'Waitlist', offered: 'Spot offered', expired: 'Hold expired' };
|
|
206
|
+
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : LABELS[b.status] ?? b.status[0].toUpperCase() + b.status.slice(1));
|
|
207
|
+
// an event's registrations: "40 going · 3 waiting" across its upcoming dates
|
|
208
|
+
const evUpcoming = eventFilter ? all.filter((b) => b.kind === 'rsvp' && b.eventId === eventFilter && b.end > now) : [];
|
|
209
|
+
const evWaiting = evUpcoming.filter((b) => b.status === 'waitlisted').length, evOffered = evUpcoming.filter((b) => b.status === 'offered').length;
|
|
210
|
+
const evCounts = [`${evUpcoming.filter(active).reduce((n, b) => n + b.seats, 0)} going`, ...(evWaiting ? [`${evWaiting} waiting`] : []), ...(evOffered ? [`${evOffered} ${evOffered === 1 ? 'spot' : 'spots'} offered`] : [])].join(' · ');
|
|
188
211
|
// where it was made: the website, an app (/api/v1/bookings), staff, an API key or the booking agent
|
|
189
212
|
const SOURCES: Record<Booking['source'], string> = { web: 'Website', app: 'App', admin: 'Staff', api: 'API', agent: 'Agent' };
|
|
190
213
|
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
@@ -204,7 +227,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
204
227
|
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
205
228
|
</BookingsHead>
|
|
206
229
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
207
|
-
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
230
|
+
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · {evCounts} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
208
231
|
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
209
232
|
{emailNotice && <p class="notice warn"><span>{emailNotice} <a href={emailStatus.configured && emailStatus.linkError ? '/admin/settings#site' : '/admin/email'}>{emailStatus.configured && emailStatus.linkError ? 'Site settings' : 'Email settings'}</a></span></p>}
|
|
210
233
|
|
|
@@ -227,7 +250,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
227
250
|
<div class="list-lead">
|
|
228
251
|
{view === 'agenda' ? (
|
|
229
252
|
<nav class="seg" aria-label="Show">
|
|
230
|
-
{TABS.map(([k, l]) => <a class={tab === k ? 'on' : ''} href={keep({ tab: k === 'upcoming' ? null : k, b: null })}>{l}{counts[k] > 0 && k === 'unconfirmed' ? ` · ${counts[k]}` : ''}</a>)}
|
|
253
|
+
{TABS.map(([k, l]) => <a class={tab === k ? 'on' : ''} href={keep({ tab: k === 'upcoming' ? null : k, b: null })}>{l}{counts[k] > 0 && (k === 'unconfirmed' || k === 'waitlist') ? ` · ${counts[k]}` : ''}</a>)}
|
|
231
254
|
</nav>
|
|
232
255
|
) : (
|
|
233
256
|
<nav class="seg" aria-label="Day">
|
|
@@ -295,6 +318,19 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
295
318
|
))}
|
|
296
319
|
</section>
|
|
297
320
|
)}
|
|
321
|
+
{queued.length > 0 && (
|
|
322
|
+
<section class="card bk-group">
|
|
323
|
+
<div class="bk-dayh"><b>Waitlist</b><span>{queued.length}</span></div>
|
|
324
|
+
{queued.map((b) => (
|
|
325
|
+
<a class={`bk-row${selected?.id === b.id ? ' is-sel' : ''}`} href={keep({ b: b.id })}>
|
|
326
|
+
<span class="bk-when">{f.day.format(b.start)}<small>{whenOf(b)}</small></span>
|
|
327
|
+
<span class="bk-who"><b>{titleOf(b)}</b><small>{contextOf(b)}</small></span>
|
|
328
|
+
<span class="bk-res">{where(b)}</span>
|
|
329
|
+
<span class={`status bk-st is-${b.status}`}>{statusLabel(b)}</span>
|
|
330
|
+
</a>
|
|
331
|
+
))}
|
|
332
|
+
</section>
|
|
333
|
+
)}
|
|
298
334
|
{[...days.entries()].map(([k, items]) => (
|
|
299
335
|
<section class="card bk-group">
|
|
300
336
|
<div class="bk-dayh"><b>{k === today ? 'Today · ' : ''}{f.dayLong.format(items[0].start)}</b><span>{items.length}</span></div>
|
|
@@ -308,7 +344,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
308
344
|
))}
|
|
309
345
|
</section>
|
|
310
346
|
))}
|
|
311
|
-
{!list.length && <p class="hint" style="padding:24px 8px">{tab === 'unconfirmed' ? 'Nothing waiting — auto-confirm links never land here.' : 'No bookings here.'}</p>}
|
|
347
|
+
{!list.length && <p class="hint" style="padding:24px 8px">{tab === 'unconfirmed' ? 'Nothing waiting — auto-confirm links never land here.' : tab === 'waitlist' ? 'No one is waiting. People can join a waitlist when an event that offers one is full.' : 'No bookings here.'}</p>}
|
|
312
348
|
</>
|
|
313
349
|
)}
|
|
314
350
|
</div>
|
|
@@ -328,6 +364,8 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
328
364
|
{b.phone && <div><dt>Phone</dt><dd><a href={`tel:${b.phone}`}>{b.phone}</a></dd></div>}
|
|
329
365
|
<div><dt>{ev ? 'Event' : 'Where'}</dt><dd>{ev ? <a href={eventUrl(ev.slug)} target="_blank" rel="noopener">{ev.title}</a> : r?.name ?? '—'}{l ? ` · ${l.name}` : ''}</dd></div>
|
|
330
366
|
{b.seats > 1 && <div><dt>Seats</dt><dd>{b.seats}</dd></div>}
|
|
367
|
+
{b.status === 'waitlisted' && <div><dt>Waitlist</dt><dd>#{placeOf(b)} in line</dd></div>}
|
|
368
|
+
{b.status === 'offered' && <div><dt>Spot held</dt><dd>Until {heldUntil(b)} · they confirm it from their email</dd></div>}
|
|
331
369
|
{Object.entries(b.answers).map(([k, v]) => <div><dt>{k}</dt><dd>{v}</dd></div>)}
|
|
332
370
|
<div><dt>Booked</dt><dd>{f.day.format(b.createdAt)} · {SOURCES[b.source] ?? b.source}</dd></div>
|
|
333
371
|
</dl>
|
|
@@ -361,7 +399,13 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
361
399
|
</form>
|
|
362
400
|
</>
|
|
363
401
|
)}
|
|
364
|
-
{(b
|
|
402
|
+
{waiting(b) && b.end > now && (
|
|
403
|
+
<form method="post">
|
|
404
|
+
<input type="hidden" name="op" value="status" /><input type="hidden" name="id" value={b.id} /><input type="hidden" name="to" value="cancelled" />
|
|
405
|
+
<button class="btn sm danger ghost" type="submit" data-confirm={b.status === 'offered' ? 'Take back the held spot and email them? It goes to the next person in line.' : 'Remove them from the waitlist and email them?'}>{b.status === 'offered' ? 'Take back the spot' : 'Remove from waitlist'}</button>
|
|
406
|
+
</form>
|
|
407
|
+
)}
|
|
408
|
+
{(b.kind === 'block' || !(b.status === 'pending' || b.status === 'confirmed' || waiting(b))) && (
|
|
365
409
|
<form method="post">
|
|
366
410
|
<input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={b.id} />
|
|
367
411
|
<button class="btn sm danger ghost" type="submit" data-confirm="Remove this entry for good?">{b.kind === 'block' ? 'Remove block' : 'Delete record'}</button>
|
|
@@ -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
|
};
|
|
@@ -56,7 +56,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
|
56
56
|
const day = new Date(start).toISOString().slice(0, 10);
|
|
57
57
|
const occ = featuredOccurrence(e, settings, Date.now(), day);
|
|
58
58
|
if (!occ || Math.abs(occ.start - start) > 60_000) throw new ApiError(400, 'That start time isn’t an occurrence of this event.');
|
|
59
|
-
const b = await createRsvp(repo, { event: e, start: occ.start, end: occ.end, seats: Number(body.seats) || 1, booker, source }, flow);
|
|
59
|
+
const b = await createRsvp(repo, { event: e, start: occ.start, end: occ.end, seats: Number(body.seats) || 1, booker, source, waitlist: body.waitlist === true }, flow);
|
|
60
60
|
return withCors(json({ booking: bookingJson(b), manageToken: b.manageToken }, 201));
|
|
61
61
|
}
|
|
62
62
|
const resource = await repo.getResourceBySlug(String(body.resource ?? ''));
|
|
@@ -45,8 +45,8 @@ POST /api/calendar/bookings (no key needed — same rules as the bo
|
|
|
45
45
|
"name": "Maria Delgado", "email": "maria@example.com", "phone": "", "answers": { "Expected guests": "60" } }
|
|
46
46
|
RSVP to an event occurrence: { "event": "<slug>", "start": "<occurrence ISO>", "seats": 2, "name": "…", "email": "…" }
|
|
47
47
|
→ 201 { booking, manageToken } status "confirmed" (auto-confirm links) or "pending" (approval)
|
|
48
|
-
→ 409 if the time was just taken
|
|
49
|
-
GET /api/calendar/bookings?status=pending&resource=<slug>&from=…&to=… (bookings:read)
|
|
48
|
+
→ 409 if the time was just taken (a full date: add "waitlist": true to join its waitlist, status "waitlisted", when the event keeps one)
|
|
49
|
+
GET /api/calendar/bookings?status=pending&resource=<slug>&from=…&to=… (bookings:read; RSVP statuses also waitlisted, offered, expired)
|
|
50
50
|
GET /api/calendar/bookings/<id> (bookings:read)
|
|
51
51
|
PATCH /api/calendar/bookings/<id> { "status": "confirmed" | "declined" | "cancelled", "start": "<ISO>", "notes": "…", "message": "…" } (bookings:write)
|
|
52
52
|
Guests manage their own booking at ${o}${CALENDAR.bookBase}/manage/<manageToken>.
|
|
@@ -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,6 @@
|
|
|
1
|
+
import { confirmManaged } from '../../../../../lib/mobile/manage';
|
|
2
|
+
import { privateRoute } from '../../../../../lib/mobile/route';
|
|
3
|
+
import { methodNotAllowed } from '../../../../../lib/mobile/http';
|
|
4
|
+
export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
|
|
5
|
+
export const POST = privateRoute(confirmManaged, { write: true });
|
|
6
|
+
export const ALL = () => methodNotAllowed('POST, OPTIONS');
|
|
@@ -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');
|