@acms-subir/core 0.3.21 → 0.3.22

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 (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cli/migrations.generated.mjs +6 -0
  3. package/docs/mobile.md +1 -1
  4. package/docs/server.md +203 -24
  5. package/integration.mjs +134 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +3 -1
  8. package/src/components/EmailSettings.tsx +1 -1
  9. package/src/components/RolesManager.tsx +15 -7
  10. package/src/components/calendar/CalendarBoard.tsx +5 -3
  11. package/src/layouts/Admin.astro +2 -2
  12. package/src/lib/auth/accounts.ts +2 -2
  13. package/src/lib/auth/request.ts +19 -7
  14. package/src/lib/auth/return.ts +32 -0
  15. package/src/lib/auth.ts +17 -4
  16. package/src/lib/calendar/board.ts +12 -1
  17. package/src/lib/calendar/member-form.ts +152 -0
  18. package/src/lib/calendar/requests.ts +284 -0
  19. package/src/lib/calendar/schedule.ts +1 -1
  20. package/src/lib/editor/auth.ts +5 -3
  21. package/src/lib/editor/schemas.ts +2 -1
  22. package/src/lib/editor/store.ts +5 -3
  23. package/src/lib/email/service.ts +16 -3
  24. package/src/lib/email/store.ts +3 -1
  25. package/src/lib/email/templates.ts +12 -0
  26. package/src/lib/permissions/catalog.ts +84 -36
  27. package/src/lib/permissions/service.ts +11 -8
  28. package/src/lib/repo/migrations.ts +6 -0
  29. package/src/lib/repo/sql.ts +23 -5
  30. package/src/lib/repo/types.ts +5 -1
  31. package/src/lib/roles.ts +46 -4
  32. package/src/lib/schema.ts +31 -2
  33. package/src/lib/sync/catalog.ts +3 -0
  34. package/src/middleware.ts +8 -1
  35. package/src/pages/admin/app-auth.astro +5 -3
  36. package/src/pages/admin/calendar/events/[id].astro +249 -2
  37. package/src/pages/admin/calendar/events/index.astro +165 -12
  38. package/src/pages/admin/index.astro +22 -0
  39. package/src/pages/admin/login.astro +37 -16
  40. package/src/pages/admin/roles.astro +3 -0
  41. package/src/pages/admin/users.astro +15 -14
  42. package/src/pages/api/search.ts +3 -1
  43. package/src/pages/author/[slug].astro +3 -1
  44. package/src/pages/members/_EventForm.astro +89 -0
  45. package/src/pages/members/_Layout.astro +76 -0
  46. package/src/pages/members/_http.ts +25 -0
  47. package/src/pages/members/events/[id].astro +86 -0
  48. package/src/pages/members/events/index.astro +218 -0
  49. package/src/pages/members/events/new.astro +48 -0
  50. package/src/pages/members/index.ts +6 -0
  51. package/src/server/index.ts +96 -18
  52. package/src/virtual.d.ts +4 -0
@@ -9,7 +9,13 @@ import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
9
9
  import { mergeEventDraft } from '../../../../lib/drafts';
10
10
  import { safeColor } from '../../../../lib/calendar/events';
11
11
  import { mapsStatus } from '../../../../lib/maps/config';
12
+ import { approveEventRequest, declineEventRequest, setEventHosts } from '../../../../lib/calendar/requests';
13
+ import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
14
+ import type { Permission } from '../../../../lib/permissions/catalog';
15
+ import { ApiError } from '../../../../lib/api';
16
+ import { roleLabel } from '../../../../lib/roles';
12
17
  import ThemeScript from '../../../../components/ThemeScript.astro';
18
+ import Modal from '../../../../components/Modal.astro';
13
19
  import PostEditor from '../../../../components/editor/PostEditor';
14
20
  import '../../../../styles/admin.css';
15
21
  import '../../../../styles/editor.css';
@@ -18,20 +24,82 @@ import '../../../../styles/editor.css';
18
24
  // publish flow as posts, with the event's settings (When · Where ·
19
25
  // Registration · Organize) on the page above its content. Live events stage
20
26
  // edits until Update, like posts.
27
+ //
28
+ // A bar above the editor carries what the editor island doesn't: a member's
29
+ // request on this event (lib/calendar/requests.ts) with Approve / Decline
30
+ // for staff who can publish, and the event's hosts.
21
31
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
22
32
 
23
33
  const repo = await getRepo(Astro.locals);
34
+ const self = `/admin/calendar/events/${encodeURIComponent(Astro.params.id!)}`;
35
+
36
+ if (Astro.request.method === 'POST') {
37
+ const form = await Astro.request.formData();
38
+ const op = String(form.get('op'));
39
+ const needs: Permission = op === 'approve' || op === 'decline' ? 'content.publish' : 'content.edit';
40
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
41
+ if (denied) return denied;
42
+ const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
43
+ const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
44
+ const hooks = { url: Astro.url, locals: Astro.locals };
45
+ const id = Astro.params.id!;
46
+ const back = (key: 'notice' | 'error', text: string) => Astro.redirect(`${self}?${new URLSearchParams({ [key]: text })}`);
47
+ try {
48
+ const before = await repo.getEvent(id);
49
+ if (op === 'approve') {
50
+ await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
51
+ return back('notice', before?.request?.kind === 'changes' ? 'Approved. The changes are live.' : 'Approved and published.');
52
+ }
53
+ if (op === 'decline') {
54
+ await declineEventRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
55
+ const wasLive = before?.status === 'published' || before?.status === 'scheduled';
56
+ return back('notice', before?.request?.kind === 'changes' ? 'Declined. The suggested changes were dropped.' : wasLive ? 'Declined.' : 'Declined. It stays a draft until it’s sent again.');
57
+ }
58
+ if (op === 'hosts') {
59
+ const e = await setEventHosts(repo, who, id, form.getAll('hostIds').map(String).filter(Boolean));
60
+ return back('notice', e.hostIds.length ? `Hosts saved (${e.hostIds.length}).` : 'Hosts removed.');
61
+ }
62
+ } catch (err) {
63
+ return back('error', err instanceof ApiError ? err.message : 'That didn’t work. Try again.');
64
+ }
65
+ return Astro.redirect(self);
66
+ }
67
+
24
68
  const event = await repo.getEvent(Astro.params.id!);
25
69
  if (!event) return new Response(null, { status: 404, statusText: 'Not found' });
26
70
 
27
71
  const editable = mergeEventDraft(event);
28
- const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
72
+ const [settings, calendars, users, access] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo)]);
29
73
  const previewPath = `/preview/event/${event.id}`;
