@acms-subir/core 0.3.54 → 0.3.56

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 (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +104 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/mobile.md +3 -1
  7. package/docs/server.md +58 -0
  8. package/integration.mjs +38 -0
  9. package/mobile-template/client/AGENTS.md +1 -0
  10. package/mobile-template/client/README.md +1 -1
  11. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  12. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  13. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  14. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  15. package/mobile-template/recipes/event-request/recipe.json +21 -0
  16. package/package.json +1 -1
  17. package/src/components/LegalSettings.tsx +1 -1
  18. package/src/components/MobileApp.tsx +2 -2
  19. package/src/components/editor/event/SingleDates.tsx +12 -2
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/google/client.ts +167 -0
  22. package/src/lib/calendar/google/connect.ts +161 -0
  23. package/src/lib/calendar/google/context.ts +50 -0
  24. package/src/lib/calendar/google/inbound.ts +493 -0
  25. package/src/lib/calendar/google/links.ts +139 -0
  26. package/src/lib/calendar/google/map.ts +543 -0
  27. package/src/lib/calendar/google/match.ts +103 -0
  28. package/src/lib/calendar/google/migration.ts +71 -0
  29. package/src/lib/calendar/google/outbound.ts +272 -0
  30. package/src/lib/calendar/google/service.ts +193 -0
  31. package/src/lib/calendar/google/store.ts +241 -0
  32. package/src/lib/calendar/google/types.ts +188 -0
  33. package/src/lib/calendar/request-admin.ts +211 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +8 -8
  41. package/src/lib/display/board.ts +3 -1
  42. package/src/lib/email/service.ts +17 -2
  43. package/src/lib/email/templates.ts +8 -0
  44. package/src/lib/legal/admin.ts +1 -1
  45. package/src/lib/legal/app-privacy.ts +2 -2
  46. package/src/lib/legal/facts.ts +7 -0
  47. package/src/lib/legal/text-en.ts +7 -0
  48. package/src/lib/legal/text-es.ts +7 -0
  49. package/src/lib/live/crypto.ts +7 -28
  50. package/src/lib/mobile/http.ts +2 -1
  51. package/src/lib/mobile/openapi.ts +8 -0
  52. package/src/lib/mobile/requests.ts +27 -0
  53. package/src/lib/mobile/schemas.ts +14 -3
  54. package/src/lib/mobile/site.ts +3 -0
  55. package/src/lib/push/types.ts +1 -1
  56. package/src/lib/repo/migrations.ts +4 -0
  57. package/src/lib/repo/sql.ts +18 -9
  58. package/src/lib/repo/types.ts +6 -2
  59. package/src/lib/scheduler.ts +6 -0
  60. package/src/lib/schema.ts +43 -2
  61. package/src/lib/sealed.ts +46 -0
  62. package/src/pages/admin/calendar/events/[id].astro +42 -1
  63. package/src/pages/admin/calendar/events/index.astro +2 -0
  64. package/src/pages/admin/calendar/google/match.astro +160 -0
  65. package/src/pages/admin/calendar/google.astro +107 -0
  66. package/src/pages/admin/calendar/requests.astro +298 -0
  67. package/src/pages/admin/calendar/settings.astro +273 -4
  68. package/src/pages/admin/integrations.astro +6 -0
  69. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  70. package/src/pages/api/v1/requests/index.ts +6 -0
  71. package/src/pages/api/v1/requests/options.ts +7 -0
  72. package/src/pages/request/index.astro +182 -0
  73. package/src/server/index.ts +105 -1
  74. package/src/styles/site.css +29 -0
  75. package/src/virtual.d.ts +1 -0
@@ -16,12 +16,16 @@ import { placeUsage, savePlace } from '../../../lib/maps/places';
16
16
  import { parseMapLink } from '../../../lib/maps/parse';
17
17
  import { ApiError } from '../../../lib/api';
18
18
  import type { Place } from '../../../lib/schema';
19
- import { forbiddenUnless } from '../../../lib/permissions/access';
19
+ import { accessFor, forbiddenUnless } from '../../../lib/permissions/access';
20
+ import { googleSyncState } from '../../../lib/calendar/google/service';
21
+ import { PAUSE_WORDS } from '../../../lib/calendar/google/links';
22
+ import type { GcalLink } from '../../../lib/calendar/google/types';
20
23
 
21
24
  // Calendar settings — one page behind the ⚙ on Schedule and Bookings:
22
25
  // calendars (groups + colors), saved places (picked in an event's Where),
23
- // subscribed calendars (read-only, synced in),
24
- // share links (out to Google/Apple/Outlook), booking approval, defaults.
26
+ // subscribed calendars (read-only, synced in), the two-way Google Calendar
27
+ // sync (its forms post to /admin/calendar/google), share links (out to
28
+ // Google/Apple/Outlook), booking approval, defaults.
25
29
  // Replaces the old Calendars page and the duplicate subscribe dialogs.
26
30
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
27
31
 
@@ -196,8 +200,51 @@ const I = {
196
200
  ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
197
201
  pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
198
202
  };
203
+ // Synced with Google: connecting needs "Connected services", linking and reviewing "Manage rooms and calendars"
204
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
205
+ const canConnect = access?.can('services.manage') ?? false;
206
+ const canLink = access?.can('schedule.manage') ?? false;
207
+ const canUse = canLink || (access?.can('content.publish') ?? false);
208
+ const google = await googleSyncState(repo, Astro.url, Astro.locals);
209
+ const gAccount = google.account;
210
+ const gConnected = gAccount?.status === 'connected';
211
+ const gStep = Astro.url.searchParams.get('google') ?? '';
212
+ const errorNotice = Astro.url.searchParams.get('error') ?? '';
213
+ const calById = new Map(calendars.map((c) => [c.id, c]));
214
+ const roomName = new Map(google.resources.map((r) => [r.id, r.name]));
215
+ const freeGoogle = (gAccount?.calendars ?? []).filter((c) => !google.links.some((l) => l.googleId === c.id));
216
+ const ARROW: Record<GcalLink['direction'], string> = { both: '⇄', out: '→', in: '←' };
217
+ const DIRECTIONS: [GcalLink['direction'], string, string][] = [
218
+ ['both', 'Both ways', 'Edits on either side copy over.'],
219
+ ['out', 'Site → Google', 'Google mirrors the site. Start here, then switch to both ways.'],
220
+ ['in', 'Google → site', 'The site follows Google. Site edits stay on the site.'],
221
+ ];
222
+ const agoText = (ts: number) => {
223
+ const m = Math.max(0, Math.round((Date.now() - ts) / 60_000));
224
+ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
225
+ };
226
+ const linkLine = (l: (typeof google.links)[number]) => [
227
+ DIRECTIONS.find(([d]) => d === l.direction)![1] + (l.direction === 'out' ? ' only' : ''),
228
+ l.roomIds.length ? `holds ${l.roomIds.map((r) => roomName.get(r) ?? 'a deleted room').join(' and ')}` : 'the whole calendar',
229
+ l.noRoom && l.roomIds.length ? 'also gets events without a room' : '',
230
+ ...l.rules.map((r) => `${r.prefix} ⇄ ${r.tag}`),
231
+ l.state === 'active' && l.lastOkAt ? `last synced ${agoText(l.lastOkAt)}` : '',
232
+ `${l.pairs} event${l.pairs === 1 ? '' : 's'}`,
233
+ ].filter(Boolean).join(' · ');
234
+ const linkState = (l: (typeof google.links)[number]) =>
235
+ l.state === 'matching' ? { tone: 'warn', label: 'Waiting for the first match' }
236
+ : l.state === 'paused' ? { tone: 'muted', label: PAUSE_WORDS[l.pausedReason] ?? 'Paused' }
237
+ : !gConnected ? { tone: 'bad', label: 'Reconnect Google' }
238
+ : l.error ? { tone: 'bad', label: 'Not syncing' }
239
+ : { tone: 'ok', label: 'In sync' };
240
+ const tz = settings.timezone || 'UTC';
241
+ const dayOf = (ts: number) => new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(ts);
242
+ const logTime = (ts: number) => new Intl.DateTimeFormat(settings.locale || 'en', dayOf(ts) === dayOf(Date.now()) ? { hour: 'numeric', minute: '2-digit', timeZone: tz } : { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(ts);
243
+ const LOG_DIRECTION: Record<string, string> = { in: '← from Google', out: '→ to Google', clash: 'Clash', info: 'Note' };
244
+ const startDefault = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
245
+
199
246
  const sections = [
200
- ['calendars', 'Calendars'], ['event-images', 'Event images'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
247
+ ['calendars', 'Calendars'], ['event-images', 'Event images'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['google', 'Synced with Google'], ['share', 'Share links'],
201
248
  ...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
202
249
  ] as const;
203
250
  ---
@@ -212,6 +259,7 @@ const sections = [
212
259
  </div>
213
260
 
214
261
  {notice && <p class="notice" role="status">{notice}</p>}
262
+ {errorNotice && <p class="notice warn" role="alert">{errorNotice}</p>}
215
263
 
216
264
  <div class="cs">
217
265
  <nav class="cs-nav" aria-label="Sections">
@@ -306,6 +354,119 @@ const sections = [
306
354
  ))}
307
355
  </section>
308
356
 
357
+ <!-- Synced with Google -->
358
+ <section class="cs-sect" id="google">
359
+ <header>
360
+ <div><h2>Synced with Google</h2><p>Editable copies of your calendars in Google Calendar. Changes on either side show up on the other within a few minutes.</p></div>
361
+ {gConnected && canLink && <button class="btn" type="button" data-modal="gcal-link-new" disabled={!freeGoogle.length} title={freeGoogle.length ? undefined : 'Every Google calendar this account can edit is linked'}>+ Link a Google calendar</button>}
362
+ </header>
363
+ {gStep === 'finish' && canConnect && (
364
+ <form method="post" action="/admin/calendar/google" class="cs-form" data-gcal-finish>
365
+ <input type="hidden" name="op" value="finish" />
366
+ <p class="gcal-note">Google sent you back. Finishing the connection…</p>
367
+ <div><button class="btn primary" type="submit">Finish connecting</button></div>
368
+ </form>
369
+ )}
370
+ {gStep === 'error' && <p class="gcal-note is-bad">Google didn’t finish the sign-in. Check that the return address in Google Cloud matches exactly, then connect again.</p>}
371
+ {!google.encryptionReady && <p class="gcal-note is-bad">Set SESSION_SECRET (or LIVE_ENCRYPTION_KEY) to at least 32 random characters on your host before connecting Google.</p>}
372
+ {!gAccount || gAccount.status === 'setup' ? (
373
+ <div>
374
+ <div class="cs-row">
375
+ <i class="gcal-dot" aria-hidden="true" />
376
+ <div class="cs-main"><b>Connect a Google account</b><small>Use the account that owns your calendars (for a church, its Google Workspace account). The site asks to see and edit events, and to list your calendars. Nothing else.</small></div>
377
+ <div class="cs-actions">
378
+ {canConnect ? (gAccount && (
379
+ <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="connect" /><button class="btn primary" type="submit">Connect Google</button></form>
380
+ )) : <small class="hint">Someone who can manage connected services connects Google.</small>}
381
+ </div>
382
+ </div>
383
+ {canConnect && (
384
+ <details class="gcal-steps" open={!gAccount}>
385
+ <summary>Before you connect: Google Cloud setup</summary>
386
+ <ol>
387
+ <li>Use the Google Cloud project you made for YouTube (or any project) and add a <b>second OAuth client</b> (type “Web application”) named “Calendar”. With its own client, disconnecting YouTube can’t cut off the calendar.</li>
388
+ <li>Turn on the <b>Google Calendar API</b> in that project.</li>
389
+ <li>Add this return address under “Authorized redirect URIs”:
390
+ <span class="cs-share gcal-copy"><input type="text" class="mono" readonly value={google.callbackUrl} aria-label="Return address" /><button class="btn sm" type="button" data-copy={google.callbackUrl}>Copy</button></span>
391
+ </li>
392
+ <li>Audience: <b>Internal</b> if the project belongs to your Google Workspace organization (no “unverified app” screen, no expiry). Otherwise choose <b>External</b> and set it to <b>In production</b>: in “Testing”, Google ends the connection every 7 days.</li>
393
+ <li>If your Workspace admin limits outside apps, mark this one as trusted in the Google Admin console → Security → API controls.</li>
394
+ </ol>
395
+ <form method="post" action="/admin/calendar/google" class="gcal-client">
396
+ <input type="hidden" name="op" value="client" />
397
+ <div class="field-row">
398
+ <label class="field"><span>Client ID</span><input class="mono" type="text" name="clientId" value={gAccount?.clientId ?? ''} placeholder="…apps.googleusercontent.com" required /></label>
399
+ <label class="field"><span>Client secret</span><input class="mono" type="password" name="clientSecret" placeholder={gAccount ? 'Saved: leave empty to keep it' : ''} autocomplete="off" /></label>
400
+ </div>
401
+ <div><button class="btn" type="submit">{gAccount ? 'Save client' : 'Save, then connect'}</button></div>
402
+ </form>
403
+ </details>
404
+ )}
405
+ </div>
406
+ ) : (
407
+ <div>
408
+ <div class="cs-row">
409
+ <i class={`gcal-dot ${gConnected ? 'ok' : 'bad'}`} aria-hidden="true" />
410
+ <div class="cs-main">
411
+ <b>{gAccount.email || 'Google account'}</b>
412
+ <small>{gConnected ? `Connected ${gAccount.connectedAt ? agoText(gAccount.connectedAt) : ''} · can see and edit events` : gAccount.error || 'Google refused the saved sign-in. Changes wait until you reconnect.'}</small>
413
+ </div>
414
+ <div class="cs-actions">
415
+ <span class={`gcal-chip ${gConnected ? 'ok' : 'bad'}`}>{gConnected ? 'Connected' : 'Reconnect Google'}</span>
416
+ {canConnect && !gConnected && <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="connect" /><button class="btn sm primary" type="submit">Reconnect</button></form>}
417
+ {canLink && gConnected && <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="refresh" /><button class="btn sm ghost" type="submit" title="List the Google calendars this account can edit again">Refresh calendars</button></form>}
418
+ {canConnect && <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="disconnect" /><button class="btn sm ghost" type="submit" data-confirm="Disconnect Google? Links pause, and every event stays in both places.">Disconnect</button></form>}
419
+ </div>
420
+ </div>
421
+ {google.links.map((l) => { const st = linkState(l); const cal = calById.get(l.calendarId); return (
422
+ <div class="cs-row" id={`gcal-${l.id}`}>
423
+ <i class="cal-dot lg" style={`background:${safeColor(cal?.color ?? '#8a8a8a')}`} aria-hidden="true" />
424
+ <div class="cs-main">
425
+ <b>{cal?.name ?? 'A deleted calendar'} <span aria-label={l.direction === 'both' ? 'syncs both ways with' : l.direction === 'out' ? 'copies to' : 'copies from'}>{ARROW[l.direction]}</span> {l.googleName}</b>
426
+ <small>{linkLine(l)}</small>
427
+ {l.state === 'active' && l.error && <small class="gcal-err">{l.error}</small>}
428
+ {l.guard && <small class="gcal-err">This copy isn’t the site that linked Google ({l.siteUrl}). It stays paused here.</small>}
429
+ </div>
430
+ <div class="cs-actions">
431
+ <span class={`gcal-chip ${st.tone}`}>{st.label}</span>
432
+ {canLink && (l.state === 'matching'
433
+ ? <a class="btn sm primary" href={`/admin/calendar/google/match?link=${encodeURIComponent(l.id)}`}>Review matches</a>
434
+ : <form method="post" action="/admin/calendar/google"><input type="hidden" name="link" value={l.id} /><button class="btn sm" type="submit" name="op" value={l.state === 'paused' ? 'resume' : 'sync'}
435
+ data-confirm={l.pausedReason === 'restored' ? 'Resume after the restore? Google’s copies take the restored details (the site is the main calendar), replacing changes made since the backup on either side. Events Google has that the backup doesn’t come back to the site.' : undefined}>{l.state === 'paused' ? 'Resume' : 'Sync now'}</button></form>)}
436
+ {canLink && <button class="btn sm ghost" type="button" data-modal={`gcal-edit-${l.id}`}>Edit</button>}
437
+ </div>
438
+ </div>
439
+ ); })}
440
+ {gConnected && !google.links.length && <p class="cs-empty">No calendars linked yet. Link one, review what’s already on both sides, then start syncing. Nothing is written before that.</p>}
441
+ {google.links.some((l) => l.state === 'active') && !google.scheduler.healthy && <p class="gcal-note is-bad">The scheduler hasn’t run in the last few minutes, so the sync is waiting. Check the scheduler on your host.</p>}
442
+ {google.queue.failing > 0 && <p class="gcal-note">{google.queue.failing} change{google.queue.failing === 1 ? '' : 's'} waiting to go to Google after an error. They’re retried on their own.</p>}
443
+ </div>
444
+ )}
445
+ {google.log.length > 0 && (
446
+ <div class="gcal-log">
447
+ <div class="gcal-log-head"><b>Recent changes</b><small>Kept for 90 days</small></div>
448
+ <div class="gcal-table">
449
+ <table>
450
+ <thead><tr><th>When</th><th>Direction</th><th>Event</th><th>What changed</th><th><span class="sr-only">Actions</span></th></tr></thead>
451
+ <tbody>
452
+ {google.log.map((x) => (
453
+ <tr>
454
+ <td class="gcal-when">{logTime(x.at)}</td>
455
+ <td><span class={`gcal-dir is-${x.direction}`}>{LOG_DIRECTION[x.direction] ?? x.direction}</span></td>
456
+ <td>{x.eventId ? <a href={`/admin/calendar/events/${encodeURIComponent(x.eventId)}`}>{x.title || 'Untitled event'}</a> : x.title}</td>
457
+ <td>{x.summary}</td>
458
+ <td>{x.direction === 'clash' && x.data && (x.data as { value?: unknown }).value && !x.resolved && canUse && (
459
+ <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="use" /><input type="hidden" name="log" value={x.id} /><button class="btn sm ghost" type="submit">Use Google’s</button></form>
460
+ )}</td>
461
+ </tr>
462
+ ))}
463
+ </tbody>
464
+ </table>
465
+ </div>
466
+ </div>
467
+ )}
468
+ </section>
469
+
309
470
  <!-- Share links -->
310
471
  <section class="cs-sect" id="share">
311
472
  <header>
@@ -457,6 +618,66 @@ const sections = [
457
618
  </Modal>
458
619
  ))}
459
620
 
621
+ {gConnected && canLink && [null, ...google.links].map((l) => (
622
+ <Modal id={l ? `gcal-edit-${l.id}` : 'gcal-link-new'} title={l ? `${calById.get(l.calendarId)?.name ?? 'Calendar'} ${ARROW[l.direction]} ${l.googleName}` : 'Link a Google calendar'}>
623
+ <form method="post" action="/admin/calendar/google" class="gcal-form">
624
+ <input type="hidden" name="op" value={l ? 'link-edit' : 'link-new'} />
625
+ {l && <input type="hidden" name="link" value={l.id} />}
626
+ {!l && <p class="hint">Step 1 of 2. Nothing is written yet: next you review what’s on both sides.</p>}
627
+ {!l && (
628
+ <div class="field-row">
629
+ <label class="field"><span>Site calendar</span>
630
+ <select name="calendarId" required>{natives.map((c) => <option value={c.id}>{c.name}</option>)}</select>
631
+ </label>
632
+ <label class="field"><span>Google calendar</span>
633
+ <select name="googleId" required>{freeGoogle.map((c) => <option value={c.id}>{c.name}{c.primary ? ' (main)' : ''}</option>)}</select>
634
+ </label>
635
+ </div>
636
+ )}
637
+ <fieldset class="gcal-fieldset">
638
+ <legend>Direction</legend>
639
+ <div class="cs-choices">
640
+ {DIRECTIONS.map(([d, label, note]) => (
641
+ <label class="cs-choice"><input type="radio" name="direction" value={d} checked={(l?.direction ?? 'both') === d} /><span><b>{label}</b><small>{note}</small></span></label>
642
+ ))}
643
+ </div>
644
+ </fieldset>
645
+ {google.resources.length > 0 && (
646
+ <fieldset class="gcal-fieldset">
647
+ <legend>This Google calendar is the room</legend>
648
+ {google.resources.map((r) => <label class="check-row"><input type="checkbox" name="roomIds" value={r.id} checked={l?.roomIds.includes(r.id) ?? false} /> {r.name}</label>)}
649
+ <label class="check-row"><input type="checkbox" name="noRoom" checked={l?.noRoom ?? false} /> Also gets this calendar’s events that hold no room</label>
650
+ <small class="hint">Events from Google hold the rooms you tick, and site events holding them go here. Tick none to sync the whole calendar.</small>
651
+ </fieldset>
652
+ )}
653
+ <fieldset class="gcal-fieldset">
654
+ <legend>Titles that set a tag</legend>
655
+ {[...(l?.rules ?? []), { prefix: '', tag: '' }, { prefix: '', tag: '' }].map((r, i) => (
656
+ <div class="gcal-rule">
657
+ <input type="text" name="rulePrefix" value={r.prefix} placeholder={i === 0 ? 'Prefix, like CLASS:' : ''} aria-label="Title prefix" maxlength="40" />
658
+ <span aria-hidden="true">⇄</span>
659
+ <input type="text" name="ruleTag" value={r.tag} placeholder={i === 0 ? 'Tag, like Class' : ''} aria-label="Tag" maxlength="80" />
660
+ </div>
661
+ ))}
662
+ <small class="hint">A Google title starting with the prefix gives the site event the tag, and a tagged site event gets the prefix in Google. The prefix stays in the title on both sides.</small>
663
+ </fieldset>
664
+ <label class="field"><span>Booking pages in this Google calendar <em class="hint">one title per line</em></span>
665
+ <textarea name="bookingTitles" rows="2" placeholder={'Band\nRehearsal Time'}>{(l?.bookingTitles ?? []).join('\n')}</textarea>
666
+ <small class="hint">Appointments booked on these Google booking pages come in as “Booked”, holding the room, without names or descriptions.</small>
667
+ </label>
668
+ {!l && <label class="field"><span>Start from</span><input type="date" name="startDate" value={startDefault} /><small class="hint">Events from this date on, plus repeating series still running.</small></label>}
669
+ <p class="hint">For every link: new Google events are published right away; deleted in Google means cancelled here; cancelled here shows as “Cancelled:” in Google. Guests are never added and Google never emails anyone.</p>
670
+ <div class="modal-foot">
671
+ {l && <button class="btn danger ghost" type="submit" name="op" value="unlink" formnovalidate data-confirm="Unlink? Every event stays in both places; linking again pairs them up.">Unlink</button>}
672
+ {l && l.state !== 'paused' && <button class="btn ghost" type="submit" name="op" value="pause" formnovalidate>Pause</button>}
673
+ <span class="spacer" />
674
+ <button class="btn ghost" type="button" data-close>Cancel</button>
675
+ <button class="btn primary" type="submit">{l ? 'Save' : 'Continue to matching'}</button>
676
+ </div>
677
+ </form>
678
+ </Modal>
679
+ ))}
680
+
460
681
  <Modal id="cal-new" title="New calendar" open={Astro.url.searchParams.has('new')}>
461
682
  <form method="post">
462
683
  <input type="hidden" name="op" value="create" />
@@ -542,6 +763,44 @@ const sections = [
542
763
  .cs-pinline.is-error span { color: var(--red); }
543
764
  .cs-map { margin: 0 0 4px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
544
765
  .cs-map img { display: block; width: 100%; height: auto; }
766
+ .gcal-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); justify-self: center; }
767
+ .gcal-dot.ok { background: var(--green); }
768
+ .gcal-dot.bad { background: var(--red); }
769
+ .gcal-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
770
+ .gcal-chip::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
771
+ .gcal-chip.ok::before { background: var(--green); }
772
+ .gcal-chip.warn::before { background: var(--amber, #c98a12); }
773
+ .gcal-chip.bad { color: var(--red); }
774
+ .gcal-chip.bad::before { background: var(--red); }
775
+ .gcal-err { color: var(--red) !important; }
776
+ .gcal-note { margin: 0; padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--text-2); }
777
+ .gcal-note.is-bad { color: var(--red); }
778
+ .gcal-steps { padding: 4px 16px 14px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--text-2); }
779
+ .gcal-steps summary { cursor: pointer; padding: 8px 0; font-weight: 600; color: var(--text); }
780
+ .gcal-steps ol { margin: 4px 0 12px; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
781
+ .gcal-copy { margin-top: 6px; }
782
+ .gcal-client { display: flex; flex-direction: column; gap: 8px; }
783
+ .gcal-log { border-top: 1px solid var(--line); }
784
+ .gcal-log-head { display: flex; align-items: baseline; gap: 10px; padding: 12px 16px 6px; }
785
+ .gcal-log-head b { font-size: 13.5px; }
786
+ .gcal-log-head small { color: var(--muted); font-size: 12px; }
787
+ .gcal-table { overflow-x: auto; }
788
+ .gcal-table table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
789
+ .gcal-table th { text-align: left; font-weight: 600; color: var(--muted); padding: 6px 16px; border-bottom: 1px solid var(--line); }
790
+ .gcal-table td { padding: 8px 16px; border-bottom: 1px solid var(--line); vertical-align: top; color: var(--text-2); }
791
+ .gcal-table tr:last-child td { border-bottom: 0; }
792
+ .gcal-when { white-space: nowrap; font-variant-numeric: tabular-nums; }
793
+ .gcal-dir { white-space: nowrap; font-weight: 600; }
794
+ .gcal-dir.is-clash { color: var(--red); }
795
+ .gcal-form { display: flex; flex-direction: column; gap: 12px; }
796
+ .gcal-fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 13px 12px; margin: 0; display: flex; flex-direction: column; gap: 6px; }
797
+ .gcal-fieldset legend { color: var(--muted); font-size: 12px; padding: 0 4px; }
798
+ .gcal-fieldset .check-row { margin: 0; }
799
+ .gcal-rule { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
800
+ .gcal-rule input { padding: 6px 8px; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--bg); color: var(--text); font: inherit; font-size: 13px; }
801
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
802
+ #google > header .btn, .gcal-table td:last-child .btn { white-space: nowrap; }
803
+ .gcal-table td:nth-child(3) { min-width: 110px; }
545
804
  [hidden] { display: none !important; }
546
805
  @media (max-width: 860px) {
547
806
  .cs { grid-template-columns: 1fr; }
@@ -551,6 +810,13 @@ const sections = [
551
810
  .cs-actions, .cs-share { grid-column: 2; flex-wrap: wrap; }
552
811
  .cs-share input { width: 100%; }
553
812
  .cs-field { grid-template-columns: 1fr; gap: 4px; }
813
+ #google > header { flex-wrap: wrap; }
814
+ .gcal-table thead { display: none; }
815
+ .gcal-table tr { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; padding: 9px 16px; border-bottom: 1px solid var(--line); }
816
+ .gcal-table tr:last-child { border-bottom: 0; }
817
+ .gcal-table td { padding: 0; border: 0; }
818
+ .gcal-table td:nth-child(n + 3) { grid-column: 1 / -1; }
819
+ .gcal-table td:last-child:empty { display: none; }
554
820
  }
555
821
  </style>
556
822
 
@@ -606,6 +872,9 @@ const sections = [
606
872
  clear.addEventListener('click', () => { lat.value = ''; lng.value = ''; show('Pin removed — it’s gone when you save.'); });
607
873
  }
608
874
 
875
+ // back from Google's sign-in: finish it for this session at once (the button is the fallback)
876
+ document.querySelector<HTMLFormElement>('[data-gcal-finish]')?.requestSubmit();
877
+
609
878
  // approval: rules only for "unless…", the deadline only when bookings can wait
610
879
  const form = document.querySelector<HTMLFormElement>('[data-approval]');
611
880
  form?.addEventListener('change', () => {
@@ -97,6 +97,12 @@ const SCOPE_LABEL: Record<string, string> = {
97
97
  <h2>Live video</h2><p>Connect your own Twitch or YouTube app to discover broadcasts automatically on your homepage and links page.</p>
98
98
  <a class="btn" href="/admin/live">Set up live video →</a>
99
99
  </div>
100
+ {CALENDAR.enabled && (
101
+ <div class="card" style="padding:20px;margin-bottom:20px">
102
+ <h2>Google Calendar</h2><p>Keep your calendars and Google Calendar in step, both ways: connect Google and link calendars in Calendar settings.</p>
103
+ <a class="btn" href="/admin/calendar/settings#google">Set up Google Calendar sync →</a>
104
+ </div>
105
+ )}
100
106
 
101
107
  <div class="page-head">
102
108
  <div>
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../../lib/repo';
3
+ import { receiveGoogleCallback } from '../../../../../lib/calendar/google/connect';
4
+ // Google's redirect back is a cross-site GET that may carry no usable sign-in, so, as with YouTube,
5
+ // it only parks the code on its one-use pending state; Calendar settings then finishes it with a
6
+ // same-origin POST bound to the session that began it. The code never enters a URL again.
7
+ export const GET: APIRoute = async ({ locals, url }) => {
8
+ const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
9
+ try {
10
+ await receiveGoogleCallback(await getRepo(locals), url, locals);
11
+ return new Response(null, { status: 303, headers: { ...headers, Location: '/admin/calendar/settings?google=finish#google' } });
12
+ } catch {
13
+ // never reflect codes, tokens or Google's error text into a page or URL
14
+ return new Response(null, { status: 303, headers: { ...headers, Location: '/admin/calendar/settings?google=error#google' } });
15
+ }
16
+ };
@@ -0,0 +1,6 @@
1
+ import { postRequest } from '../../../../lib/mobile/requests';
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(postRequest, { write: true, status: 201 });
6
+ export const ALL = () => methodNotAllowed('POST, OPTIONS');
@@ -0,0 +1,7 @@
1
+ import { getRequestOptions } from '../../../../lib/mobile/requests';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // never cached: closing requests or marking a room requestable shows at once
6
+ export const GET = privateRoute(getRequestOptions);
7
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -0,0 +1,182 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { ApiError } from '../../lib/api';
5
+ import { readForm } from '../../lib/forms/http';
6
+ import { I18N, localePath, requestLocale as pageLocale } from '../../lib/i18n';
7
+ import { siteLang, siteLocale } from '../../lib/dates';
8
+ import { LEGAL } from '../../lib/legal/config';
9
+ import { fill } from '../../lib/live/words';
10
+ import { REQUESTS } from '../../lib/calendar/request-config';
11
+ import { requestOptions, submitRequest, requestLocale, type RequestRefusal } from '../../lib/calendar/request-service';
12
+ import { requestStrings } from '../../lib/calendar/request-words';
13
+ import { formRequest, sentSummary } from '../../lib/calendar/request-page';
14
+
15
+ // Request an event (acms({ requests })): the same request as the app's
16
+ // screen, for anyone, without an account, at /request and under each
17
+ // language's prefix (/es/request). The page posts to itself; the rules,
18
+ // limits and emails are lib/calendar/request-service.ts. Themed through the
19
+ // site's tokens (the booking form's pc-bk-* fields); a site file at this path
20
+ // replaces it, and acms({ requests: { page: false } }) drops it.
21
+ if (!REQUESTS.enabled) return new Response(null, { status: 404 });
22
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
23
+
24
+ const repo = await getRepo(Astro.locals);
25
+ const settings = await repo.getSettings();
26
+ const locale = I18N.enabled ? requestLocale(pageLocale(Astro.url, Astro.locals)) : '';
27
+ const lang = locale || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
28
+ const dateLocale = lang.split('-')[0] === siteLang(settings) ? siteLocale(settings) : lang;
29
+ const w = requestStrings(lang);
30
+ const options = await requestOptions(repo, locale);
31
+
32
+ let error = '';
33
+ let field = '';
34
+ let values: Record<string, string | string[]> = { kind: 'new', repeat: 'once' };
35
+ let key: string = crypto.randomUUID();
36
+ let sent: { email: string; title: string; line: string } | null = null;
37
+ if (Astro.request.method === 'POST') {
38
+ try {
39
+ const data = await readForm(Astro.request);
40
+ values = Object.fromEntries([...new Set(data.keys())].map((k) => [k, k === 'rooms' ? data.getAll(k).map(String) : String(data.get(k) ?? '')]));
41
+ key = String(data.get('requestId') ?? '');
42
+ const { request } = await submitRequest(repo, formRequest(data, locale), { source: 'web', key, address: Astro.clientAddress, locals: Astro.locals });
43
+ sent = { email: request.requester?.email ?? '', ...await sentSummary(repo, request, settings, lang, dateLocale) };
44
+ } catch (e) {
45
+ const code = (e as { code?: RequestRefusal }).code;
46
+ field = code ?? '';
47
+ error = code ? w.errors[code] : e instanceof ApiError && e.status !== 500 ? e.message : w.errors.failed;
48
+ Astro.response.status = e instanceof ApiError ? e.status : 500;
49
+ // a refused request can be fixed and sent again with the same key; one that went through can't
50
+ if (code === 'changed') key = crypto.randomUUID();
51
+ }
52
+ }
53
+ const v = (k: string) => { const x = values[k]; return Array.isArray(x) ? x[0] ?? '' : x ?? ''; };
54
+ const chosenRooms = Array.isArray(values.rooms) ? values.rooms : values.rooms ? [values.rooms] : [];
55
+ const today = new Intl.DateTimeFormat('en-CA', { timeZone: settings.timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(Date.now());
56
+ const privacy = LEGAL.enabled ? localePath('/privacy', locale) : '';
57
+ const self = localePath(REQUESTS.path, locale);
58
+ const tel = (p: string) => 'tel:' + p.replace(/[^\d+]/g, '');
59
+ const invalid = (...codes: string[]) => (codes.includes(field) ? 'true' : undefined);
60
+ const fieldWords = { free: w.free, taken: w.taken };
61
+ ---
62
+ <Site title={w.heading}>
63
+ <section class="pc-simple-form pc-rq">
64
+ <h1>{sent ? w.sent : w.heading}</h1>
65
+ {sent ? (
66
+ <div class="pc-rq-sent" role="status">
67
+ <p>{options.replyTime ? fill(w.answerWithin, { time: options.replyTime, email: sent.email }) : fill(w.answerBy, { email: sent.email })}</p>
68
+ <div class="pc-rq-card"><small>{w.yours}</small><b>{sent.title}</b><span>{sent.line}</span></div>
69
+ <p class="pc-bk-hint">{w.notYet}{options.phone && <> <a href={tel(options.phone)}>{fill(w.sooner, { phone: options.phone })}</a></>}</p>
70
+ <p><a href={self}>{w.another}</a></p>
71
+ </div>
72
+ ) : !options.open ? (
73
+ <div class="pc-rq-closed" role="status">
74
+ <p>{w.closed}</p>
75
+ {options.phone && <p><a href={tel(options.phone)}>{fill(w.call, { phone: options.phone })}</a></p>}
76
+ </div>
77
+ ) : (
78
+ <form method="post" action={self} class="pc-rq-form" data-tz={options.timezone} data-words={JSON.stringify(fieldWords)}>
79
+ <p class="pc-rq-notice">{options.notice || w.notice}</p>
80
+ {error && <p class="pc-bk-error" role="alert">{error}</p>}
81
+ <input type="hidden" name="requestId" value={key} />
82
+ <div class="pc-form-trap" aria-hidden="true"><label>Leave empty<input name="website" tabindex="-1" autocomplete="off" /></label></div>
83
+ <fieldset class="pc-rq-kind">
84
+ <legend class="pc-rq-sr">{w.heading}</legend>
85
+ <label><input type="radio" name="kind" value="new" checked={v('kind') !== 'change'} /><span>{w.somethingNew}</span></label>
86
+ <label><input type="radio" name="kind" value="change" checked={v('kind') === 'change'} /><span>{w.change}</span></label>
87
+ </fieldset>
88
+
89
+ <div class="pc-rq-new">
90
+ <label class="pc-bk-field"><span>{w.what}</span><input name="title" maxlength="200" value={v('title')} aria-invalid={invalid('title')} /></label>
91
+ {options.types.length > 0 && (
92
+ <fieldset class="pc-bk-field pc-bk-choices"><legend>{w.type}</legend>
93
+ <div class="pc-rq-chips">{options.types.map((t) => <label class="pc-bk-option"><input type="radio" name="type" value={t.id} checked={v('type') === t.id} /><span>{t.label}</span></label>)}</div>
94
+ </fieldset>
95
+ )}
96
+ <div class="pc-rq-when">
97
+ <label class="pc-bk-field"><span>{w.date}</span><input type="date" name="date" min={today} value={v('date')} aria-invalid={invalid('start', 'past', 'far')} /></label>
98
+ <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="start" step="300" value={v('start')} aria-invalid={invalid('start', 'past')} /></label>
99
+ <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="end" step="300" value={v('end')} aria-invalid={invalid('end')} /></label>
100
+ </div>
101
+ <fieldset class="pc-bk-field pc-bk-choices"><legend>{w.repeats}</legend>
102
+ <div class="pc-rq-chips">
103
+ <label class="pc-bk-option"><input type="radio" name="repeat" value="once" checked={v('repeat') !== 'weekly' && v('repeat') !== 'other'} /><span>{w.once}</span></label>
104
+ <label class="pc-bk-option"><input type="radio" name="repeat" value="weekly" checked={v('repeat') === 'weekly'} /><span>{w.weekly}</span></label>
105
+ <label class="pc-bk-option"><input type="radio" name="repeat" value="other" checked={v('repeat') === 'other'} /><span>{w.other}</span></label>
106
+ </div>
107
+ <label class="pc-bk-field pc-rq-other"><span>{w.otherHint}</span><input name="repeatText" maxlength="200" value={v('repeatText')} aria-invalid={invalid('repeat')} /></label>
108
+ </fieldset>
109
+ {options.rooms.length > 0 && (
110
+ <fieldset class="pc-bk-field pc-bk-choices"><legend>{w.room}</legend>
111
+ <div class="pc-rq-chips">
112
+ {options.rooms.map((r) => <label class="pc-bk-option"><input type="checkbox" name="rooms" value={r.slug} data-name={r.name} checked={chosenRooms.includes(r.slug)} /><span>{r.name}</span></label>)}
113
+ <label class="pc-bk-option"><input type="checkbox" name="roomsUnsure" value="1" checked={v('roomsUnsure') === '1'} /><span>{w.notSure}</span></label>
114
+ </div>
115
+ <p class="pc-rq-free" aria-live="polite"></p>
116
+ </fieldset>
117
+ )}
118
+ <label class="pc-bk-field"><span>{w.people} <em>{w.optional}</em></span><input type="number" name="people" min="1" max="100000" inputmode="numeric" value={v('people')} aria-invalid={invalid('people')} /></label>
119
+ <label class="pc-bk-field"><span>{w.message} <em>{w.optional}</em></span><textarea name="message" rows="3" maxlength="2000">{v('message')}</textarea></label>
120
+ </div>
121
+
122
+ <div class="pc-rq-change">
123
+ <label class="pc-bk-field"><span>{w.event}</span>
124
+ <select name="event" aria-invalid={invalid('event')}>
125
+ <option value="">{w.pickEvent}</option>
126
+ {options.events.map((e) => <option value={e.slug} selected={v('event') === e.slug}>{e.title}</option>)}
127
+ </select>
128
+ </label>
129
+ <label class="pc-bk-field"><span>{w.onDate} <em>{w.optional}</em></span><input type="date" name="onDate" min={today} value={v('onDate')} aria-invalid={invalid('date')} /><small>{w.onDateHint}</small></label>
130
+ <label class="pc-bk-field"><span>{w.whatChange}</span><textarea name="whatChange" rows="3" maxlength="2000" aria-invalid={invalid('what')}>{v('whatChange')}</textarea></label>
131
+ </div>
132
+
133
+ <fieldset class="pc-rq-contact">
134
+ <legend>{w.contact}</legend>
135
+ <label class="pc-bk-field"><span>{w.name}</span><input name="name" required maxlength="120" autocomplete="name" value={v('name')} aria-invalid={invalid('name')} /></label>
136
+ <div class="pc-form-row">
137
+ <label class="pc-bk-field"><span>{w.email}</span><input type="email" name="email" required maxlength="254" autocomplete="email" value={v('email')} aria-invalid={invalid('email')} /></label>
138
+ <label class="pc-bk-field"><span>{w.phone} <em>{w.optional}</em></span><input type="tel" name="phone" maxlength="40" autocomplete="tel" value={v('phone')} aria-invalid={invalid('phone')} /></label>
139
+ </div>
140
+ <p class="pc-bk-hint">{w.contactWhy}{privacy && <> <a href={privacy}>{w.privacy}</a></>}</p>
141
+ </fieldset>
142
+ <button class="pc-bk-submit" type="submit">{w.send}</button>
143
+ </form>
144
+ )}
145
+ </section>
146
+ </Site>
147
+
148
+ <script>
149
+ // "Sanctuary looks free then": free or busy for the chosen rooms
150
+ // (GET /api/v1/rooms/free, no names), in the site's zone
151
+ const form = document.querySelector<HTMLFormElement>('.pc-rq-form');
152
+ const out = form?.querySelector<HTMLElement>('.pc-rq-free');
153
+ if (form && out) {
154
+ const tz = form.dataset.tz || 'UTC';
155
+ const words = JSON.parse(form.dataset.words || '{}') as { free: string; taken: string };
156
+ const wall = (iso: string) => {
157
+ const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
158
+ .formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
159
+ return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
160
+ };
161
+ const next = (day: string) => { const d = new Date(day + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); };
162
+ let run = 0;
163
+ const check = async () => {
164
+ const mine = ++run;
165
+ const get = (n: string) => (form.elements.namedItem(n) as HTMLInputElement | null)?.value || '';
166
+ const day = get('date'), start = get('start'), end = get('end');
167
+ const rooms = [...form.querySelectorAll<HTMLInputElement>('input[name=rooms]:checked')];
168
+ if (!day || !start || !end || end <= start || !rooms.length) { out.textContent = ''; return; }
169
+ const from = day + 'T' + start, to = day + 'T' + end;
170
+ const lines = await Promise.all(rooms.map(async (r) => {
171
+ const res = await fetch('/api/v1/rooms/free?room=' + encodeURIComponent(r.value) + '&start=' + day + '&end=' + next(day)).catch(() => null);
172
+ if (!res?.ok) return '';
173
+ const { data } = await res.json() as { data: { busy: { start: string; end: string }[] } };
174
+ const taken = data.busy.some((b) => wall(b.start) < to && wall(b.end) > from);
175
+ return (taken ? words.taken : words.free).replace('{room}', r.dataset.name || r.value);
176
+ }));
177
+ if (mine === run) out.textContent = lines.filter(Boolean).join(' ');
178
+ };
179
+ form.addEventListener('change', (e) => { if ((e.target as HTMLInputElement).name !== 'roomsUnsure') void check(); });
180
+ void check();
181
+ }
182
+ </script>