@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
@@ -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
+ ---
@@ -0,0 +1,298 @@
1
+ ---
2
+ import Admin from '../../../layouts/Admin.astro';
3
+ import Modal from '../../../components/Modal.astro';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { ApiError } from '../../../lib/api';
6
+ import { extraLocales, localeName, localePath } from '../../../lib/i18n';
7
+ import { PUSH } from '../../../lib/push/config';
8
+ import { accessFor, forbiddenUnless } from '../../../lib/permissions/access';
9
+ import type { Permission } from '../../../lib/permissions/catalog';
10
+ import { REQUESTS } from '../../../lib/calendar/request-config';
11
+ import { MEMBERS } from '../../../lib/calendar/members-config';
12
+ import { approveRequest, declineRequest, deleteRequest, replyToRequest, requestRecipients, requestableRooms, requestSettings } from '../../../lib/calendar/request-service';
13
+ import { pendingCount, requestRows, settingsFromForm, type RequestTab } from '../../../lib/calendar/request-admin';
14
+ import { approveEventRequest, declineEventRequest } from '../../../lib/calendar/requests';
15
+
16
+ // Calendar → Requests (acms({ requests })): requests from the app, the
17
+ // website and hosts in one list, with Pending, Approved and Declined tabs and
18
+ // what takes the rooms on each row. Approve opens a filled-in draft (or the
19
+ // event a change is about); Reply and Decline email the person in their
20
+ // language. Reading needs "Create and edit content", answering "Publish
21
+ // content", the settings "Manage rooms and calendars". The rules are
22
+ // lib/calendar/request-service.ts; the list lib/calendar/request-admin.ts.
23
+ if (!REQUESTS.enabled) return new Response(null, { status: 404 });
24
+ const repo = await getRepo(Astro.locals);
25
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit');
26
+ if (denied) return denied;
27
+ const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
28
+ const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
29
+ const hooks = { url: Astro.url, locals: Astro.locals };
30
+ const back = (notice: string, tab = Astro.url.searchParams.get('tab') ?? '') => {
31
+ const p = new URLSearchParams();
32
+ if (tab) p.set('tab', tab);
33
+ if (notice) p.set('notice', notice);
34
+ const q = p.toString();
35
+ return Astro.redirect(q ? '/admin/calendar/requests?' + q : '/admin/calendar/requests');
36
+ };
37
+
38
+ if (Astro.request.method === 'POST') {
39
+ const form = await Astro.request.formData();
40
+ const op = String(form.get('op') ?? '');
41
+ const id = String(form.get('id') ?? '');
42
+ const seen = Number(form.get('at')) || undefined;
43
+ const nope = await forbiddenUnless(Astro.request, Astro.cookies, repo, op === 'settings' ? 'schedule.manage' : 'content.publish');
44
+ if (nope) return nope;
45
+ try {
46
+ if (op === 'approve') {
47
+ const done = await approveRequest(repo, who, id, hooks, seen, { message: String(form.get('message') ?? ''), email: form.get('email') === '1' });
48
+ return Astro.redirect(done.next);
49
+ }
50
+ if (op === 'decline') {
51
+ // the email choice is only offered for requests from the app or the website (hosts are always told)
52
+ const email = form.has('email-offered') ? form.get('email') === '1' : true;
53
+ await declineRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen, { email });
54
+ return back(email ? 'Declined. The person was emailed.' : 'Declined, without an email.');
55
+ }
56
+ if (op === 'delete') {
57
+ await deleteRequest(repo, who, id);
58
+ return back('Deleted as spam. Nobody was emailed.');
59
+ }
60
+ if (op === 'reply') {
61
+ await replyToRequest(repo, who, id, String(form.get('message') ?? ''), hooks);
62
+ return back('Reply sent.');
63
+ }
64
+ // members' requests staged on their events (My events)
65
+ if (op === 'member-approve') {
66
+ const e = await approveEventRequest(repo, who, id, hooks, seen);
67
+ return back(`Approved “${e.title}”.`);
68
+ }
69
+ if (op === 'member-decline') {
70
+ const e = await declineEventRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen);
71
+ return back(`Declined “${e.title}”.`);
72
+ }
73
+ if (op === 'settings') {
74
+ const settings = await repo.getSettings();
75
+ const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id);
76
+ const requests = settingsFromForm(form, requestSettings(settings), calendars);
77
+ await repo.updateSettings({ calendar: { ...settings.calendar, requests } });
78
+ return back('Request settings saved.');
79
+ }
80
+ return back('');
81
+ } catch (err) {
82
+ return back(err instanceof ApiError ? err.message : 'Couldn’t do that. Try again.');
83
+ }
84
+ }
85
+
86
+ const settings = await repo.getSettings();
87
+ const rq = requestSettings(settings);
88
+ const tab = (['pending', 'approved', 'declined'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'pending';
89
+ const page = Math.min(1000, Math.max(1, Number.parseInt(Astro.url.searchParams.get('page') ?? '', 10) || 1));
90
+ const [{ rows, more }, waiting, rooms, calendars, recipients] = await Promise.all([
91
+ requestRows(repo, settings, tab as RequestTab, Date.now(), page), pendingCount(repo), requestableRooms(repo),
92
+ repo.listCalendars().then((cs) => cs.filter((c) => c.kind === 'native')), requestRecipients(repo, settings),
93
+ ]);
94
+ const notice = Astro.url.searchParams.get('notice') ?? '';
95
+ const canAnswer = access.can('content.publish');
96
+ const canSet = access.can('schedule.manage');
97
+ const langs = extraLocales();
98
+ // the kinds there are, plus a few empty rows to add more (12 at most)
99
+ const typeRows = [...rq.types, ...Array.from({ length: Math.min(3, 12 - rq.types.length) }, () => null)];
100
+ const TABS = [['pending', 'Pending'], ['approved', 'Approved'], ['declined', 'Declined']] as const;
101
+ const formUrl = REQUESTS.page ? REQUESTS.path : '';
102
+ const pageHref = (n: number) => '/admin/calendar/requests?' + new URLSearchParams({ ...(tab === 'pending' ? {} : { tab }), ...(n > 1 ? { page: String(n) } : {}) }).toString();
103
+ const empty = { pending: 'Nothing waiting. Requests from the app, the website and hosts show up here.', approved: 'No approved requests yet.', declined: 'No declined requests.' }[tab];
104
+ ---
105
+
106
+ <Admin title="Requests" section="calendar:requests">
107
+ <div class="page-head">
108
+ <div>
109
+ <h1>Requests</h1>
110
+ <p class="sub">From the app, the website{MEMBERS.enabled ? ' and hosts' : ''}, in one list{formUrl && <> · the form is at <a href={formUrl} target="_blank" rel="noopener">{formUrl}</a>{langs.map((l) => <> and <a href={localePath(formUrl, l)} target="_blank" rel="noopener">{localePath(formUrl, l)}</a></>)}</>}</p>
111
+ </div>
112
+ <div class="page-actions">
113
+ {!rq.open && <span class="rq-closed">Closed</span>}
114
+ {canSet && <button class="btn" type="button" data-modal="rq-settings">Settings</button>}
115
+ </div>
116
+ </div>
117
+
118
+ {notice && <p class="notice" role="status">{notice}</p>}
119
+
120
+ <nav class="seg rq-tabs" aria-label="Show">
121
+ {TABS.map(([k, l]) => (
122
+ <a href={k === 'pending' ? '/admin/calendar/requests' : '/admin/calendar/requests?tab=' + k} class={tab === k ? 'on' : ''} aria-current={tab === k ? 'page' : undefined}>
123
+ {l}{k === 'pending' && waiting > 0 && <span class="rq-count">{waiting}</span>}
124
+ </a>
125
+ ))}
126
+ </nav>
127
+
128
+ {rows.length === 0 ? <p class="hint rq-empty">{empty}</p> : (
129
+ <section class="card rq-list">
130
+ {rows.map((r) => (
131
+ <article class="rq-row" id={r.dom}>
132
+ <small class="rq-head">{r.head}</small>
133
+ <b class="rq-title">{r.href ? <a href={r.href}>{r.title}</a> : r.title}</b>
134
+ {r.line && <span class="rq-line">{r.line}</span>}
135
+ <span class="rq-who">{r.who}</span>
136
+ {r.note && <p class="rq-note">“{r.note}”</p>}
137
+ {r.clash && <p class={r.clash.taken ? 'rq-clash is-taken' : 'rq-clash'}>{r.clash.text}</p>}
138
+ {r.answer && <p class="rq-answer">{r.answer}{r.reply && <> — “{r.reply}”</>}</p>}
139
+ {tab === 'pending' && canAnswer && (
140
+ <div class="rq-acts">
141
+ {(r.kind === 'new' || r.kind === 'change') && <button class="btn sm primary" type="button" data-modal={'approve-' + r.dom}>Approve…</button>}
142
+ {r.kind === 'move' && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
143
+ {(r.kind === 'move' || r.kind === 'from-date') && r.href && <a class="btn sm ghost" href={r.href}>Open the event</a>}
144
+ {r.kind === 'member-new' || r.kind === 'member-changes' ? (
145
+ <>
146
+ <a class="btn sm ghost" href={r.href}>Open</a>
147
+ <form method="post"><input type="hidden" name="op" value="member-approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>
148
+ </>
149
+ ) : r.canReply && r.email && <button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button>}
150
+ {r.kind !== 'from-date' && <button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>}
151
+ </div>
152
+ )}
153
+ {tab !== 'pending' && r.canReply && r.email && canAnswer && <div class="rq-acts"><button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button></div>}
154
+ </article>
155
+ ))}
156
+ </section>
157
+ )}
158
+ {(page > 1 || more) && (
159
+ <nav class="rq-pager" aria-label="Pages">
160
+ {page > 1 && <a class="btn sm ghost" href={pageHref(page - 1)}>Previous</a>}
161
+ <span class="hint">Page {page}</span>
162
+ {more && <a class="btn sm ghost" href={pageHref(page + 1)}>Next</a>}
163
+ </nav>
164
+ )}
165
+ {tab === 'pending' && rows.length > 0 && !canAnswer && <p class="hint">Someone whose role can publish content answers these.</p>}
166
+
167
+ {canAnswer && rows.map((r) => (
168
+ <>
169
+ {tab === 'pending' && (r.kind === 'new' || r.kind === 'change') && (
170
+ <Modal id={'approve-' + r.dom} title={r.kind === 'new' ? 'Approve and make a draft' : 'Approve this change'}>
171
+ <form method="post">
172
+ <input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
173
+ <p class="hint">{r.kind === 'new'
174
+ ? 'You’ll get a draft with the title, time, repeat, rooms held and the kind’s tag and calendar. Check it, then publish it. Their contact details stay here, never on the event.'
175
+ : 'The event opens (or that date’s Move… dialog) so you can make the change.'}</p>
176
+ <label class="field"><span>Message <em class="hint">optional</em></span><textarea name="message" rows="3" maxlength="2000" placeholder="See you then!"></textarea></label>
177
+ <label class="check-row"><input type="checkbox" name="email" value="1" checked /> Email them that it’s approved, in their language</label>
178
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Approve</button></div>
179
+ </form>
180
+ </Modal>
181
+ )}
182
+ {r.canReply && r.email && (
183
+ <Modal id={'reply-' + r.dom} title="Reply">
184
+ <form method="post">
185
+ <input type="hidden" name="op" value="reply" /><input type="hidden" name="id" value={r.id} />
186
+ <p class="hint">Emailed to {r.email} with a short line in their language. The request stays {tab === 'pending' ? 'waiting' : tab}.</p>
187
+ <label class="field"><span>Your reply</span><textarea name="message" rows="5" maxlength="2000" required placeholder="Could you come at 3 instead? The room is free then."></textarea></label>
188
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Send reply</button></div>
189
+ </form>
190
+ </Modal>
191
+ )}
192
+ {tab === 'pending' && r.kind !== 'from-date' && (
193
+ <Modal id={'decline-' + r.dom} title="Decline this request">
194
+ <form method="post">
195
+ <input type="hidden" name="op" value={r.kind === 'member-new' || r.kind === 'member-changes' ? 'member-decline' : 'decline'} /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
196
+ <p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move'
197
+ ? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : '.')
198
+ : 'It moves to Declined, and is deleted with the rest after the time you keep requests.'}</p>
199
+ <label class="field"><span>Reason <em class="hint">optional</em></span><textarea name="reason" rows="3" maxlength="2000" placeholder="The Sanctuary is booked that weekend."></textarea></label>
200
+ {(r.kind === 'new' || r.kind === 'change') && <label class="check-row"><input type="hidden" name="email-offered" value="1" /><input type="checkbox" name="email" value="1" checked /> Email them, in their language</label>}
201
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Decline</button></div>
202
+ </form>
203
+ {(r.kind === 'new' || r.kind === 'change') && (
204
+ <form method="post" class="rq-spam">
205
+ <input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={r.id} />
206
+ <p class="hint">Spam? Delete it now, without an email to the address it gave.</p>
207
+ <button class="btn sm ghost" type="submit">Delete as spam</button>
208
+ </form>
209
+ )}
210
+ </Modal>
211
+ )}
212
+ </>
213
+ ))}
214
+
215
+ {canSet && (
216
+ <Modal id="rq-settings" title="Request settings" open={Astro.url.searchParams.has('settings')}>
217
+ <form method="post" class="rq-settings">
218
+ <input type="hidden" name="op" value="settings" />
219
+ <div>
220
+ <label class="check-row"><input type="checkbox" name="open" value="1" checked={rq.open} /> Taking requests</label>
221
+ <p class="hint">When off, the form and the app say so and show the office phone. They never disappear.</p>
222
+ </div>
223
+ <fieldset>
224
+ <legend>Kinds people choose from</legend>
225
+ <p class="hint">Approving a request makes a draft with the kind’s tag, in its calendar (else the default calendar).</p>
226
+ <div class="rq-type rq-type-h" aria-hidden="true"><span>Kind</span>{langs.map((l) => <span>{localeName(l, true)}</span>)}<span>Tag</span><span>Calendar</span></div>
227
+ {typeRows.map((t, i) => (
228
+ <div class="rq-type">
229
+ {t && <input type="hidden" name={'type-id-' + i} value={t.id} />}
230
+ <input name={'type-label-' + i} value={t?.label ?? ''} placeholder={t || i > rq.types.length ? '' : rq.types.length ? 'Add a kind' : 'Class'} aria-label="Kind" maxlength="60" />
231
+ {langs.map((l) => <input name={'type-' + l + '-' + i} value={t?.translations[l] ?? ''} placeholder={localeName(l, true)} aria-label={'Kind in ' + localeName(l)} maxlength="60" />)}
232
+ <input name={'type-tag-' + i} value={t?.tag ?? ''} placeholder="Tag" aria-label="Tag" maxlength="60" />
233
+ <select name={'type-cal-' + i} aria-label="Calendar">
234
+ <option value="">Default calendar</option>
235
+ {calendars.map((c) => <option value={c.id} selected={t?.calendarId === c.id}>{c.name}</option>)}
236
+ </select>
237
+ </div>
238
+ ))}
239
+ </fieldset>
240
+ <label class="field"><span>Reply time <em class="hint">“We usually answer within …”</em></span><input name="replyTime" value={rq.replyTime} placeholder="3 days" maxlength="60" /></label>
241
+ <label class="field"><span>Notice at the top of the form <em class="hint">optional</em></span><textarea name="notice" rows="2" maxlength="400" placeholder="The church office reviews every request. It isn’t on the calendar until they confirm it.">{rq.notice}</textarea></label>
242
+ {langs.map((l) => (
243
+ <fieldset>
244
+ <legend>{localeName(l, true)}</legend>
245
+ <label class="field"><span>Reply time</span><input name={'replyTime-' + l} value={rq.translations[l]?.replyTime ?? ''} maxlength="60" /></label>
246
+ <label class="field"><span>Notice</span><textarea name={'notice-' + l} rows="2" maxlength="400">{rq.translations[l]?.notice ?? ''}</textarea></label>
247
+ </fieldset>
248
+ ))}
249
+ <label class="field"><span>Office phone <em class="hint">shown when requests are closed, and after sending</em></span><input name="phone" inputmode="tel" autocomplete="off" value={rq.phone} maxlength="40" /></label>
250
+ <label class="field"><span>Who hears about new requests</span><textarea name="notify" rows="2" placeholder={recipients.join(', ')}>{rq.notify.join(', ')}</textarea><em class="hint">Email addresses. Empty: {MEMBERS.notify.length ? 'the members notify list in the site’s config' : 'the owners'} ({recipients.join(', ') || 'nobody yet'}).</em></label>
251
+ {PUSH.enabled && (
252
+ <div>
253
+ <label class="check-row"><input type="hidden" name="phones" value="0" /><input type="checkbox" name="phones" value="1" checked={rq.phones} /> Also notify their linked phones</label>
254
+ <p class="hint">A push saying a request arrived and for when, without names.</p>
255
+ </div>
256
+ )}
257
+ <div class="field-row">
258
+ <label class="field"><span>New requests a day <em class="hint">0 = no limit</em></span><input name="perDay" type="number" min="0" max="10000" value={rq.perDay} /></label>
259
+ <label class="field"><span>Keep requests for (days) <em class="hint">after the answer, or after they arrive if nobody answers</em></span><input name="keepDays" type="number" min="7" max="1095" value={rq.keepDays} /></label>
260
+ </div>
261
+ <p class="hint">Rooms people can ask for: {rooms.length ? rooms.map((r) => r.name).join(', ') : 'none yet'}. Turn on “Anyone can check if it’s free” on a room to offer it.</p>
262
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
263
+ </form>
264
+ </Modal>
265
+ )}
266
+ </Admin>
267
+
268
+ <style>
269
+ .rq-tabs { margin: 0 0 14px; }
270
+ .rq-count { font-size: 11px; font-weight: 700; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 0 6px; margin-left: 6px; }
271
+ .rq-closed { font-size: 12.5px; font-weight: 600; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 3px 10px; }
272
+ .rq-empty { padding: 24px 8px; }
273
+ .rq-list { padding: 0; }
274
+ .rq-row { display: grid; gap: 3px; padding: 14px 18px; border-top: 1px solid var(--line); scroll-margin-top: 80px; }
275
+ .rq-row:first-child { border-top: 0; }
276
+ .rq-row:target { background: var(--amber-soft); }
277
+ .rq-head { color: var(--text-2); font-size: 12.5px; }
278
+ .rq-title { font-size: 15.5px; }
279
+ .rq-title a { color: inherit; }
280
+ .rq-line, .rq-who { font-size: 13.5px; color: var(--text-2); }
281
+ .rq-note { margin: 4px 0 0; font-style: italic; font-size: 14px; }
282
+ .rq-clash { margin: 4px 0 0; font-size: 13px; color: var(--green); }
283
+ .rq-clash.is-taken { color: var(--amber); }
284
+ .rq-answer { margin: 4px 0 0; font-size: 13px; color: var(--text-2); }
285
+ .rq-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
286
+ .rq-acts form { margin: 0; }
287
+ .rq-pager { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; }
288
+ .rq-spam { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); margin-top: 14px; padding-top: 12px; }
289
+ .rq-spam .hint { margin: 0; }
290
+ .rq-settings { display: grid; gap: 12px; }
291
+ .rq-settings fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
292
+ .rq-settings legend { font-weight: 600; font-size: 13.5px; padding: 0 4px; }
293
+ .rq-settings .hint { margin: 2px 0 0; }
294
+ .rq-type { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; }
295
+ .rq-type > * { min-width: 0; }
296
+ .rq-type-h span { font-size: 12px; color: var(--text-2); }
297
+ :global(dialog#rq-settings.modal) { width: min(680px, calc(100vw - 32px)); max-width: none; }
298
+ </style>