30
74
  const sharePath = `${previewPath}?token=${createPreviewToken('event', event.id)}`;
31
75
  // other languages this document can be written in (acms({ i18n }))
32
76
  const trLanguages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
33
77
  const trMain = { code: I18N.defaultLocale, name: localeName(I18N.defaultLocale || 'en', true) };
34
78
  const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSettings()).ai, Astro.locals) !== null;
79
+
80
+ // the request bar: a member's request, the event's hosts, and a one-time flash
81
+ const canPublish = access?.can('content.publish') ?? false;
82
+ const canEdit = access?.can('content.edit') ?? false;
83
+ const nameOf = (id: string) => { const u = users.find((x) => x.id === id); return u ? u.name || u.email : ''; };
84
+ const req = event.request;
85
+ const pending = req?.state === 'pending' ? req : null;
86
+ const declined = req?.state === 'declined' ? req : null;
87
+ const requester = req ? nameOf(req.by) || 'a member' : '';
88
+ const cap = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);
89
+ const isLive = event.status === 'published' || event.status === 'scheduled';
90
+ const hosts = event.hostIds.map((id) => ({ id, name: nameOf(id) })).filter((h) => h.name);
91
+ const people = [...users].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email));
92
+ const notice = Astro.url.searchParams.get('notice') ?? '';
93
+ const error = Astro.url.searchParams.get('error') ?? '';
94
+ const showBar = Boolean(req) || canEdit || hosts.length > 0 || Boolean(notice || error);
95
+ const ago = (ts: number) => {
96
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
97
+ if (s < 60) return 'just now';
98
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
99
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
100
+ return `${Math.floor(s / 86400)}d ago`;
101
+ };
102
+ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ? 'Approve' : 'Approve and publish';
35
103
  ---
36
104
 
37
105
  <!doctype html>
@@ -44,8 +112,62 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
44
112
  <title>{event.title || 'Untitled event'} · {BRAND.name} editor</title>
45
113
  <link rel="preconnect" href="https://fonts.googleapis.com" />
46
114
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
115
+ {showBar && (
116
+ <script is:inline>
117
+ // The bar's forms leave the editor on purpose (approving publishes what's
118
+ // staged, declining drops it, hosts reload the page), so the editor's
119
+ // "unpublished changes" prompt must not stop them. Registered before
120
+ // the editor (and as a capture listener, which runs first) so it can
121
+ // stop the editor's own beforeunload handler for these submits only.
122
+ window.addEventListener('beforeunload', (e) => { if (window.__evreqLeaving) e.stopImmediatePropagation(); }, { capture: true });
123
+ </script>
124
+ )}
47
125
  </head>
