@acms-subir/core 0.3.38 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/mobile.md +1 -1
  3. package/integration.mjs +1 -0
  4. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
  5. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +2 -1
  8. package/src/components/EventPage.astro +14 -5
  9. package/src/components/MobileApp.tsx +44 -23
  10. package/src/components/editor/EventPanel.tsx +3 -1
  11. package/src/components/editor/PostEditor.tsx +1 -1
  12. package/src/components/editor/event/RegistrationCard.tsx +55 -1
  13. package/src/components/editor/event/registration.ts +18 -1
  14. package/src/lib/booking/emails.ts +37 -11
  15. package/src/lib/booking/service.ts +96 -19
  16. package/src/lib/calendar/api.ts +3 -2
  17. package/src/lib/calendar/events.ts +11 -0
  18. package/src/lib/calendar/members-config.ts +5 -0
  19. package/src/lib/calendar/requests.ts +4 -4
  20. package/src/lib/calendar/rsvp-words.ts +31 -2
  21. package/src/lib/content-service.ts +29 -14
  22. package/src/lib/drafts.ts +2 -0
  23. package/src/lib/editor/documents.ts +4 -1
  24. package/src/lib/editor/schemas.ts +1 -1
  25. package/src/lib/email/booking-words.ts +66 -1
  26. package/src/lib/email/service.ts +1 -1
  27. package/src/lib/email/store.ts +17 -1
  28. package/src/lib/email/types.ts +3 -1
  29. package/src/lib/legal/text-en.ts +1 -1
  30. package/src/lib/legal/text-es.ts +1 -1
  31. package/src/lib/mobile/bookings.ts +1 -1
  32. package/src/lib/mobile/calendar.ts +23 -1
  33. package/src/lib/mobile/http.ts +1 -1
  34. package/src/lib/mobile/manage.ts +1 -1
  35. package/src/lib/mobile/openapi.ts +3 -2
  36. package/src/lib/mobile/rsvp.ts +14 -8
  37. package/src/lib/mobile/schemas.ts +7 -3
  38. package/src/lib/push/api.ts +7 -8
  39. package/src/lib/push/service.ts +21 -16
  40. package/src/lib/push/types.ts +18 -8
  41. package/src/lib/repo/sql.ts +7 -3
  42. package/src/lib/repo/types.ts +8 -4
  43. package/src/lib/schema.ts +8 -1
  44. package/src/pages/api/v1/events/[slug]/index.ts +8 -0
  45. package/src/pages/events/[slug].astro +10 -3
  46. package/src/styles/editor.css +4 -0
  47. package/src/styles/mobile-app.css +7 -0
  48. package/src/styles/site.css +3 -0
  49. package/src/theme-sdk/index.ts +1 -1
@@ -2,6 +2,22 @@ import { z } from 'zod';
2
2
 
3
3
  // '*' in a calendars list = every public event
4
4
  const keys = z.array(z.string().min(1).max(200)).max(100);
