@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
@@ -0,0 +1,218 @@
1
+ ---
2
+ import Layout from '../_Layout.astro';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { currentUser } from '../../../server';
5
+ import { ApiError } from '../../../lib/api';
6
+ import { readForm } from '../../../lib/forms/http';
7
+ import { MEMBERS, memberEventsPath, hostedEvents, withdrawEventRequest, dismissEventRequest, setHostedOccurrence } from '../../../lib/calendar/requests';
8
+ import { eventUrl } from '../../../lib/calendar/config';
9
+ import { eventIsPublic, eventTimeZone, expandEvents, featuredOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
10
+ import { describeRepeat } from '../../../lib/calendar/rrule';
11
+ import { requestLabel } from '../../../lib/calendar/member-form';
12
+ import { mergeEventDraft } from '../../../lib/drafts';
13
+ import { siteLocale } from '../../../lib/dates';
14
+ import type { CalEvent } from '../../../lib/schema';
15
+ import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
16
+
17
+ // "My events": the person's requests, the events they host (each upcoming
18
+ // date with a Cancel / Restore control) and their past events. The rules
19
+ // live in lib/calendar/requests.ts; this page only shows them and posts.
20
+ // A site replaces it with its own file at the same path.
21
+ if (!MEMBERS.enabled) return notFound();
22
+ const user = await currentUser(Astro);
23
+ if (!user) return signInRedirect(Astro.url);
24
+ keepPrivate(Astro.response.headers);
25
+ const repo = await getRepo(Astro.locals);
26
+ const home = memberEventsPath();
27
+ const hooks = { url: Astro.url, locals: Astro.locals };
28
+
29
+ let error = '';
30
+ if (Astro.request.method === 'POST') {
31
+ try {
32
+ const form = await readForm(Astro.request);
33
+ const op = String(form.get('op') ?? '');
34
+ const id = String(form.get('id') ?? '');
35
+ if (op === 'withdraw') {
36
+ await withdrawEventRequest(repo, user, id, hooks);
37
+ return redirectTo(`${home}?withdrawn=1`);
38
+ }
39
+ if (op === 'dismiss') {
40
+ await dismissEventRequest(repo, user, id);
41
+ return redirectTo(home);
42
+ }
43
+ if (op === 'cancel-date' || op === 'restore-date') {
44
+ await setHostedOccurrence(repo, user, id, String(form.get('key') ?? ''), op === 'cancel-date', hooks);
45
+ return redirectTo(`${home}?${op === 'cancel-date' ? 'cancelled' : 'restored'}=1`);
46
+ }
47
+ throw new ApiError(400, 'That action isn’t available here.');
48
+ } catch (err) {
49
+ const f = failure(err);
50
+ error = f.message;
51
+ Astro.response.status = f.status;
52
+ }
53
+ }
54
+
55
+ const FLASH: [string, string][] = [
56
+ ['sent', 'Thanks! Your request is with the team. You’ll hear back once they’ve looked at it.'],
57
+ ['updated', 'Your request is updated and waiting for approval.'],
58
+ ['changes', 'Your changes are with the team. The event stays as it is until they approve them.'],
59
+ ['withdrawn', 'Request withdrawn.'],
60
+ ['cancelled', 'That date is cancelled. The calendar shows it as cancelled.'],
61
+ ['restored', 'That date is back on.'],
62
+ ];
63
+ const flash = error ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
64
+
65
+ const DAY = 86_400_000;
66
+ const now = Date.now();
67
+ const [settings, calendars, mine] = await Promise.all([repo.getSettings(), repo.listCalendars(), hostedEvents(repo, user.id)]);
68
+ const calById = new Map(calendars.map((c) => [c.id, c]));
69
+ const siteTz = settings.timezone || 'UTC';
70
+ const locale = siteLocale(settings);
71
+ const canRequest = user.can('events.request');
72
+ const isLive = (e: CalEvent) => e.status === 'published' || e.status === 'scheduled';
73
+
74
+ const format = (opts: Intl.DateTimeFormatOptions, tz: string) => {
75
+ try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }); }
76
+ catch { return new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }); }
77
+ };
78
+ /** "Tue, Oct 21 · 7:00 PM – 9:00 PM", or "Tue, Oct 21 · All day" */
79
+ const when = (o: Occurrence) => {
80
+ const tz = o.allDay ? siteTz : eventTimeZone(o.event, settings);
81
+ const f = formatOccurrence(o, tz, locale);
82
+ return o.allDay ? f.range : `${f.range} – ${format({ hour: 'numeric', minute: '2-digit' }, tz).format(o.end)}`;
83
+ };
84
+ const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
85
+ const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
86
+
87
+ // requests: waiting ones first, newest first; shown as they'd be once approved
88
+ const requests = mine
89
+ .filter((e) => e.request)
90
+ .sort((a, b) => Number(b.request!.state === 'pending') - Number(a.request!.state === 'pending') || b.request!.at - a.request!.at)
91
+ .map((e) => {
92
+ // the host's own waiting changes, never staff's unpublished edits
93
+ const shown = e.request!.kind === 'changes' && e.request!.state === 'pending' ? mergeEventDraft(e) : e;
94
+ const o = featuredOccurrence(shown, settings, now);
95
+ return { e, r: e.request!, title: shown.title, line: meta(o ? when(o) : '', describeRepeat(shown.rrule, shown.start), shown.location.name) };
96
+ });
97
+
98
+ // live events they host: the next 8 dates, or (all dates over) the last one
99
+ const win = { displayTz: siteTz, now, windowStartMs: now, windowEndMs: now + 366 * DAY };
100
+ const upcoming: { e: CalEvent; dates: Occurrence[] }[] = [];
101
+ const past: { e: CalEvent; last: Occurrence | null }[] = [];
102
+ for (const e of mine) {
103
+ if (!isLive(e) || !e.hostIds.includes(user.id)) continue;
104
+ const dates = expandEvents([e], settings, win).filter((o) => o.end > now).slice(0, 8);
105
+ if (!dates.length && e.startAt > now) {
106
+ const next = featuredOccurrence(e, settings, now);
107
+ if (next) dates.push(next);
108
+ }
109
+ if (dates.length) upcoming.push({ e, dates });
110
+ else past.push({ e, last: featuredOccurrence(e, settings, now) });
111
+ }
112
+ upcoming.sort((a, b) => a.dates[0].start - b.dates[0].start);
113
+ past.sort((a, b) => (b.last?.start ?? b.e.startAt) - (a.last?.start ?? a.e.startAt));
114
+ const pastShown = past.slice(0, 10);
115
+ ---
116
+
117
+ <Layout title="My events" user={user}>
118
+ <div class="pc-mem-head">
119
+ <h1>My events</h1>
120
+ {canRequest && <a class="pc-mem-btn is-primary" href={`${home}/new`}>Request an event</a>}
121
+ </div>
122
+ {flash && <p class="pc-mem-flash" role="status">{flash}</p>}
123
+ {error && <p class="pc-bk-error" role="alert">{error}</p>}
124
+
125
+ {requests.length > 0 && (
126
+ <section aria-labelledby="pc-mem-requests">
127
+ <h2 id="pc-mem-requests">Requests</h2>
128
+ <ul class="pc-mem-list">
129
+ {requests.map(({ e, r, title, line }) => (
130
+ <li class="pc-mem-card">
131
+ <p class={`pc-mem-state is-${r.state}`}>{requestLabel(r)}</p>
132
+ <h3>{title}</h3>
133
+ {line && <p class="pc-mem-meta">{line}</p>}
134
+ <p class="pc-mem-meta">Sent {day(r.at)}</p>
135
+ {r.state === 'declined' && r.reply && <p class="pc-mem-reply"><strong>The team said:</strong> {r.reply}</p>}
136
+ <div class="pc-mem-actions">
137
+ {canRequest && <a class="pc-mem-btn is-small" href={`${home}/${e.id}`}>{r.kind === 'changes' && r.state === 'declined' ? 'Suggest changes again' : 'Edit'}</a>}
138
+ {r.kind === 'changes' && r.state === 'declined' ? (
139
+ <form method="post">
140
+ <input type="hidden" name="op" value="dismiss" />
141
+ <input type="hidden" name="id" value={e.id} />
142
+ <button class="pc-mem-btn is-small" type="submit">Dismiss</button>
143
+ </form>
144
+ ) : canRequest && (
145
+ <form method="post" onsubmit={r.kind === 'new' ? "return confirm('Withdraw this request?')" : "return confirm('Withdraw these changes?')"}>
146
+ <input type="hidden" name="op" value="withdraw" />
147
+ <input type="hidden" name="id" value={e.id} />
148
+ <button class="pc-mem-btn is-small is-danger" type="submit">Withdraw</button>
149
+ </form>
150
+ )}
151
+ </div>
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ </section>
156
+ )}
157
+
158
+ <section aria-labelledby="pc-mem-upcoming">
159
+ <h2 id="pc-mem-upcoming">Upcoming</h2>
160
+ {upcoming.length === 0 ? (
161
+ <p class="pc-mem-empty">
162
+ You’re not hosting any upcoming events.
163
+ {canRequest && requests.length === 0 && <> <a href={`${home}/new`}>Request one</a> and the team will take a look.</>}
164
+ </p>
165
+ ) : (
166
+ <ul class="pc-mem-list">
167
+ {upcoming.map(({ e, dates }) => (
168
+ <li class="pc-mem-card">
169
+ {e.cancelled ? <p class="pc-mem-state is-declined">Cancelled</p>
170
+ : e.status === 'scheduled' && <p class="pc-mem-state is-pending">{e.scheduledAt ? `Goes live ${day(e.scheduledAt)}` : 'Scheduled'}</p>}
171
+ <h3>{e.title}</h3>
172
+ {meta(describeRepeat(e.rrule, e.start), e.location.name) && <p class="pc-mem-meta">{meta(describeRepeat(e.rrule, e.start), e.location.name)}</p>}
173
+ <ul class="pc-mem-dates">
174
+ {dates.map((o) => {
175
+ const off = e.cancelledDates.includes(o.key);
176
+ return (
177
+ <li class={o.cancelled ? 'is-cancelled' : undefined}>
178
+ <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}</span>
179
+ {e.rrule && !e.cancelled && o.start > now && (
180
+ <form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
181
+ <input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
182
+ <input type="hidden" name="id" value={e.id} />
183
+ <input type="hidden" name="key" value={o.key} />
184
+ <button class={`pc-mem-btn is-small${off ? '' : ' is-danger'}`} type="submit" aria-label={`${off ? 'Restore' : 'Cancel'} ${when(o)}`}>{off ? 'Restore' : 'Cancel'}</button>
185
+ </form>
186
+ )}
187
+ </li>
188
+ );
189
+ })}
190
+ </ul>
191
+ <div class="pc-mem-actions">
192
+ {canRequest && <a class="pc-mem-btn is-small" href={`${home}/${e.id}`}>Suggest changes</a>}
193
+ {eventIsPublic(e, calById) && <a class="pc-mem-btn is-small" href={eventUrl(e.slug)}>View</a>}
194
+ </div>
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ )}
199
+ </section>
200
+
201
+ {past.length > 0 && (
202
+ <section aria-label="Past events">
203
+ <details class="pc-mem-past">
204
+ <summary>Past events ({past.length})</summary>
205
+ <ul class="pc-mem-list">
206
+ {pastShown.map(({ e, last }) => (
207
+ <li class="pc-mem-card">
208
+ <h3>{e.title}</h3>
209
+ {last && <p class="pc-mem-meta">{when(last)}</p>}
210
+ {eventIsPublic(e, calById) && <div class="pc-mem-actions"><a class="pc-mem-btn is-small" href={eventUrl(e.slug)}>View</a></div>}
211
+ </li>
212
+ ))}
213
+ </ul>
214
+ {past.length > pastShown.length && <p class="pc-mem-hint">Showing the {pastShown.length} most recent.</p>}
215
+ </details>
216
+ </section>
217
+ )}
218
+ </Layout>
@@ -0,0 +1,48 @@
1
+ ---
2
+ import Layout from '../_Layout.astro';
3
+ import EventForm from '../_EventForm.astro';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { currentUser } from '../../../server';
6
+ import { readForm } from '../../../lib/forms/http';
7
+ import { MEMBERS, memberEventsPath, submitEventRequest } from '../../../lib/calendar/requests';
8
+ import { blankEventForm, eventRequestInput, memberEventFields, readEventForm } from '../../../lib/calendar/member-form';
9
+ import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
10
+
11
+ // "Request an event": a draft the member hosts, waiting for the team's
12
+ // approval (submitEventRequest checks who may ask and what they sent).
13
+ if (!MEMBERS.enabled) return notFound();
14
+ const user = await currentUser(Astro);
15
+ if (!user) return signInRedirect(Astro.url);
16
+ keepPrivate(Astro.response.headers);
17
+ const repo = await getRepo(Astro.locals);
18
+ const home = memberEventsPath();
19
+ const fields = memberEventFields();
20
+ const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native' && c.visibility === 'public');
21
+
22
+ let values = blankEventForm(fields);
23
+ let error = '';
24
+ if (Astro.request.method === 'POST') {
25
+ try {
26
+ const form = await readForm(Astro.request);
27
+ values = readEventForm(form, fields);
28
+ await submitEventRequest(repo, user, eventRequestInput(values), { url: Astro.url, locals: Astro.locals });
29
+ return redirectTo(`${home}?sent=1`);
30
+ } catch (err) {
31
+ const f = failure(err);
32
+ error = f.message;
33
+ Astro.response.status = f.status;
34
+ }
35
+ }
36
+ const canRequest = user.can('events.request');
37
+ ---
38
+
39
+ <Layout title="Request an event" user={user}>
40
+ <div>
41
+ <a class="pc-mem-back" href={home}>← My events</a>
42
+ <div class="pc-mem-head"><h1>Request an event</h1></div>
43
+ <p class="pc-mem-lede">Tell the team what you’d like to host. It goes on the calendar once they approve it.</p>
44
+ </div>
45
+ {canRequest
46
+ ? <EventForm values={values} calendars={calendars} fields={fields} op="submit" submitLabel="Submit for approval" error={error} />
47
+ : <p class="pc-bk-error" role="alert">Your role doesn’t include “Request events”. Ask the site’s team if you need it.</p>}
48
+ </Layout>
@@ -0,0 +1,6 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { MEMBERS, memberEventsPath } from '../../lib/calendar/requests';
3
+ import { notFound, redirectTo } from './_http';
4
+
5
+ // acms({ members }).base: "My events" is the only member page so far.
6
+ export const GET: APIRoute = () => (MEMBERS.enabled ? redirectTo(memberEventsPath(), 302) : notFound());
@@ -2,11 +2,12 @@
2
2
  // endpoints and actions in the site's own src/) uses to read events, find
3
3
  // the signed-in person and cancel single dates. It wraps the internals so
4
4
  // they can change without breaking sites; everything here is a public
5
- // contract (see docs/server.md, `acms docs server`). Nothing in this module
6
- // checks permissions on writes: callers decide who may change what.
5
+ // contract (see docs/server.md, `acms docs server`). The event and date
6
+ // functions don't check permissions (callers decide who may change what); the
7
+ // event-request functions take the person acting and check them themselves.
7
8
  import type { AstroCookies } from 'astro';
8
9
  import type { ContentRepository } from '../lib/repo/types';
9
- import type { CalEvent, Role } from '../lib/schema';
10
+ import type { CalEvent, CoreRole } from '../lib/schema';
10
11
  import type { Occurrence } from '../lib/calendar/events';
11
12
 
12
13
  /** A handle to the site's content store. Pass it to the functions below. */
@@ -14,23 +15,35 @@ export interface Repo {
14
15
  readonly __acmsRepo: true;
15
16
  }
16
17
 
18
+ /** A core role, or a key from the site's acms({ roles }) (e.g. 'host'). */
19
+ export type Role = CoreRole | (string & {});
20
+
17
21
  /** The signed-in person (null when signed out or removed). */
18
22
  export interface ServerUser {
19
23
  id: string;
20
24
  email: string;
21
25
  name: string;
22
26
  role: Role;
23
- /** whether their role grants a permission key (e.g. 'content.edit'); unknown keys are false */
27
+ /** whether they may use the admin: core roles, and site roles with `admin: true` */
28
+ staff: boolean;
29
+ /**
30
+ * whether their role grants a permission key: a core one ('content.edit') or
31
+ * one from acms({ permissions }) ('classes.manageAll'). Unknown keys are
32
+ * false; a role without admin access only ever holds site permissions.
33
+ */
24
34
  can(permission: string): boolean;
25
35
  }
26
36
 
27
37
  export type { CalEvent, Occurrence };
38
+ export type { EventRequestInput } from '../lib/calendar/requests';
28
39
 
29
40
  export interface ListEventsOptions {
30
41
  /** only published events (default: every event except archived ones) */
31
42
  publishedOnly?: boolean;
32
43
  /** only events in this calendar (id or slug) */
33
44
  calendar?: string;
45
+ /** only events this Team member hosts (CalEvent.hostIds) */
46
+ hostedBy?: string;
34
47
  }
35
48
 
36
49
  export interface OccurrenceWindow {
@@ -57,27 +70,31 @@ export async function getRepo(locals: unknown): Promise<Repo> {
57
70
  }
58
71
 
59
72
  /**
60
- * The person signed in to the admin on this request, on any page (public
61
- * pages included), or null. With Clerk sign-in, a signed-in browser carries
62
- * the `__session` cookie and is resolved on every page except /media,
63
- * /embed and /api/v1.
73
+ * The person signed in on this request, on any page (public pages included),
74
+ * or null: staff, and people in the site's own roles (acms({ roles })) who
75
+ * can't use the admin. With Clerk sign-in, a signed-in browser carries the
76
+ * `__session` cookie and is resolved on every page except /media, /embed and
77
+ * /api/v1.
64
78
  */
65
79
  export async function currentUser(context: { request: Request; cookies: AstroCookies | { get(name: string): { value: string } | undefined }; locals: unknown }): Promise<ServerUser | null> {
66
- const [{ getRequestAuth }, { accessFor }, { isPermission }, { OWNER_ENV }, { getRepo: load }] = await Promise.all([
67
- import('../lib/auth/request'), import('../lib/permissions/access'), import('../lib/permissions/catalog'), import('../lib/auth'), import('../lib/repo'),
80
+ const [{ getRequestAuth }, { grantsFor }, { allows }, { isStaffRole }, { OWNER_ENV }, { getRepo: load }] = await Promise.all([
81
+ import('../lib/auth/request'), import('../lib/permissions/access'), import('../lib/permissions/catalog'), import('../lib/roles'), import('../lib/auth'), import('../lib/repo'),
68
82
  ]);
69
83
  // with locals first, so Clerk reads this deployment's keys (cached per request)
70
- await getRequestAuth(context.request, context.cookies, context.locals);
84
+ const auth = await getRequestAuth(context.request, context.cookies, context.locals);
85
+ if (!auth.person) return null;
71
86
  const repo = await load(context.locals);
72
- const access = await accessFor(context.request, context.cookies, repo);
73
- if (!access) return null;
74
- const user = access.userId === OWNER_ENV ? null : await repo.getUser(access.userId);
87
+ const user = auth.person.id === OWNER_ENV ? null : await repo.getUser(auth.person.id);
88
+ if (auth.person.id !== OWNER_ENV && !user) return null;
89
+ const role = user?.role ?? 'owner';
90
+ const grants = await grantsFor(repo, context.request);
75
91
  return {
76
- id: access.userId,
92
+ id: auth.person.id,
77
93
  email: user?.email ?? '',
78
- name: access.name,
79
- role: access.role,
80
- can: (permission: string) => isPermission(permission) && access.can(permission),
94
+ name: user?.name ?? 'Owner',
95
+ role,
96
+ staff: isStaffRole(role),
97
+ can: (permission: string) => allows(grants, role, permission),
81
98
  };
82
99
  }
83
100
 
@@ -90,6 +107,7 @@ export async function listEvents(repo: Repo, opts: ListEventsOptions = {}): Prom
90
107
  if (!cal) return [];
91
108
  events = events.filter((e) => e.calendarIds.includes(cal.id));
92
109
  }
110
+ if (opts.hostedBy) events = events.filter((e) => e.hostIds.includes(opts.hostedBy!));
93
111
  return events;
94
112
  }
95
113
 
@@ -139,3 +157,63 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
139
157
  const { restoreOccurrence: restore } = await import('../lib/calendar/schedule');
140
158
  return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
141
159
  }
160
+
161
+ // ---- members' event requests ------------------------------------------
162
+ // The rules behind the built-in "My events" pages (acms({ members })), for a
163
+ // site that builds its own. Each takes the person acting (currentUser()) and
164
+ // checks them itself: members need "Request events" and only touch events
165
+ // they host; approving and declining need "Publish content". Errors carry a
166
+ // `status` (400, 401, 403, 404, 409, 429) and a message safe to show people.
167
+
168
+ type Who = Pick<ServerUser, 'id' | 'can'> | null;
169
+ const requests = () => import('../lib/calendar/requests');
170
+
171
+ /** The events this person hosts plus their own requests (pending or declined), soonest first. */
172
+ export async function listMyEvents(repo: Repo, who: Who): Promise<CalEvent[]> {
173
+ if (!who) return [];
174
+ return (await requests()).hostedEvents(internal(repo), who.id);
175
+ }
176
+
177
+ /** Requests waiting for staff (new events and suggested changes), oldest first; for people who can approve them ("Publish content"). */
178
+ export async function listEventRequests(repo: Repo, who: Who): Promise<CalEvent[]> {
179
+ if (!who) throw Object.assign(new Error('Sign in to continue.'), { status: 401 });
180
+ if (!who.can('content.publish')) throw Object.assign(new Error('Your role doesn’t include “Publish content”, which approving requests needs.'), { status: 403 });
181
+ return (await requests()).pendingRequests(internal(repo));
182
+ }
183
+
184
+ /** Ask for a new event: a draft the person hosts, waiting for approval. Staff are emailed. */
185
+ export async function submitEventRequest(repo: Repo, who: Who, input: import('../lib/calendar/requests').EventRequestInput, context: WriteContext): Promise<CalEvent> {
186
+ return (await requests()).submitEventRequest(internal(repo), who, input, context);
187
+ }
188
+
189
+ /**
190
+ * Edit a request, or suggest changes to a live event the person hosts. A live
191
+ * event keeps showing what it shows until staff approve the changes.
192
+ */
193
+ export async function updateEventRequest(repo: Repo, who: Who, eventId: string, input: import('../lib/calendar/requests').EventRequestInput, context: WriteContext): Promise<CalEvent> {
194
+ return (await requests()).updateEventRequest(internal(repo), who, eventId, input, context);
195
+ }
196
+
197
+ /** Take a request back: a new event that never went live is deleted; suggested changes are dropped. */
198
+ export async function withdrawEventRequest(repo: Repo, who: Who, eventId: string, context: WriteContext): Promise<{ deleted: boolean }> {
199
+ return (await requests()).withdrawEventRequest(internal(repo), who, eventId, context);
200
+ }
201
+
202
+ /**
203
+ * Staff: approve (a new event is published; suggested changes go live). The
204
+ * requester is emailed. Pass `seen`, the `request.at` of the version you showed,
205
+ * and it answers 409 if the member changed the request since.
206
+ */
207
+ export async function approveEventRequest(repo: Repo, who: Who, eventId: string, context: WriteContext, seen?: number): Promise<CalEvent> {
208
+ return (await requests()).approveEventRequest(internal(repo), who, eventId, context, seen);
209
+ }
210
+
211
+ /** Staff: decline with a note for the requester (emailed). A new request stays a draft they can edit and resend. */
212
+ export async function declineEventRequest(repo: Repo, who: Who, eventId: string, reply: string, context: WriteContext, seen?: number): Promise<CalEvent> {
213
+ return (await requests()).declineEventRequest(internal(repo), who, eventId, reply, context, seen);
214
+ }
215
+
216
+ /** A host cancels (or restores) one date of an event they host, at once. Same rules as cancelOccurrence. */
217
+ export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string, occurrenceKey: string, cancelled: boolean, context: WriteContext): Promise<CalEvent> {
218
+ return (await requests()).setHostedOccurrence(internal(repo), who, eventId, occurrenceKey, cancelled, context);
219
+ }
package/src/virtual.d.ts CHANGED
@@ -19,6 +19,10 @@ declare module 'virtual:acms-config' {
19
19
  // resolved by integration.mjs; '' = pick from the environment (lib/maps/config.ts)
20
20
  maps?: { provider: '' | 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias' };
21
21
  legal?: { enabled: boolean; operatorName: string; contactEmail: string; updatedAt: string };
22
+ // resolved by integration.mjs (lib/roles.ts and lib/permissions/catalog.ts read them)
23
+ roles?: { key: string; label: string; summary: string; admin: boolean; home: string }[];
24
+ permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
25
+ members?: { enabled: boolean; base: string; notify: string[] };
22
26
  // resolved by integration.mjs (lib/calendar/config.ts documents the shape)
23
27
  calendar?: {
24
28
  enabled: boolean;