@acms-subir/core 0.3.53 → 0.3.55

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 (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +76 -6
  4. package/docs/calendar.md +49 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +68 -1
  7. package/integration.mjs +15 -5
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/api.ts +3 -0
  11. package/src/lib/backup/content.ts +4 -2
  12. package/src/lib/calendar/google/client.ts +167 -0
  13. package/src/lib/calendar/google/connect.ts +161 -0
  14. package/src/lib/calendar/google/context.ts +50 -0
  15. package/src/lib/calendar/google/inbound.ts +493 -0
  16. package/src/lib/calendar/google/links.ts +139 -0
  17. package/src/lib/calendar/google/map.ts +543 -0
  18. package/src/lib/calendar/google/match.ts +103 -0
  19. package/src/lib/calendar/google/migration.ts +71 -0
  20. package/src/lib/calendar/google/outbound.ts +272 -0
  21. package/src/lib/calendar/google/service.ts +193 -0
  22. package/src/lib/calendar/google/store.ts +241 -0
  23. package/src/lib/calendar/google/types.ts +188 -0
  24. package/src/lib/calendar/host-requests.ts +247 -0
  25. package/src/lib/calendar/member-form.ts +17 -11
  26. package/src/lib/calendar/member-words.ts +473 -0
  27. package/src/lib/calendar/members-config.ts +2 -2
  28. package/src/lib/calendar/moves.ts +81 -33
  29. package/src/lib/calendar/request-migration.ts +17 -0
  30. package/src/lib/calendar/request-store.ts +104 -0
  31. package/src/lib/calendar/requests.ts +24 -24
  32. package/src/lib/calendar/schedule.ts +8 -8
  33. package/src/lib/display/board.ts +3 -1
  34. package/src/lib/email/service.ts +22 -2
  35. package/src/lib/email/templates.ts +20 -2
  36. package/src/lib/legal/admin.ts +1 -1
  37. package/src/lib/legal/facts.ts +3 -0
  38. package/src/lib/legal/text-en.ts +4 -0
  39. package/src/lib/legal/text-es.ts +4 -0
  40. package/src/lib/live/crypto.ts +7 -28
  41. package/src/lib/repo/migrations.ts +8 -0
  42. package/src/lib/repo/sql.ts +25 -9
  43. package/src/lib/repo/types.ts +12 -3
  44. package/src/lib/scheduler.ts +3 -0
  45. package/src/lib/schema.ts +47 -0
  46. package/src/lib/sealed.ts +46 -0
  47. package/src/pages/admin/calendar/events/[id].astro +42 -1
  48. package/src/pages/admin/calendar/events/index.astro +82 -6
  49. package/src/pages/admin/calendar/google/match.astro +160 -0
  50. package/src/pages/admin/calendar/google.astro +107 -0
  51. package/src/pages/admin/calendar/settings.astro +273 -4
  52. package/src/pages/admin/index.astro +6 -2
  53. package/src/pages/admin/integrations.astro +6 -0
  54. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  55. package/src/pages/members/_EventForm.astro +33 -27
  56. package/src/pages/members/_Layout.astro +25 -5
  57. package/src/pages/members/_http.ts +29 -6
  58. package/src/pages/members/events/[id].astro +21 -20
  59. package/src/pages/members/events/index.astro +295 -101
  60. package/src/pages/members/events/new.astro +11 -10
  61. package/src/server/index.ts +102 -0
  62. package/src/virtual.d.ts +1 -1
@@ -22,6 +22,7 @@ import { invalidateRedirectCache } from '../../../../middleware';
22
22
  import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
23
23
  import type { Permission } from '../../../../lib/permissions/catalog';
24
24
  import { approveEventRequest, declineEventRequest, pendingRequests } from '../../../../lib/calendar/requests';
25
+ import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
25
26
  import { mergeEventDraft } from '../../../../lib/drafts';
26
27
  import { ApiError } from '../../../../lib/api';
27
28
 
@@ -72,7 +73,7 @@ if (Astro.request.method === 'POST') {
72
73
  // create, copy and import make drafts; bulk status changes that touch live events need Publish
73
74
  let needs: Permission = 'content.edit';
74
75
  // answering members' event requests needs Publish (approving puts them live)
75
- if (op === 'approve' || op === 'decline') needs = 'content.publish';
76
+ if (op === 'approve' || op === 'decline' || op === 'approve-date' || op === 'decline-date') needs = 'content.publish';
76
77
  if (op === 'bulk') {
77
78
  const to = String(form.get('to'));
78
79
  const before = await Promise.all(form.getAll('ids').map(String).filter(Boolean).map((id) => repo.getEvent(id)));
@@ -81,7 +82,7 @@ if (Astro.request.method === 'POST') {
81
82
  const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
82
83
  if (denied) return denied;
83
84
 
84
- if (op === 'approve' || op === 'decline') {
85
+ if (op === 'approve' || op === 'decline' || op === 'approve-date' || op === 'decline-date') {
85
86
  const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
86
87
  const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
87
88
  const hooks = { url: Astro.url, locals: Astro.locals };
@@ -93,6 +94,17 @@ if (Astro.request.method === 'POST') {
93
94
  return Astro.redirect(`/admin/calendar/events?${p}`);
94
95
  };
95
96
  try {
97
+ // a host's date move (host-requests.ts): `id` is the request's
98
+ if (op === 'approve-date') {
99
+ const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
100
+ const n = moved.clashes.length;
101
+ const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
102
+ return toRequests('Approved. “' + moved.event.title + '” has the date moved' + shared + '.');
103
+ }
104
+ if (op === 'decline-date') {
105
+ await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
106
+ return toRequests('Declined. The date stays as it was.');
107
+ }
96
108
  const before = await repo.getEvent(id);
97
109
  if (op === 'approve') {
98
110
  const e = await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
@@ -182,8 +194,9 @@ if (Astro.request.method === 'POST') {
182
194
  }
183
195
  }
184
196
 
185
- const [events, calendars, requests, users, access] = await Promise.all([
197
+ const [events, calendars, requests, users, access, waitingDates] = await Promise.all([
186
198
  repo.listEvents(), repo.listCalendars(), pendingRequests(repo), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo),
199
+ repo.requests.list({ kinds: ['move', 'from-date'], states: ['pending'], order: 'oldest', limit: -1 }),
187
200
  ]);
188
201
  const canApprove = access?.can('content.publish') ?? false;
189
202
  const calById = new Map(calendars.map((c) => [c.id, c]));
@@ -201,7 +214,7 @@ const counts = {
201
214
  upcoming: rows.filter(isUpcoming).length,
202
215
  past: rows.filter((r) => !isUpcoming(r)).length,
203
216
  drafts: rows.filter((r) => r.e.status === 'draft').length,
204
- requests: requests.length,
217
+ requests: requests.length + waitingDates.length,
205
218
  all: rows.length,
206
219
  };
207
220
  const selected = rows
@@ -278,6 +291,26 @@ const requestRows = scope !== 'requests' ? [] : requests
278
291
  };
279
292
  });
280
293
 
294
+ // hosts' date moves waiting (scope=requests): what moves where, and what
295
+ // else holds its rooms then (C2 clashes; names are fine for staff)
296
+ const eventById = new Map(events.map((e) => [e.id, e]));
297
+ const roomNames = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
298
+ const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
299
+ .filter((r) => eventById.has(r.eventId))
300
+ .filter((r) => { const e = eventById.get(r.eventId)!; return (!calFilter || e.calendarIds.includes(calFilter)) && (!q || `${e.title} ${e.slug}`.toLowerCase().includes(q)); })
301
+ .map(async (r) => {
302
+ const e = eventById.get(r.eventId)!;
303
+ const d = r.data as MoveRequestData;
304
+ const host = nameOf.get(r.userId) ?? 'a host';
305
+ const clashes = await dateRequestClashes(repo, r);
306
+ const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
307
+ return {
308
+ id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
309
+ ...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
310
+ line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
311
+ };
312
+ }));
313
+
281
314
  // feed calendars: the next 60 days as read-only rows (upcoming/all scopes)
282
315
  const feedErrors: string[] = [];
283
316
  if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
@@ -464,8 +497,51 @@ const subBits = [
464
497
  ))}
465
498
  </section>
466
499
  )}