48
- <body>
126
+ <body class={showBar ? 'has-evreq' : undefined}>
127
+ {showBar && (
128
+ <header class="evreq" id="evreq" aria-label="Requests and hosts">
129
+ {(notice || error) && <p class={`evreq-flash${error ? ' is-error' : ''}`} role={error ? 'alert' : 'status'}>{error || notice}</p>}
130
+ {pending && (
131
+ <div class="evreq-req">
132
+ <div class="evreq-text">
133
+ <p class="evreq-line">
134
+ <b class="evreq-state">{pending.kind === 'new' ? 'Needs approval' : 'Changes to review'}</b>
135
+ <span>Requested by <b>{requester}</b> · <time datetime={new Date(pending.at).toISOString()}>{ago(pending.at)}</time></span>
136
+ {pending.kind === 'changes' && (
137
+ <span class="evreq-muted">Suggested changes to this live event · <a href={eventUrl(event.slug)} target="_blank" rel="noopener">See what’s live ↗</a></span>
138
+ )}
139
+ </p>
140
+ {pending.note && <p class="evreq-note">“{pending.note}”</p>}
141
+ </div>
142
+ {canPublish ? (
143
+ <div class="evreq-acts">
144
+ <button class="btn sm ghost" type="button" data-modal="evreq-decline">Decline…</button>
145
+ <form method="post" data-evreq>
146
+ <input type="hidden" name="op" value="approve" /><input type="hidden" name="at" value={pending.at} />
147
+ <button class="btn sm" type="submit">{approveLabel}</button>
148
+ </form>
149
+ </div>
150
+ ) : (
151
+ <span class="evreq-muted">Someone whose role can publish approves this.</span>
152
+ )}
153
+ </div>
154
+ )}
155
+ {declined && (
156
+ <p class="evreq-declined">
157
+ {declined.kind === 'new' ? 'Request declined' : 'Suggested changes declined'}
158
+ {declined.reviewedAt ? ` ${ago(declined.reviewedAt)}` : ''}{declined.reviewedBy && nameOf(declined.reviewedBy) ? ` by ${nameOf(declined.reviewedBy)}` : ''}.
159
+ {declined.reply ? <> Reply to {requester}: <q>{declined.reply}</q></> : ' No note was sent.'}
160
+ {declined.kind === 'new' && !isLive && ` It stays a draft until ${requester} sends it again.`}
161
+ </p>
162
+ )}
163
+ <div class="evreq-hosts">
164
+ <span class="evreq-label">Hosts</span>
165
+ {hosts.length ? hosts.map((h) => <span class="evreq-chip">{h.name}</span>) : <span class="evreq-muted">None</span>}
166
+ {canEdit && <button class="btn sm ghost" type="button" data-modal="evreq-hosts">{hosts.length ? 'Edit' : 'Add hosts'}</button>}
167
+ </div>
168
+ </header>
169
+ )}
170
+
49
171
  <PostEditor
50
172
  client:only="react"
51
173
  kind="event"
@@ -64,5 +186,130 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
64
186
  maps={mapsStatus(Astro.locals)}
65
187
  site={{ title: settings.siteTitle, url: settings.siteUrl }}
66
188
  />