5
+ /** One "when an event changes" rule: the events it covers and who hears. */
6
+ export const ChangeRule = z.object({
7
+ calendars: keys.default([]),
8
+ /** only events with one of these tags (any case); [] = every event in the calendars */
9
+ tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
10
+ /** sent to these topics instead of the event's own (`events` and its calendars) */
11
+ topics: z.array(z.string().min(1).max(200)).max(10).default([]),
12
+ });
13
+ export type ChangeRule = z.infer<typeof ChangeRule>;
14
+ export const MAX_CHANGE_RULES = 10;
15
+ // one rule, as saved before there could be several: a list of one, or none while it had no calendars
16
+ const oneRule = (v: unknown) => {
17
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return v;
18
+ const calendars = (v as { calendars?: unknown }).calendars;
19
+ return Array.isArray(calendars) && calendars.length ? [v] : [];
20
+ };
5
21
  export const PushAuto = z.object({
6
22
  /** collection keys whose first publish sends a notification */
7
23
  newPost: z.object({ collections: keys.default([]) }).default({}),
@@ -11,14 +27,8 @@ export const PushAuto = z.object({
11
27
  startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
12
28
  /** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
13
29
  live: z.object({ enabled: z.boolean().default(false) }).default({}),
14
- /** a published event's new date, time or place, or a cancellation, once its edits settle */
15
- eventChanges: z.object({
16
- calendars: keys.default([]),
17
- /** only events with one of these tags (any case); [] = every event in the calendars */
18
- tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
19
- /** sent to these topics instead of the event's own (`events` and its calendars) */
20
- topics: z.array(z.string().min(1).max(200)).max(10).default([]),
21
- }).default({}),
30
+ /** a published event's new date, time or place, or a cancellation, once its edits settle: one notice when any rule covers it */
31
+ eventChanges: z.preprocess(oneRule, z.array(ChangeRule).max(MAX_CHANGE_RULES)).default([]),
22
32
  });
23
33
  export type PushAuto = z.infer<typeof PushAuto>;
24
34
  export const PushSettings = z.object({ auto: PushAuto.default({}) });
@@ -11,7 +11,7 @@ import { PermissionStore } from '../permissions/store';
11
11
  import { BackupStore } from '../backup/store';
12
12
  import { LiveConfig } from '../live/types';
13
13
  import { ApiError } from '../api';
14
- import { EmailStore, bookingJobs } from '../email/store';
14
+ import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
15
15
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
16
16
  import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
17
17
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
@@ -1225,6 +1225,9 @@ export class SqlRepository implements ContentRepository {
1225
1225
  write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
1226
1226
  WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ('pending','confirmed')) + ? <= ?)`,
1227
1227
  args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
1228
+ // 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 ('pending','confirmed'))`,
1230
+ args: [...write.args!,b.eventId,b.start,b.email] };
1228
1231
  } else if (holds && b.resourceId) {
1229
1232
  write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
1230
1233
  SELECT 1 FROM bookings WHERE resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,
@@ -1238,7 +1241,7 @@ export class SqlRepository implements ContentRepository {
1238
1241
  SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
1239
1242
  }
1240
1243
  // Booking, retry ledger and email outbox commit together on D1 and libSQL.
1241
- await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true)]);
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)]);
1242
1245
  return this.getBooking(b.id);
1243
1246
  }
1244
1247
  async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