467
- {requestRows.length > 0 && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
468
- {requestRows.length === 0 && (
500
+ {dateRows.length > 0 && (
501
+ <section class="card bk-group">
502
+ <div class="bk-dayh"><b>Date changes from hosts</b><span>{dateRows.length}</span></div>
503
+ {dateRows.map((r) => (
504
+ <div class="bk-row ev-req">
505
+ <a class="bk-when" href={r.href}>{r.from || 'No date'}<small>{r.to ? '→ ' + r.to : ''}</small></a>
506
+ <a class="bk-who" href={r.href}>
507
+ <b>{r.title}</b>
508
+ <small>{r.line}</small>
509
+ {r.note && <small class="ev-req-note">“{r.note}”</small>}
510
+ {r.publicNote && <small>Note on the date: {r.publicNote}</small>}
511
+ {r.clash && <small class="ev-req-clash">{r.clash}</small>}
512
+ </a>
513
+ <span class="bk-st is-pending">{r.back ? 'Move back' : 'Date move'}</span>
514
+ <div class="bk-acts">
515
+ <a class="btn sm ghost" href={r.href}>Open</a>
516
+ {canApprove && (
517
+ <>
518
+ <button class="btn sm ghost" type="button" data-modal={`decline-date-${r.id}`}>Decline…</button>
519
+ <form method="post">
520
+ <input type="hidden" name="op" value="approve-date" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
521
+ <button class="btn sm" type="submit">Approve</button>
522
+ </form>
523
+ </>
524
+ )}
525
+ </div>
526
+ </div>
527
+ ))}
528
+ </section>
529
+ )}
530
+ {(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
531
+ {canApprove && dateRows.map((r) => (
532
+ <Modal id={`decline-date-${r.id}`} title="Decline this date change">
533
+ <form method="post">
534
+ <input type="hidden" name="op" value="decline-date" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
535
+ <p class="hint">The date stays as it is, and {r.host} hears that it wasn’t approved.</p>
536
+ <label class="field"><span>Note to {r.host} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="That room is booked for the youth night. Could the week after work?"></textarea></label>
537
+ <div class="modal-foot">
538
+ <button class="btn ghost" type="button" data-close>Cancel</button>
539
+ <button class="btn primary" type="submit">Decline</button>
540
+ </div>
541
+ </form>
542
+ </Modal>
543
+ ))}
544
+ {requestRows.length === 0 && dateRows.length === 0 && (
469
545
  <p class="hint ev-req-empty">{counts.requests ? 'No requests match the current filters.' : 'Nothing waiting. When members ask for events or suggest changes, they show up here.'}</p>
470
546
  )}
471
547
  {canApprove && requestRows.map((r) => (
@@ -0,0 +1,160 @@
1
+ ---
2
+ import Admin from '../../../../layouts/Admin.astro';
3
+ import { getRepo } from '../../../../lib/repo';
4
+ import { ApiError } from '../../../../lib/api';
5
+ import { CALENDAR } from '../../../../lib/calendar/config';
6
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
7
+ import { reviewMatch } from '../../../../lib/calendar/google/service';
8
+ import type { CalEvent } from '../../../../lib/schema';
9
+ import type { MatchGoogle, MatchPair } from '../../../../lib/calendar/google/types';
10
+
11
+ // Step 2 of linking a Google calendar: what's on both sides, before anything
12
+ // is written. Decisions save at once ("Save and finish later" just leaves);
13
+ // "Start syncing" pairs, copies the rest each way and sends no notifications.
14
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
15
+ const repo = await getRepo(Astro.locals);
16
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
17
+ if (denied) return denied;
18
+ const linkId = Astro.url.searchParams.get('link') ?? '';
19
+ let link = await repo.gcal.getLink(linkId);
20
+ if (!link) return Astro.redirect('/admin/calendar/settings#google');
21
+ if (link.state !== 'matching') return Astro.redirect(`/admin/calendar/settings?notice=${encodeURIComponent('That link is already past its first match.')}#google`);
22
+ let error = Astro.url.searchParams.get('error') ?? '';
23
+ if (!link.match && !error) {
24
+ try { await reviewMatch(repo, link.id, Astro.locals); link = (await repo.gcal.getLink(link.id))!; }
25
+ catch (e) { error = e instanceof ApiError ? e.message : 'Couldn’t read the Google calendar. Try again.'; }
26
+ }
27
+ const [settings, calendar, events] = await Promise.all([repo.getSettings(), repo.getCalendar(link.calendarId), repo.listEvents()]);
28
+ const byId = new Map(events.map((e) => [e.id, e]));
29
+ const m = link.match;
30
+ const tz = settings.timezone || 'UTC';
31
+ const fmt = (ms: number, allDay: boolean) => new Intl.DateTimeFormat(settings.locale || 'en', allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(ms);
32
+ const siteWhen = (e: CalEvent) => `${fmt(e.startAt, e.allDay)}${e.rrule ? ', repeats' : ''}`;
33
+ const googleWhen = (g: MatchGoogle) => `${fmt(g.start, g.allDay)}${g.repeats ? ', repeats' : ''}`;
34
+ const pairs = (m?.pairs ?? []).filter((p) => byId.has(p.eventId) && m?.google[p.googleId]);
35
+ const waiting = pairs.filter((p) => p.state === 'suggested').length;
36
+ const apart = pairs.filter((p) => p.state === 'apart');
37
+ const toGoogle = (m?.siteOnly.length ?? 0) + apart.length;
38
+ const fromGoogle = (m?.googleOnly.length ?? 0) + apart.length;
39
+ const STATE: Record<MatchPair['state'], string> = { linked: 'Linked', suggested: 'Same?', apart: 'Not the same' };
40
+ const WHY: Record<MatchPair['reason'], string> = {
41
+ 'hidden-id': 'Linked before (Google’s copy carries the site’s hidden link)', uid: 'Same Google ID (copied from Google earlier)',
42
+ 'title-time': 'Same title and time', 'time-repeat': 'Same time and repeat, different title',
43
+ };
44
+ const out = link.direction !== 'in', inn = link.direction !== 'out';
45
+ ---
46
+
47
+ <Admin title="Match events" section="calendar:schedule">
48
+ <div class="page-head">
49
+ <div>
50
+ <p class="sub"><a href="/admin/calendar/settings#google">Calendar settings</a> › Synced with Google</p>
51
+ <h1>Match events: {calendar?.name ?? 'Calendar'} {link.direction === 'both' ? '⇄' : link.direction === 'out' ? '→' : '←'} {link.googleName}</h1>
52
+ <p class="sub">Step 2 of 2. Nothing has been written to either side yet.</p>
53
+ </div>
54
+ <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="match-build" /><input type="hidden" name="link" value={link.id} /><button class="btn" type="submit">Read both sides again</button></form>
55
+ </div>
56
+ {error && <p class="notice warn" role="alert">{error}</p>}
57
+ {m && (
58
+ <>
59
+ <nav class="gm-tabs" aria-label="Groups">
60
+ <a href="#both">On both · {pairs.length}</a>
61
+ <a href="#site">Only on the site · {m.siteOnly.length}</a>
62
+ <a href="#google">Only in Google · {m.googleOnly.length}</a>
63
+ </nav>
64
+
65
+ <section class="card gm-sect" id="both">
66
+ <table class="gm-table">
67
+ <thead><tr><th>On the site</th><th>In Google</th><th>Why they match</th><th><span class="gm-sr">Decision</span></th></tr></thead>
68
+ <tbody>
69
+ {pairs.length === 0 && <tr><td colspan="4" class="hint">No events look like they’re on both sides.</td></tr>}
70
+ {pairs.map((p) => { const e = byId.get(p.eventId)!; const g = m.google[p.googleId]; return (
71
+ <tr id={p.googleId} class={`is-${p.state}`}>
72
+ <td><b>{e.title}</b><small>{siteWhen(e)}</small></td>
73
+ <td><b>{g.title || '(no title)'}</b><small>{googleWhen(g)}</small></td>
74
+ <td><small>{WHY[p.reason]}</small></td>
75
+ <td>
76
+ <form method="post" action="/admin/calendar/google" class="gm-acts">
77
+ <input type="hidden" name="op" value="match-decide" /><input type="hidden" name="link" value={link.id} /><input type="hidden" name="googleId" value={p.googleId} />
78
+ {p.state === 'suggested' ? (<>
79
+ <button class="btn sm primary" type="submit" name="decision" value="link">Link</button>
80
+ <button class="btn sm ghost" type="submit" name="decision" value="apart">Not the same</button>
81
+ </>) : (<>
82
+ <span class={`gm-state is-${p.state}`}>{STATE[p.state]}</span>
83
+ <button class="btn sm ghost" type="submit" name="decision" value={p.state === 'linked' ? 'apart' : 'link'}>{p.state === 'linked' ? 'Undo' : 'Link'}</button>
84
+ </>)}
85
+ </form>
86
+ </td>
87
+ </tr>
88
+ ); })}
89
+ </tbody>
90
+ </table>
91
+ </section>
92
+
93
+ <section class="card gm-sect" id="site">
94
+ <h2>Only on the site · {m.siteOnly.length}</h2>
95
+ {m.siteOnly.length === 0 ? <p class="hint">None.</p> : (
96
+ <ul class="gm-list">{m.siteOnly.map((id) => byId.get(id)).filter(Boolean).slice(0, 200).map((e) => <li><b>{e!.title}</b> <small>{siteWhen(e!)}</small></li>)}</ul>
97
+ )}
98
+ </section>
99
+
100
+ <section class="card gm-sect" id="google">
101
+ <h2>Only in Google · {m.googleOnly.length}</h2>
102
+ {m.googleOnly.length === 0 ? <p class="hint">None.</p> : (
103
+ <ul class="gm-list">{m.googleOnly.map((id) => m.google[id]).filter(Boolean).slice(0, 200).map((g) => <li><b>{g.booking ? 'Booked' : g.title || '(no title)'}</b> <small>{googleWhen(g)}{g.booking ? ' · a booked appointment' : ''}</small></li>)}</ul>
104
+ )}
105
+ </section>
106
+
107
+ <div class="card gm-foot">
108
+ <p>
109
+ When you start:
110
+ {out ? ` the ${toGoogle} event${toGoogle === 1 ? '' : 's'} only on the site ${toGoogle === 1 ? 'is' : 'are'} copied to Google.` : ' events only on the site stay on the site.'}
111
+ {inn ? ` The ${fromGoogle} only in Google come to the site as published events in ${calendar?.name ?? 'this calendar'}${link.roomIds.length ? ', holding its room' : ''}${link.rules.length ? `, and titles starting with ${link.rules.map((r) => `“${r.prefix}”`).join(' or ')} get their tag` : ''}.` : ' Events only in Google stay in Google.'}
112
+ {' '}Linked pairs keep both titles as they are until someone edits one. Pairs you mark “Not the same” are treated as two events. No member notifications are sent for any of this.
113
+ </p>
114
+ <div class="gm-buttons">
115
+ <a class="btn ghost" href="/admin/calendar/settings#google">Back</a>
116
+ <a class="btn" href="/admin/calendar/settings#google" title="Your decisions are saved as you make them">Save and finish later</a>
117
+ <form method="post" action="/admin/calendar/google">
118
+ <input type="hidden" name="op" value="match-start" /><input type="hidden" name="link" value={link.id} />
119
+ <button class="btn primary" type="submit" disabled={waiting > 0} title={waiting ? `Decide the ${waiting} suggested pair${waiting === 1 ? '' : 's'} first` : undefined}>Start syncing</button>
120
+ </form>
121
+ </div>
122
+ {waiting > 0 && <p class="hint">Decide the {waiting} suggested pair{waiting === 1 ? '' : 's'} first: Link, or Not the same.</p>}
123
+ </div>
124
+ </>
125
+ )}
126
+ </Admin>
127
+
128
+ <style>
129
+ .gm-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; font-size: 13px; }
130
+ .gm-tabs a { padding: 6px 12px; border: 1px solid var(--line); border-radius: 999px; color: var(--text-2); background: var(--panel); }
131
+ .gm-sect { margin-bottom: 14px; padding: 0; overflow: hidden; scroll-margin-top: 64px; }
132
+ .gm-sect h2 { font-size: 14px; margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--line); }
133
+ .gm-sect .hint { padding: 12px 16px; margin: 0; }
134
+ .gm-table { width: 100%; border-collapse: collapse; font-size: 13px; }
135
+ .gm-table th { text-align: left; font-weight: 600; color: var(--muted); padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 12px; }
136
+ .gm-table td { padding: 10px 16px; border-bottom: 1px solid var(--line); vertical-align: top; }
137
+ .gm-table tr:last-child td { border-bottom: 0; }
138
+ .gm-table td b { display: block; font-weight: 600; }
139
+ .gm-table td small { color: var(--muted); font-size: 12px; }
140
+ .gm-table tr.is-apart td:not(:last-child) { opacity: 0.6; }
141
+ .gm-acts { display: flex; gap: 6px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
142
+ .gm-state { font-size: 12px; font-weight: 600; color: var(--text-2); }
143
+ .gm-state.is-linked { color: var(--green); }
144
+ .gm-list { list-style: none; margin: 0; padding: 0; }
145
+ .gm-list li { padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 13px; }
146
+ .gm-list li:last-child { border-bottom: 0; }
147
+ .gm-list small { color: var(--muted); }
148
+ .gm-foot { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--text-2); }
149
+ .gm-foot p { margin: 0; }
150
+ .gm-buttons { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
151
+ .gm-buttons .btn:disabled { opacity: 0.45; cursor: not-allowed; }
152
+ .gm-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
153
+ @media (max-width: 760px) {
154
+ .gm-table thead { display: none; }
155
+ .gm-table tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--line); }
156
+ .gm-table td { border: 0; }
157
+ .gm-table td:nth-child(3), .gm-table td:nth-child(4) { grid-column: span 2; padding-top: 0; }
158
+ .gm-acts { justify-content: flex-start; }
159
+ }
160
+ </style>
@@ -0,0 +1,107 @@
1
+ ---
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { ApiError } from '../../../lib/api';
4
+ import { CALENDAR } from '../../../lib/calendar/config';
5
+ import { accessFor } from '../../../lib/permissions/access';
6
+ import { deniedMessage, type Permission } from '../../../lib/permissions/catalog';
7
+ import { requestSessionBinding } from '../../../lib/auth/request';
8
+ import { beginGoogleConnect, disconnectGoogle, finishGoogleConnect, refreshGoogleCalendars, saveGoogleClient } from '../../../lib/calendar/google/connect';
9
+ import { createLink, pauseLink, resumeLink, unlink, updateLink } from '../../../lib/calendar/google/links';
10
+ import { decideMatch } from '../../../lib/calendar/google/match';
11
+ import { reviewMatch, syncLinkNow, useGoogleVersion } from '../../../lib/calendar/google/service';
12
+
13
+ // The forms of Calendar settings → Synced with Google, the first-match page
14
+ // and the event editor's sync note post here, then go back with a notice.
15
+ // Connecting and disconnecting Google need "Connected services"; linking,
16
+ // pausing and reviewing need "Manage rooms and calendars"; using Google's
17
+ // version of a field changes the live event, so "Publish content" does too.
18
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
19
+ if (Astro.request.method !== 'POST') return Astro.redirect('/admin/calendar/settings#google');
20
+
21
+ const repo = await getRepo(Astro.locals);
22
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
23
+ const form = await Astro.request.formData();
24
+ const op = String(form.get('op') ?? '');
25
+ const linkId = String(form.get('link') ?? '');
26
+ const NEEDS: Record<string, Permission> = { client: 'services.manage', connect: 'services.manage', finish: 'services.manage', disconnect: 'services.manage' };
27
+ const need = NEEDS[op] ?? (op === 'use' ? (access?.can('content.publish') ? 'content.publish' : 'schedule.manage') : 'schedule.manage');
28
+ if (!access?.can(need)) return new Response(deniedMessage(need), { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } });
29
+
30
+ // only back to the admin (an event page or the match page)
31
+ const ret = String(form.get('return') ?? '');
32
+ const home = ret.startsWith('/admin/') && !ret.startsWith('//') ? ret : '/admin/calendar/settings';
33
+ const back = (key: 'notice' | 'error', text: string, hash = home === '/admin/calendar/settings' ? '#google' : '') => {
34
+ const [path, query = ''] = home.split('?');
35
+ const params = new URLSearchParams(query);
36
+ params.set(key, text);
37
+ return Astro.redirect(`${path}?${params}${hash}`);
38
+ };
39
+ const list = (name: string) => form.getAll(name).map(String).filter(Boolean);
40
+ // rules come as rows of prefix + tag fields; booking titles one per line
41
+ const linkFields = () => ({
42
+ direction: String(form.get('direction') || 'both'),
43
+ roomIds: list('roomIds'),
44
+ noRoom: form.has('noRoom'),
45
+ rules: form.getAll('rulePrefix').map((p, i) => ({ prefix: String(p).trim(), tag: String(form.getAll('ruleTag')[i] ?? '').trim() })).filter((r) => r.prefix && r.tag),
46
+ bookingTitles: String(form.get('bookingTitles') ?? '').split(/\n|,/).map((t) => t.trim()).filter(Boolean),
47
+ });
48
+
49
+ try {
50
+ switch (op) {
51
+ case 'client':
52
+ await saveGoogleClient(repo, { clientId: String(form.get('clientId') ?? ''), clientSecret: String(form.get('clientSecret') ?? '') }, Astro.locals);
53
+ return back('notice', 'Saved. Next, connect Google.');
54
+ case 'connect':
55
+ return Astro.redirect(await beginGoogleConnect(repo, await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals), Astro.url, Astro.locals));
56
+ case 'finish': {
57
+ const account = await finishGoogleConnect(repo, await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals), Astro.url, access.userId, Astro.locals);
58
+ return back('notice', `Connected ${account.email || 'Google'}. Link a calendar next.`);
59
+ }
60
+ case 'disconnect':
61
+ await disconnectGoogle(repo, Astro.locals);
62
+ return back('notice', 'Google is disconnected. Links are paused, and every event stays in both places.');
63
+ case 'refresh':
64
+ await refreshGoogleCalendars(repo, Astro.locals);
65
+ return back('notice', 'Google calendars listed again.');
66
+ case 'link-new': {
67
+ const link = await createLink(repo, { ...linkFields(), calendarId: String(form.get('calendarId') ?? ''), googleId: String(form.get('googleId') ?? ''), startDate: String(form.get('startDate') || '') || undefined });
68
+ return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(link.id)}`);
69
+ }
70
+ case 'link-edit':
71
+ await updateLink(repo, linkId, linkFields());
72
+ return back('notice', 'Link saved. Events that now belong to it go out at the next sync, and ones that don’t are removed from Google.');
73
+ case 'pause':
74
+ await pauseLink(repo, linkId);
75
+ return back('notice', 'Paused. Changes on either side wait until you resume.');
76
+ case 'resume': {
77
+ const link = await resumeLink(repo, linkId);
78
+ if (link.state === 'matching') return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(link.id)}`);
79
+ return back('notice', 'Resumed. It reads Google again from the start date, quietly, and catches up.');
80
+ }
81
+ case 'unlink':
82
+ await unlink(repo, linkId);
83
+ return back('notice', 'Unlinked. Every event stays in both places; linking again pairs them up.');
84
+ case 'sync':
85
+ await syncLinkNow(repo, linkId, Astro.locals);
86
+ return back('notice', 'Synced.');
87
+ case 'match-build':
88
+ await reviewMatch(repo, linkId, Astro.locals);
89
+ return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(linkId)}`);
90
+ case 'match-decide':
91
+ await decideMatch({ repo }, linkId, String(form.get('googleId') ?? ''), form.get('decision') === 'link' ? 'link' : 'apart');
92
+ return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(linkId)}#${encodeURIComponent(String(form.get('googleId') ?? ''))}`);
93
+ case 'match-start':
94
+ await syncLinkNow(repo, linkId, Astro.locals, { start: true });
95
+ return Astro.redirect(`/admin/calendar/settings?notice=${encodeURIComponent('Syncing. Recent changes lists what was copied each way.')}#google`);
96
+ case 'use':
97
+ await useGoogleVersion(repo, Number(form.get('log')), Astro.url, Astro.locals);
98
+ return back('notice', 'Using Google’s version. The other copies follow at the next sync.');
99
+ }
100
+ return back('error', 'Unknown action.');
101
+ } catch (e) {
102
+ const message = e instanceof ApiError ? e.message : e instanceof Error && e.name === 'ZodError' ? 'Check the link’s settings and try again.' : 'That didn’t work. Try again.';
103
+ if (!(e instanceof ApiError)) console.error('acms google sync admin:', e);
104
+ if (op.startsWith('match-')) return Astro.redirect(`/admin/calendar/google/match?link=${encodeURIComponent(linkId)}&error=${encodeURIComponent(message)}`);
105
+ return back('error', message);
106
+ }
107
+ ---