189
+
190
+ {pending && canPublish && (
191
+ <Modal id="evreq-decline" title={pending.kind === 'new' ? 'Decline this request' : 'Decline these changes'}>
192
+ <form method="post" data-evreq>
193
+ <input type="hidden" name="op" value="decline" /><input type="hidden" name="at" value={pending.at} />
194
+ <p class="hint evreq-modal-hint">
195
+ {pending.kind === 'changes'
196
+ ? `${cap(requester)}’s suggested changes are dropped. The event stays as it is.`
197
+ : isLive ? `${cap(requester)} hears that it was declined. The event stays as it is.`
198
+ : `It stays a draft that ${requester} can change and send again.`}
199
+ </p>
200
+ <label class="field"><span>Note to {requester} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="Thanks for asking! That weekend is taken. Could it work the week after?"></textarea></label>
201
+ <div class="modal-foot">
202
+ <button class="btn ghost" type="button" data-close>Cancel</button>
203
+ <button class="btn primary" type="submit">Decline</button>
204
+ </div>
205
+ </form>
206
+ </Modal>
207
+ )}
208
+
209
+ {canEdit && (
210
+ <Modal id="evreq-hosts" title="Hosts">
211
+ <form method="post" data-evreq>
212
+ <input type="hidden" name="op" value="hosts" />
213
+ <p class="hint evreq-modal-hint">Hosts see this event on My events and can cancel its dates. Up to 10.</p>
214
+ {people.length > 8 && <input class="evreq-find" type="search" placeholder="Find someone…" aria-label="Find someone" data-evreq-find />}
215
+ <div class="evreq-people">
216
+ {people.map((u) => (
217
+ <label class="check-row" data-q={`${u.name} ${u.email} ${roleLabel(u.role)}`.toLowerCase()}>
218
+ <input type="checkbox" name="hostIds" value={u.id} checked={event.hostIds.includes(u.id)} />
219
+ <span><b>{u.name || u.email}</b> <small>{roleLabel(u.role)}</small></span>
220
+ </label>
221
+ ))}
222
+ {people.length === 0 && <p class="hint">No one on the team yet. <a href="/admin/users?new=1">Invite someone</a></p>}
223
+ </div>
224
+ <div class="modal-foot">
225
+ <button class="btn ghost" type="button" data-close>Cancel</button>
226
+ <button class="btn primary" type="submit">Save hosts</button>
227
+ </div>
228
+ </form>
229
+ </Modal>
230
+ )}
231
+
232
+ <script>
233
+ const bar = document.getElementById('evreq');
234
+ // the editor's settings sidebar is fixed under its own top bar: move it below this bar too
235
+ if (bar) new ResizeObserver(() => document.body.style.setProperty('--evreq-h', `${bar.offsetHeight}px`)).observe(bar);
236
+
237
+ // a flash shows once: drop it from the address so a reload doesn't repeat it
238
+ const url = new URL(location.href);
239
+ if (url.searchParams.has('notice') || url.searchParams.has('error')) {
240
+ url.searchParams.delete('notice');
241
+ url.searchParams.delete('error');
242
+ history.replaceState(history.state, '', url);
243
+ }
244
+
245
+ // let autosave finish before leaving, then skip the editor's leave prompt
246
+ const busy = () => document.querySelector('.pe-savestate.dirty, .pe-savestate.saving');
247
+ const w = window as unknown as { __evreqLeaving?: boolean };
248
+ document.addEventListener('submit', async (e) => {
249
+ if (!bar) return;
250
+ const form = e.target instanceof HTMLFormElement && e.target.matches('[data-evreq]') ? e.target : null;
251
+ if (!form) return;
252
+ if (!busy()) { w.__evreqLeaving = true; return; }
253
+ e.preventDefault();
254
+ for (let i = 0; i < 50 && busy(); i++) await new Promise((r) => setTimeout(r, 120));
255
+ if (busy()) { alert('Your latest edits haven’t saved yet. Check the editor, then try again.'); return; }
256
+ w.__evreqLeaving = true;
257
+ form.submit();
258
+ });
259
+ // back from the next page (back/forward cache): the editor guards leaving again
260
+ addEventListener('pageshow', () => { w.__evreqLeaving = false; });
261
+
262
+ // find someone in a long team list
263
+ document.querySelector<HTMLInputElement>('[data-evreq-find]')?.addEventListener('input', (e) => {
264
+ const needle = (e.target as HTMLInputElement).value.trim().toLowerCase();
265
+ document.querySelectorAll<HTMLElement>('.evreq-people [data-q]').forEach((row) => { row.hidden = !!needle && !(row.dataset.q ?? '').includes(needle); });
266
+ });
267
+ </script>
67
268
  </body>
68
269
  </html>
270
+
271
+ <style is:global>
272
+ /* the bar above the editor; the editor fills the rest of the screen */
273
+ body.has-evreq { height: 100vh; height: 100dvh; margin: 0; display: flex; flex-direction: column; overflow: hidden; }
274
+ body.has-evreq > astro-island { display: flex; flex-direction: column; flex: 1; min-height: 0; }
275
+ body.has-evreq .pe-shell { flex: 1; min-height: 0; height: auto; }
276
+ @media (min-width: 768px) { body.has-evreq .pe-sidebar { top: calc(52px + var(--evreq-h, 0px)); } }
277
+ @media (max-width: 767px) { body.has-evreq .pe-topbar { padding-top: 8px; } }
278
+
279
+ .evreq {
280
+ flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px;
281
+ padding: 8px 14px; background: var(--panel-2); border-bottom: 1px solid var(--line);
282
+ font-size: 13px; color: var(--text-2); max-height: 45vh; overflow-y: auto;
283
+ }
284
+ .evreq p { margin: 0; }
285
+ .evreq-flash { flex: 1 1 100%; font-weight: 600; color: var(--text); }
286
+ .evreq-flash.is-error { color: var(--red); }
287
+ .evreq-req { flex: 1 1 480px; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; }
288
+ .evreq-text { flex: 1 1 320px; min-width: 0; }
289
+ .evreq-line { display: flex; flex-wrap: wrap; gap: 2px 10px; align-items: baseline; }
290
+ .evreq-line b { color: var(--text); font-weight: 600; }
291
+ .evreq-line .evreq-state { color: var(--amber); }
292
+ .evreq-muted { color: var(--muted); }
293
+ .evreq-muted a { color: var(--accent-deep); }
294
+ .evreq .evreq-note { margin-top: 2px; color: var(--text-2); font-style: italic; overflow-wrap: anywhere; }
295
+ .evreq-acts { display: flex; align-items: center; gap: 6px; flex: none; }
296
+ .evreq-acts form { margin: 0; }
297
+ .evreq-declined { flex: 1 1 480px; min-width: 0; color: var(--muted); overflow-wrap: anywhere; }
298
+ .evreq-declined q { color: var(--text-2); }
299
+ .evreq-hosts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: auto; }
300
+ .evreq-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-right: 2px; }
301
+ .evreq-chip { font-size: 12px; font-weight: 500; color: var(--text-2); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-full); padding: 1px 9px; white-space: nowrap; }
302
+ .evreq-modal-hint { margin: 0 0 12px; }
303
+ .evreq-find { width: 100%; margin-bottom: 10px; }
304
+ .evreq-people { max-height: min(320px, 50vh); overflow-y: auto; margin: 0 -4px; padding: 2px 4px; }
305
+ .evreq-people .check-row { align-items: center; margin: 0; padding: 7px 4px; border-bottom: 1px solid var(--line); }
306
+ .evreq-people .check-row:last-child { border-bottom: 0; }
307
+ .evreq-people .check-row[hidden] { display: none; }
308
+ .evreq-people .check-row b { font-weight: 600; color: var(--text); }
309
+ .evreq-people .check-row small { color: var(--muted); font-size: 12px; margin-left: 4px; }
310
+ @media (max-width: 767px) {
311
+ .evreq { padding: calc(8px + env(safe-area-inset-top)) 12px 8px; }
312
+ .evreq-hosts { margin-left: 0; }
313
+ .evreq-acts .btn { min-height: 36px; }
314
+ }
315
+ </style>
@@ -19,13 +19,18 @@ import { siteOrigin } from '../../../../lib/seo';
19
19
  import { siteLocale } from '../../../../lib/dates';