@@ -1275,7 +1278,8 @@ export class SqlRepository implements ContentRepository {
1275
1278
  args.push(id);
1276
1279
  const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received' : 'cancelled';
1277
1280
  await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?`,args:[...args,current.notificationVersion]},
1278
- ...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : [])]);
1281
+ ...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : []),
1282
+ ...(changed && data.status === 'cancelled' ? rsvpStaffJobs(id,'staff-rsvp-cancelled',data.staff,current.notificationVersion+1,mutation) : [])]);
1279
1283
  }
1280
1284
  async deleteBooking(id: string) {
1281
1285
  await this.ready;
@@ -7,6 +7,7 @@ import type { SavedForm, FormSubmission } from '../forms/schema';
7
7
  import type { LiveStore } from '../live/store';
8
8
  import type { BookingRequest } from '../backup/content';
9
9
  import type { EmailStore } from '../email/store';
10
+ import type { RsvpStaffNotice } from '../email/types';
10
11
  import type { PushStore } from '../push/store';
11
12
  import type { AnnouncementStore } from '../announcements/store';
12
13
  import type { Announcement } from '../announcements/types';
@@ -187,8 +188,10 @@ export interface ContentRepository {
187
188
 
188
189
  // bookings (+ staff blocks and event RSVPs). createBooking is the only
189
190
  // write that can fail on a race: it inserts atomically only if the held
190
- // range is still free (resource bookings) or seats remain (RSVPs), and
191
- // returns null when it lost.
191
+ // range is still free (resource bookings) or seats remain (RSVPs; with
192
+ // oncePerEmail, and the address has no active RSVP for that date), and
193
+ // returns null when it lost. staff: who hears about a new RSVP (queued with
194
+ // it, so a lost race or a replay emails no one).
192
195
  /** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
193
196
  listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
194
197
  getBooking(id: string): Promise<Booking | null>;
@@ -198,11 +201,12 @@ export interface ContentRepository {
198
201
  // locale: the booker's language; '' (the main language) when left out
199
202
  createBooking(
200
203
  data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
201
- guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
204
+ guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string }; staff?: RsvpStaffNotice }
202
205
  ): Promise<Booking | null>;
203
206
  // moves a booking only if the new held range is free (reschedule)
204
207
  moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
205
- updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string }): Promise<void>;
208
+ // staff: who hears that the person cancelled their RSVP (status → cancelled only)
209
+ updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string; staff?: RsvpStaffNotice }): Promise<void>;
206
210
  deleteBooking(id: string): Promise<void>;
207
211
  // confirmed bookings starting in [from, to) that haven't had a reminder
208
212
  listDueReminders(from: number, to: number): Promise<Booking[]>;
package/src/lib/schema.ts CHANGED
@@ -415,12 +415,19 @@ export const CalEvent = z.object({
415
415
  // UID of the feed event this was imported from ('' = authored here)
416
416
  sourceUid: z.string().default(''),
417
417
  // RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
418
- // once; RSVPs are stored as bookings of kind 'rsvp'
418
+ // once; RSVPs are stored as bookings of kind 'rsvp'. They close at closesAt
419
+ // (wall time in the event's zone, like start; every date), else closeBefore
420
+ // minutes before each date's start, else when the date ends. notifyStaff:
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).
419
423
  rsvp: z.object({
420
424
  enabled: z.boolean().default(false),
421
425
  capacity: z.number().int().default(0),
422
426
  autoConfirm: z.boolean().default(true),
423
427
  maxSeats: z.number().int().default(4),
428
+ closesAt: z.string().nullable().default(null),
429
+ closeBefore: z.number().int().default(0),
430
+ notifyStaff: z.boolean().default(true),
424
431
  form: FormDefinition.optional(),
425
432
  }).default({}),
426
433
  // rooms/people this event uses: they can't be booked while it happens (or
@@ -0,0 +1,8 @@
1
+ import { getEventDetail } from '../../../../../lib/mobile/calendar';
2
+ import { readRoute } from '../../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../../lib/mobile/http';
5
+ // the next occurrence moves with the clock: not timeless
6
+ export const GET = readRoute(getEventDetail, true);
7
+ export const HEAD = GET;
8
+ export const ALL = () => methodNotAllowed();
@@ -19,7 +19,7 @@ import { effectiveSettings } from '../../lib/designPreview';
19
19
  import { resolveDesign } from '../../lib/theme';
20
20
  import { siteOrigin } from '../../lib/seo';
21
21
  import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/config';
22
- import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
22
+ import { eventIsPublic, featuredOccurrence, rsvpDeadline } from '../../lib/calendar/events';
23
23
  import { eventJsonLd } from '../../lib/calendar/seo';
24
24
  import { upcomingEvents } from '../../lib/calendar/cards';
25
25
  import { seatsTaken, BookingError } from '../../lib/booking/service';
@@ -50,6 +50,7 @@ const origin = siteOrigin(settings, Astro.url);
50
50
  // RSVP: "Save my seat" posts back here and lands on the manage page, in the
51
51
  // visitor's language (its emails speak it too)
52
52
  let rsvpError = '';
53
+ let rsvpDuplicate = false;
53
54
  let formValues: Record<string,string> = {};
54
55
  let coreValues = { name: '', email: '', seats: '1' };
55
56
  let requestId: string = crypto.randomUUID();
@@ -66,13 +67,19 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
66
67
  const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) }, locale);
67
68
  return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
68
69
  } catch (err) {
69
- rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings)).saveError;
70
+ const w = rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings));
71
+ // the address already has one: a notice in the page's words (templates that don't know it show it as the error)
72
+ rsvpDuplicate = err instanceof BookingError && err.code === 'duplicate';
73
+ rsvpError = rsvpDuplicate ? `${w.duplicate} ${w.duplicateNote}` : err instanceof BookingError || err instanceof ApiError ? err.message : w.saveError;
70
74
  }
71
75
  }
72
76
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
73
77
  const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
78
+ // the event's RSVP deadline for this date (null: open until it ends)
79
+ const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
74
80
  const rsvp = event.rsvp.enabled
75
- ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !occurrence.cancelled }
81
+ ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
82
+ open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt }
76
83
  : null;
77
84
  const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
78
85
  const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
@@ -1168,6 +1168,10 @@
1168
1168
  .pe-evr-modes + .pe-evs-fields { margin-top: 22px; }
1169
1169
  .pe-evr-link { grid-template-columns: minmax(0, 1fr) 150px; }
1170
1170
  .pe-evr-seats { grid-template-columns: repeat(2, minmax(0, 200px)); }
1171
+ .pe-evr-when { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
1172
+ .pe-evr-when > span { font-size: 13px; color: var(--muted); }
1173
+ .pe-field .pe-evr-when input, .pe-field .pe-evr-when select { width: auto; }
1174
+ .pe-field .pe-evr-when input[type='number'] { width: 84px; }
1171
1175
  .pe-evr-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--panel); }
1172
1176
  .pe-evr-form-t { flex: 1 1 240px; min-width: 0; }
1173
1177
  .pe-evr-form-t b { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); }
@@ -88,6 +88,13 @@
88
88
  .ma-tag { display: inline-flex; align-items: center; gap: 5px; background: var(--sel); color: var(--text-2); font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-full); }
89
89
  .ma-tag button { background: none; border: 0; color: inherit; cursor: pointer; font-size: 10px; padding: 0; }
90
90
  .ma-tags input { flex: 1; min-width: 70px; background: none !important; border: 0 !important; box-shadow: none !important; padding: 2px !important; }
91
+ /* "when an event changes": a card per rule */
92
+ .ma-subrules { display: grid; gap: 8px; margin-top: 10px; }
93
+ .ma-subrule { padding: 6px 12px 12px; border: 1px solid var(--line); border-radius: var(--r-md); min-width: 0; }
94
+ .ma-subrule-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 30px; }
95
+ .ma-subrule-head b { font-size: 12.5px; color: var(--text-2); }
96
+ .ma-subrule .ma-choices { margin-top: 2px; }
97
+ .ma-add { margin-top: 10px; }
91
98
  .ma .form-actions { margin-top: 6px; }
92
99
  .ma-saved { color: var(--green); font-size: 12.5px; font-weight: 500; }
93
100
 
@@ -1147,6 +1147,9 @@ li::marker { color: var(--muted); }
1147
1147
  .pc-ev-rsvpform[open] > summary { display: none; }
1148
1148
  .pc-ev-rsvpform form { margin-top: 4px; }
1149
1149
  .pc-ev-full { font-size: 14px; font-weight: 600; color: var(--muted); }
1150
+ .pc-ev-closes { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
1151
+ .pc-ev-full + .pc-ev-closes { margin-top: 4px; }
1152
+ .pc-ev-notice { font-size: 14px; line-height: 1.5; background: var(--soft); border-radius: var(--r-sm); padding: 9px 12px; margin: 0 0 14px; }
1150
1153
 
1151
1154
  @media (max-width: 860px) {
1152
1155
  .pc-bk { grid-template-columns: 1fr; }
@@ -11,7 +11,7 @@ export interface HomeProps { settings: Settings; page: Page | null; posts: Post[
11
11
  export interface PageProps { settings: Settings; page: Page; html: string }
12
12
  export interface PostProps { settings: Settings; post: Post; html: string; authors: User[]; related: Post[] }
13
13
  import type { Occurrence } from '../lib/calendar/events';
14
- export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean}|null }
14
+ export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean;closesAt?:number|null;duplicate?:boolean}|null }
15
15
  /**
16
16
  * Props for a coded section. `data` holds resolved values: link fields arrive
17
17
  * as LinkValue, lists as row arrays, source fields as fresh item arrays