20
20
  import { waitUntilFrom } from '../../../../lib/webhooks';
21
21
  import { invalidateRedirectCache } from '../../../../middleware';
22
- import { forbiddenUnless } from '../../../../lib/permissions/access';
22
+ import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
23
23
  import type { Permission } from '../../../../lib/permissions/catalog';
24
+ import { approveEventRequest, declineEventRequest, pendingRequests } from '../../../../lib/calendar/requests';
25
+ import { mergeEventDraft } from '../../../../lib/drafts';
26
+ import { ApiError } from '../../../../lib/api';
24
27
 
25
28
  // Schedule → List: upcoming first, grouped by the month each event next
26
29
  // happens. Subscribed calendars appear as read-only rows with a "Make an
27
30
  // editable copy" action. New events open the quick-add dialog (with its mini
28
- // month); subscribing lives in Calendar settings.
31
+ // month); subscribing lives in Calendar settings. Requests (shown while any
32
+ // wait) lists members' event requests (lib/calendar/requests.ts) for staff
33
+ // who can publish to approve or decline.
29
34
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
30
35
 
31
36
  const repo = await getRepo(Astro.locals);
@@ -67,6 +72,8 @@ if (Astro.request.method === 'POST') {
67
72
  const hookOpts = { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache };
68
73
  // create, copy and import make drafts; bulk status changes that touch live events need Publish
69
74
  let needs: Permission = 'content.edit';
75
+ // answering members' event requests needs Publish (approving puts them live)
76
+ if (op === 'approve' || op === 'decline') needs = 'content.publish';
70
77
  if (op === 'bulk') {
71
78
  const to = String(form.get('to'));
72
79
  const before = await Promise.all(form.getAll('ids').map(String).filter(Boolean).map((id) => repo.getEvent(id)));
@@ -75,6 +82,30 @@ if (Astro.request.method === 'POST') {
75
82
  const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
76
83
  if (denied) return denied;
77
84
 
85
+ if (op === 'approve' || op === 'decline') {
86
+ const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
87
+ const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
88
+ const hooks = { url: Astro.url, locals: Astro.locals };
89
+ const id = String(form.get('id') || '');
90
+ const toRequests = (notice: string) => {
91
+ const p = new URLSearchParams(Astro.url.search);
92
+ p.set('scope', 'requests');
93
+ p.set('notice', notice);
94
+ return Astro.redirect(`/admin/calendar/events?${p}`);
95
+ };
96
+ try {
97
+ const before = await repo.getEvent(id);
98
+ if (op === 'approve') {
99
+ const e = await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
100
+ return toRequests(before?.request?.kind === 'changes' ? `Approved the changes to “${e.title}”. They’re live now.` : `Approved “${e.title}”. It’s published.`);
101
+ }
102
+ const e = await declineEventRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
103
+ return toRequests(before?.request?.kind === 'changes' ? `Declined the changes to “${e.title}”.` : `Declined “${e.title}”.`);
104
+ } catch (err) {
105
+ return toRequests(err instanceof ApiError ? err.message : 'Couldn’t answer that request. Try again.');
106
+ }
107
+ }
108
+
78
109
  if (op === 'create') {
79
110
  const title = String(form.get('title') || 'Untitled event').slice(0, 300);
80
111
  const date = String(form.get('date') || '');
@@ -156,12 +187,15 @@ if (Astro.request.method === 'POST') {
156
187
  }
157
188
  }
158
189
 
159
- const [events, calendars] = await Promise.all([repo.listEvents(), repo.listCalendars()]);
190
+ const [events, calendars, requests, users, access] = await Promise.all([
191
+ repo.listEvents(), repo.listCalendars(), pendingRequests(repo), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo),
192
+ ]);
193
+ const canApprove = access?.can('content.publish') ?? false;
160
194
  const calById = new Map(calendars.map((c) => [c.id, c]));
161
195
  const nativeCals = calendars.filter((c) => c.kind === 'native');
162
196
  const feedCals = calendars.filter((c) => c.kind === 'feed');
163
197
 
164
- const scope = (['upcoming', 'past', 'drafts', 'all'] as const).find((s) => s === Astro.url.searchParams.get('scope')) ?? 'upcoming';
198
+ const scope = (['upcoming', 'past', 'drafts', 'requests', 'all'] as const).find((s) => s === Astro.url.searchParams.get('scope')) ?? 'upcoming';
165
199
  const calFilter = Astro.url.searchParams.get('cal') ?? '';
166
200
  const q = (Astro.url.searchParams.get('q') ?? '').toLowerCase();
167
201
  const notice = Astro.url.searchParams.get('notice') ?? '';
@@ -172,6 +206,7 @@ const counts = {
172
206
  upcoming: rows.filter(isUpcoming).length,
173
207
  past: rows.filter((r) => !isUpcoming(r)).length,
174
208
  drafts: rows.filter((r) => r.e.status === 'draft').length,
209
+ requests: requests.length,
175
210
  all: rows.length,
176
211
  };
177
212
  const selected = rows
@@ -179,6 +214,7 @@ const selected = rows
179
214
  if (scope === 'upcoming' && !isUpcoming(r)) return false;
180
215
  if (scope === 'past' && isUpcoming(r)) return false;
181
216
  if (scope === 'drafts' && r.e.status !== 'draft') return false;
217
+ if (scope === 'requests') return false; // listed on their own below
182
218
  if (calFilter && !r.e.calendarIds.includes(calFilter)) return false;
183
219
  if (q && !`${r.e.title} ${r.e.slug} ${r.e.location.name}`.toLowerCase().includes(q)) return false;
184
220
  return true;
@@ -203,10 +239,50 @@ const items: ContentItem[] = selected.map(({ e, o }) => {
203
239
  image: e.cover,
204
240
  viewHref: e.status === 'published' ? eventUrl(e.slug) : undefined,
205
241
  featured: e.featured,
206
- badges: e.cancelled ? ['Cancelled'] : [],
242
+ badges: [
243
+ ...(e.cancelled ? ['Cancelled'] : []),
244
+ ...(e.request?.state === 'pending' ? [e.request.kind === 'new' ? 'Needs approval' : 'Changes to review'] : []),
245
+ ],
207
246
  };
208
247
  });
209
248
 
249
+ // members' requests waiting for staff (scope=requests), oldest first; dates
250
+ // are what the event would be once approved
251
+ const nameOf = new Map(users.map((u) => [u.id, u.name || u.email]));
252
+ const cap = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);
253
+ const ago = (ts: number) => {
254
+ const s = Math.max(0, Math.floor((now - ts) / 1000));
255
+ if (s < 60) return 'just now';
256
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
257
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
258
+ return `${Math.floor(s / 86400)}d ago`;
259
+ };
260
+ const dayFmt = (() => {
261
+ try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz }); }
262
+ catch { return new Intl.DateTimeFormat('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }); }
263
+ })();
264
+ const requestRows = scope !== 'requests' ? [] : requests
265
+ .map((e) => ({ e, shown: mergeEventDraft(e), req: e.request! }))
266
+ .filter(({ shown }) => (!calFilter || shown.calendarIds.includes(calFilter)) && (!q || `${shown.title} ${shown.slug} ${shown.location.name}`.toLowerCase().includes(q)))
267
+ .map(({ e, shown, req }) => {
268
+ const o = featuredOccurrence(shown, settings, now);
269
+ const f = o ? formatOccurrence(o, tz, locale) : null;
270
+ const from = nameOf.get(req.by) ?? 'a member';
271
+ return {
272
+ id: e.id,
273
+ href: `/admin/calendar/events/${e.id}`,
274
+ title: shown.title || 'Untitled event',
275
+ kind: req.kind,
276
+ at: req.at,
277
+ live: e.status === 'published' || e.status === 'scheduled',
278
+ from,
279
+ line: `${req.kind === 'new' ? 'New event' : 'Changes'} from ${from} · ${ago(req.at)}`,
280
+ note: req.note,
281
+ day: o ? dayFmt.format(o.start) : '',
282
+ time: f ? [f.time, describeRepeat(shown.rrule, shown.start)].filter(Boolean).join(' · ') : '',
283
+ };
284
+ });
285
+
210
286
  // feed calendars: the next 60 days as read-only rows (upcoming/all scopes)
211
287
  const feedErrors: string[] = [];
212
288
  if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
@@ -255,13 +331,16 @@ const calHref = (id: string) => {
255
331
  const str = p.toString();
256
332
  return `/admin/calendar/events${str ? `?${str}` : ''}`;
257
333
  };
258
- const SCOPES = [['upcoming', 'Upcoming'], ['past', 'Past'], ['drafts', 'Drafts'], ['all', 'All']] as const;
334
+ // Requests shows while any wait (or while you're looking at it)
335
+ const SCOPES = ([['upcoming', 'Upcoming'], ['past', 'Past'], ['drafts', 'Drafts'], ['requests', 'Requests'], ['all', 'All']] as const)
336
+ .filter(([k]) => k !== 'requests' || counts.requests > 0 || scope === 'requests');
259
337
  const chev = 'M6 9l6 6 6-6';
260
338
  const tomorrow = tzParts(now + 86_400_000, tz);
261
339
  const tomorrowIso = `${tomorrow.y}-${String(tomorrow.m).padStart(2, '0')}-${String(tomorrow.d).padStart(2, '0')}`;
262
340
  const subBits = [
263
341
  `${counts.upcoming} upcoming`,
264
342
  counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? '' : 's'}` : '',
343
+ counts.requests ? `${counts.requests} request${counts.requests === 1 ? '' : 's'} waiting` : '',
265
344
  feedCals.length ? `${feedCals.length} subscribed calendar${feedCals.length === 1 ? '' : 's'}` : '',
266
345
  ].filter(Boolean);
267
346
  ---
@@ -335,7 +414,11 @@ const subBits = [
335
414
  <div class="list-tools">
336
415
  <div class="list-lead">
337
416
  <nav class="seg ev-scope" aria-label="Show">
338
- {SCOPES.map(([k, l]) => <a href={scopeHref(k)} aria-current={scope === k ? 'page' : undefined} class={scope === k ? 'on' : ''}>{l}</a>)}
417
+ {SCOPES.map(([k, l]) => (
418
+ <a href={scopeHref(k)} aria-current={scope === k ? 'page' : undefined} class={scope === k ? 'on' : ''}>
419
+ {l}{k === 'requests' && counts.requests > 0 && <span class="ev-count" aria-label={`${counts.requests} waiting`}>{counts.requests}</span>}
420
+ </a>
421
+ ))}
339
422
  </nav>
340
423
  <form method="get" class="list-search" role="search">
341
424
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
@@ -356,16 +439,86 @@ const subBits = [
356
439
  </div>
357
440
  )}
358
441
  </div>
359
- <ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
360
- {items.length === 0 && (
361
- <p class="hint" style="padding:24px 8px">
362
- {scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <button class="linklike" type="button" data-new-event>Add an event</button>
363
- </p>
442
+ {scope === 'requests' ? (
443
+ <>
444
+ {requestRows.length > 0 && (
445
+ <section class="card bk-group">
446
+ <div class="bk-dayh"><b>Waiting for approval</b><span>{requestRows.length}</span></div>
447
+ {requestRows.map((r) => (
448
+ <div class="bk-row ev-req">
449
+ <a class="bk-when" href={r.href}>{r.day || 'No date'}<small>{r.time}</small></a>
450
+ <a class="bk-who" href={r.href}>
451
+ <b>{r.title}</b>
452
+ <small>{r.line}</small>
453
+ {r.note && <small class="ev-req-note">“{r.note}”</small>}
454
+ </a>
455
+ <span class="bk-st is-pending">{r.kind === 'new' ? 'Needs approval' : 'Changes to review'}</span>
456
+ <div class="bk-acts">
457
+ <a class="btn sm ghost" href={r.href}>Open</a>
458
+ {canApprove && (
459
+ <>
460
+ <button class="btn sm ghost" type="button" data-modal={`decline-${r.id}`}>Decline…</button>
461
+ <form method="post">
462
+ <input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
463
+ <button class="btn sm" type="submit">Approve</button>
464
+ </form>
465
+ </>
466
+ )}
467
+ </div>
468
+ </div>
469
+ ))}
470
+ </section>
471
+ )}
472
+ {requestRows.length > 0 && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
473
+ {requestRows.length === 0 && (
474
+ <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>
475
+ )}
476
+ {canApprove && requestRows.map((r) => (
477
+ <Modal id={`decline-${r.id}`} title={r.kind === 'new' ? 'Decline this request' : 'Decline these changes'}>
478
+ <form method="post">
479
+ <input type="hidden" name="op" value="decline" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
480
+ <p class="hint">
481
+ {r.kind === 'changes'
482
+ ? `${cap(r.from)}’s suggested changes to “${r.title}” are dropped. The event stays as it is.`
483
+ : r.live ? `${cap(r.from)} hears that it was declined. “${r.title}” stays as it is.`
484
+ : `“${r.title}” stays a draft that ${r.from} can change and send again.`}
485
+ </p>
486
+ <label class="field"><span>Note to {r.from} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="Thanks for asking! That weekend is taken. Could it work the week after?"></textarea></label>
487
+ <div class="modal-foot">
488
+ <button class="btn ghost" type="button" data-close>Cancel</button>
489
+ <button class="btn primary" type="submit">Decline</button>
490
+ </div>
491
+ </form>
492
+ </Modal>
493
+ ))}
494
+ </>
495
+ ) : (
496
+ <>
497
+ <ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
498
+ {items.length === 0 && (
499
+ <p class="hint" style="padding:24px 8px">
500
+ {scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <button class="linklike" type="button" data-new-event>Add an event</button>
501
+ </p>
502
+ )}
503
+ </>
364
504
  )}
365
505
  </>
366
506
  )}
367
507
  </Admin>
368
508
 
509
+ <style>
510
+ /* Requests: the waiting count on its tab, and each row's note */
511
+ .ev-count { font-size: 11px; font-weight: 700; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 0 6px; min-width: 18px; text-align: center; }
512
+ .ev-req .bk-who small.ev-req-note { color: var(--text-2); font-style: italic; }
513
+ .ev-req .bk-acts { align-items: center; }
514
+ .ev-req .bk-acts form { margin: 0; }
515
+ .ev-req-empty { padding: 24px 8px; }
516
+ @media (max-width: 767px) {
517
+ .ev-req .bk-acts { grid-column: 1 / -1; justify-content: flex-end; }
518
+ .ev-req .bk-acts .btn { min-height: 36px; }
519
+ }
520
+ </style>
521
+
369
522
  <script>
370
523
  document.addEventListener('click', (e) => {
371
524
  document.querySelectorAll<HTMLDetailsElement>('details.fmenu[open]').forEach((d) => {
@@ -7,6 +7,9 @@ import { OWNER_ENV } from '../../lib/auth';
7
7
  import { validateRequestSession } from '../../lib/auth/request';
8
8
  import { getTraffic, umamiReadEnabled } from '../../lib/umami';
9
9
  import { collectionUrl } from '../../lib/content';
10
+ import { CALENDAR } from '../../lib/calendar/config';
11
+ import { pendingRequests } from '../../lib/calendar/requests';
12
+ import { accessFor } from '../../lib/permissions/access';
10
13
 
11
14
  const coreVersion = userConfig.coreVersion;
12
15
  const repo = await getRepo(Astro.locals);
@@ -21,6 +24,15 @@ const [stats, pages, posts, links, settings, nav, users] = await Promise.all([
21
24
  repo.listUsers(),
22
25
  ]);
23
26
  const traffic = await getTraffic(settings, 30);
27
+ // members' event requests waiting for someone who can approve them
28
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
29
+ const eventRequests = CALENDAR.enabled && access?.can('content.publish') ? await pendingRequests(repo) : [];
30
+ const newRequests = eventRequests.filter((e) => e.request?.kind === 'new').length;
31
+ const changeRequests = eventRequests.length - newRequests;
32
+ const requestDetail = [
33
+ newRequests ? `${newRequests} new event${newRequests === 1 ? '' : 's'}` : '',
34
+ changeRequests ? `changes to ${changeRequests} event${changeRequests === 1 ? '' : 's'}` : '',
35
+ ].filter(Boolean).join(' · ');
24
36
  const me = sessionUserId && sessionUserId !== OWNER_ENV ? users.find((u) => u.id === sessionUserId) : null;
25
37
  const firstName = me?.name.split(/\s+/)[0] ?? '';
26
38
 
@@ -155,6 +167,16 @@ const tiles = [
155
167
  </div>
156
168
  )}
157
169
 
170
+ {eventRequests.length > 0 && (
171
+ <div class="notice">
172
+ <div class="grow-text">
173
+ <b>{eventRequests.length} event request{eventRequests.length === 1 ? '' : 's'} waiting</b>
174
+ <span class="hint">{requestDetail}</span>
175
+ </div>
176
+ <a class="btn sm" href="/admin/calendar/events?scope=requests">Review</a>
177
+ </div>
178
+ )}
179
+
158
180
  <div class="kpis">
159
181
  {kpis.map((k) => (
160
182
  <div class="kpi">