@acms-subir/core 0.3.21 → 0.3.23
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.
- package/CHANGELOG.md +24 -0
- package/cli/migrations.generated.mjs +6 -0
- package/docs/mobile.md +1 -1
- package/docs/sections.md +2 -2
- package/docs/server.md +210 -29
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +134 -1
- package/package.json +1 -1
- package/src/actions/index.ts +3 -1
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/RolesManager.tsx +15 -7
- package/src/components/calendar/CalendarBoard.tsx +5 -3
- package/src/components/sections/SectionFields.tsx +31 -10
- package/src/layouts/Admin.astro +2 -2
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/request.ts +19 -7
- package/src/lib/auth/return.ts +32 -0
- package/src/lib/auth.ts +17 -4
- package/src/lib/calendar/board.ts +12 -1
- package/src/lib/calendar/cards.ts +6 -4
- package/src/lib/calendar/member-form.ts +152 -0
- package/src/lib/calendar/requests.ts +284 -0
- package/src/lib/calendar/schedule.ts +1 -1
- package/src/lib/editor/auth.ts +5 -3
- package/src/lib/editor/schemas.ts +2 -1
- package/src/lib/editor/store.ts +5 -3
- package/src/lib/email/service.ts +16 -3
- package/src/lib/email/store.ts +3 -1
- package/src/lib/email/templates.ts +12 -0
- package/src/lib/mobile/calendar.ts +6 -2
- package/src/lib/permissions/catalog.ts +84 -36
- package/src/lib/permissions/service.ts +11 -8
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/repo/sql.ts +23 -5
- package/src/lib/repo/types.ts +5 -1
- package/src/lib/roles.ts +46 -4
- package/src/lib/schema.ts +43 -2
- package/src/lib/section-targets.ts +10 -0
- package/src/lib/sections.ts +7 -5
- package/src/lib/sync/catalog.ts +3 -0
- package/src/middleware.ts +8 -1
- package/src/pages/admin/app-auth.astro +5 -3
- package/src/pages/admin/calendar/events/[id].astro +249 -2
- package/src/pages/admin/calendar/events/index.astro +165 -12
- package/src/pages/admin/index.astro +22 -0
- package/src/pages/admin/login.astro +37 -16
- package/src/pages/admin/roles.astro +3 -0
- package/src/pages/admin/users.astro +15 -14
- package/src/pages/api/calendar/events/index.ts +8 -1
- package/src/pages/api/search.ts +3 -1
- package/src/pages/author/[slug].astro +3 -1
- package/src/pages/members/_EventForm.astro +89 -0
- package/src/pages/members/_Layout.astro +76 -0
- package/src/pages/members/_http.ts +25 -0
- package/src/pages/members/events/[id].astro +86 -0
- package/src/pages/members/events/index.astro +218 -0
- package/src/pages/members/events/new.astro +48 -0
- package/src/pages/members/index.ts +6 -0
- package/src/server/index.ts +111 -20
- package/src/theme-sdk/calendar.ts +6 -2
- package/src/theme-sdk/fields.mjs +7 -2
- package/src/virtual.d.ts +4 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { CalEvent, EventRequest } from '../schema';
|
|
4
|
+
import { EventLocation } from '../schema';
|
|
5
|
+
import { ApiError } from '../api';
|
|
6
|
+
import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
|
|
7
|
+
import { CALENDAR } from './config';
|
|
8
|
+
import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
|
|
9
|
+
import { eventDeleted } from './hooks';
|
|
10
|
+
import { waitUntilFrom } from '../webhooks';
|
|
11
|
+
import { kickEmail } from '../email/service';
|
|
12
|
+
|
|
13
|
+
// Members' event requests: the rules behind "My events". A member whose role
|
|
14
|
+
// holds "Request events" (events.request) asks for a new event (a draft that
|
|
15
|
+
// waits for approval) or suggests changes to an event they host (staged on
|
|
16
|
+
// the live event, which stays as it is). Staff who can "Publish content"
|
|
17
|
+
// approve or decline. Hosts may always cancel or restore single dates of
|
|
18
|
+
// their own events, which applies at once like it does for staff.
|
|
19
|
+
//
|
|
20
|
+
// Everything here checks who is acting, so the built-in member pages and a
|
|
21
|
+
// site's own pages (through @acms-subir/core/server) share the same rules.
|
|
22
|
+
|
|
23
|
+
/** where the built-in member pages live (acms({ members })); `notify` hears about new requests */
|
|
24
|
+
export const MEMBERS: { enabled: boolean; base: string; notify: string[] } = { enabled: false, base: '/members', notify: [], ...(userConfig.members ?? {}) };
|
|
25
|
+
export const memberEventsPath = () => `${MEMBERS.base}/events`;
|
|
26
|
+
|
|
27
|
+
/** who is acting: currentUser() from @acms-subir/core/server fits */
|
|
28
|
+
export interface Actor { id: string; can(permission: string): boolean }
|
|
29
|
+
|
|
30
|
+
/** what a member may fill in; staff can change anything else in the admin */
|
|
31
|
+
export interface EventRequestInput {
|
|
32
|
+
title: string;
|
|
33
|
+
/** plain text; blank lines start new paragraphs */
|
|
34
|
+
description?: string;
|
|
35
|
+
/** YYYY-MM-DDTHH:mm (or YYYY-MM-DD when allDay), in the site's time zone */
|
|
36
|
+
start: string;
|
|
37
|
+
end?: string;
|
|
38
|
+
allDay?: boolean;
|
|
39
|
+
/** '' (once), or FREQ=DAILY|WEEKLY|MONTHLY with an optional INTERVAL and COUNT */
|
|
40
|
+
repeat?: string;
|
|
41
|
+
/** where it happens, as text */
|
|
42
|
+
where?: string;
|
|
43
|
+
/** one of the site's own calendars */
|
|
44
|
+
calendarId?: string;
|
|
45
|
+
/** the site's custom event fields (private ones are staff-only and ignored) */
|
|
46
|
+
fields?: Record<string, string>;
|
|
47
|
+
/** a message for staff; never public */
|
|
48
|
+
note?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const LIMITS = { title: 200, description: 5000, where: 300, note: 1000, field: 500, pending: 20 };
|
|
52
|
+
const REPEAT = /^FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=[1-9]\d?)?(;COUNT=[1-9]\d{0,2})?$/;
|
|
53
|
+
const live = (e: CalEvent) => e.status === 'published' || e.status === 'scheduled';
|
|
54
|
+
const text = (v: unknown, max: number) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, '').trim().slice(0, max);
|
|
55
|
+
/** one line: titles and places end up in email subjects and headings */
|
|
56
|
+
const line = (v: unknown, max: number) => text(v, max).replace(/\s+/g, ' ');
|
|
57
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
58
|
+
|
|
59
|
+
/** plain text → the editor's document and its HTML (escaped: members are the public) */
|
|
60
|
+
export function textToBody(value: string): { bodyJson: string; bodyHtml: string } {
|
|
61
|
+
const paras = value.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
|
62
|
+
if (!paras.length) return { bodyJson: '', bodyHtml: '' };
|
|
63
|
+
const content = paras.map((p) => {
|
|
64
|
+
const lines = p.split('\n');
|
|
65
|
+
return { type: 'paragraph', content: lines.flatMap((l, i) => [...(i ? [{ type: 'hardBreak' }] : []), ...(l ? [{ type: 'text', text: l }] : [])]) };
|
|
66
|
+
});
|
|
67
|
+
return { bodyJson: JSON.stringify({ type: 'doc', content }), bodyHtml: paras.map((p) => `<p>${p.split('\n').map(esc).join('<br>')}</p>`).join('') };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** the description a member typed back from an event (for editing a request) */
|
|
71
|
+
export function bodyToText(e: Pick<CalEvent, 'bodyHtml'>): string {
|
|
72
|
+
return e.bodyHtml
|
|
73
|
+
.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>\s*<p[^>]*>/gi, '\n\n').replace(/<[^>]+>/g, '')
|
|
74
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&').trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requireMember(who: Actor | null): asserts who is Actor {
|
|
78
|
+
if (!who) throw new ApiError(401, 'Sign in to continue.');
|
|
79
|
+
if (!who.can('events.request')) throw new ApiError(403, 'Your role doesn’t include “Request events”. Ask the site’s team if you need it.');
|
|
80
|
+
}
|
|
81
|
+
function requireApprover(who: Actor | null): asserts who is Actor {
|
|
82
|
+
if (!who) throw new ApiError(401, 'Sign in to continue.');
|
|
83
|
+
if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which approving requests needs.');
|
|
84
|
+
}
|
|
85
|
+
/** a host of this event, or the member who asked for it */
|
|
86
|
+
export const isHost = (e: CalEvent, userId: string) => e.hostIds.includes(userId) || (e.request?.by === userId && e.request.kind === 'new');
|
|
87
|
+
async function hostedEvent(repo: ContentRepository, who: Actor, id: string) {
|
|
88
|
+
const e = await repo.getEvent(id);
|
|
89
|
+
if (!e || e.status === 'archived' || !isHost(e, who.id)) throw new ApiError(404, 'Event not found.');
|
|
90
|
+
return e;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** the request form's input → event fields, validated (`current`: the event being edited, whose own values may be kept) */
|
|
94
|
+
async function clean(repo: ContentRepository, input: EventRequestInput, current?: CalEvent) {
|
|
95
|
+
const title = line(input.title, LIMITS.title);
|
|
96
|
+
if (!title) throw new ApiError(400, 'Give the event a title.');
|
|
97
|
+
const start = text(input.start, 16), allDay = input.allDay === true;
|
|
98
|
+
if (!(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(start)) throw new ApiError(400, 'Pick a date and start time.');
|
|
99
|
+
const end = text(input.end, 16);
|
|
100
|
+
if (end && !(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(end)) throw new ApiError(400, 'Pick a valid end time.');
|
|
101
|
+
if (end && end < start) throw new ApiError(400, 'The end has to come after the start.');
|
|
102
|
+
const rrule = text(input.repeat, 80).toUpperCase();
|
|
103
|
+
// a pattern staff set (weekly on two days, yearly…) may be kept as it is
|
|
104
|
+
if (rrule && !REPEAT.test(rrule) && rrule !== current?.rrule.toUpperCase()) throw new ApiError(400, 'Choose how often it repeats from the list.');
|
|
105
|
+
// members pick among the public calendars; any other calendars staff put it in stay
|
|
106
|
+
const calendars = await repo.listCalendars();
|
|
107
|
+
const open = new Set(calendars.filter((c) => c.kind === 'native' && c.visibility === 'public').map((c) => c.id));
|
|
108
|
+
const chosen = input.calendarId ? String(input.calendarId) : '';
|
|
109
|
+
if (chosen && !open.has(chosen) && !current?.calendarIds.includes(chosen)) throw new ApiError(400, 'Choose a calendar from the list.');
|
|
110
|
+
const calendarIds = current && (!chosen || current.calendarIds.includes(chosen))
|
|
111
|
+
? current.calendarIds
|
|
112
|
+
: [...(current?.calendarIds ?? []).filter((id) => !open.has(id)), ...(chosen ? [chosen] : [])];
|
|
113
|
+
const fields: Record<string, string> = {};
|
|
114
|
+
for (const f of CALENDAR.eventFields) {
|
|
115
|
+
if (f.private || input.fields?.[f.key] === undefined) continue;
|
|
116
|
+
const value = text(input.fields[f.key], LIMITS.field);
|
|
117
|
+
if (f.type === 'select' && value && !f.options.includes(value)) throw new ApiError(400, `Choose ${f.label} from the list.`);
|
|
118
|
+
fields[f.key] = value; // '' clears it
|
|
119
|
+
}
|
|
120
|
+
return { title, start, end: end || undefined, allDay, rrule, calendarIds, fields, description: text(input.description, LIMITS.description), where: line(input.where, LIMITS.where) };
|
|
121
|
+
}
|
|
122
|
+
const filled = (fields: Record<string, string>) => Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== ''));
|
|
123
|
+
|
|
124
|
+
const hooksFor = (h: SaveHooks): SaveHooks => ({ url: h.url, locals: h.locals });
|
|
125
|
+
const pending = (by: string, kind: EventRequest['kind'], note: string, created: boolean): EventRequest =>
|
|
126
|
+
({ kind, state: 'pending', by, at: Date.now(), note, reply: '', reviewedBy: '', reviewedAt: 0, created });
|
|
127
|
+
/** a host's own suggestion waiting on this live event (its staged draft is theirs) */
|
|
128
|
+
const hostChanges = (e: CalEvent) => e.request?.kind === 'changes' && e.request.state === 'pending';
|
|
129
|
+
|
|
130
|
+
/** events this person hosts, plus their own requests, soonest first (archived ones left out) */
|
|
131
|
+
export async function hostedEvents(repo: ContentRepository, userId: string): Promise<CalEvent[]> {
|
|
132
|
+
return (await repo.listEvents()).filter((e) => e.status !== 'archived' && isHost(e, userId)).sort((a, b) => a.startAt - b.startAt);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** requests waiting for staff, oldest first */
|
|
136
|
+
export async function pendingRequests(repo: ContentRepository): Promise<CalEvent[]> {
|
|
137
|
+
return (await repo.listEvents()).filter((e) => e.request?.state === 'pending').sort((a, b) => a.request!.at - b.request!.at);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** a member asks for a new event: a draft they host, waiting for approval */
|
|
141
|
+
export async function submitEventRequest(repo: ContentRepository, who: Actor | null, input: EventRequestInput, hooks: SaveHooks): Promise<CalEvent> {
|
|
142
|
+
requireMember(who);
|
|
143
|
+
if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw new ApiError(429, 'That’s a lot of requests in an hour. Try again later.', 3600);
|
|
144
|
+
const open = (await hostedEvents(repo, who.id)).filter((e) => e.request?.state === 'pending').length;
|
|
145
|
+
if (open >= LIMITS.pending) throw new ApiError(429, 'You have many requests waiting already. Wait for the team to answer them first.');
|
|
146
|
+
const c = await clean(repo, input);
|
|
147
|
+
const created = await repo.createEvent({
|
|
148
|
+
title: c.title, slug: await uniqueSlug(repo, c.title), start: c.start, allDay: c.allDay, status: 'draft',
|
|
149
|
+
calendarIds: c.calendarIds, fields: filled(c.fields), ...textToBody(c.description),
|
|
150
|
+
location: EventLocation.parse({ name: c.where }),
|
|
151
|
+
hostIds: [who.id], request: pending(who.id, 'new', text(input.note, LIMITS.note), true),
|
|
152
|
+
});
|
|
153
|
+
// times and repeat go through the editor's own validation
|
|
154
|
+
await saveEvent(repo, created.id, { start: c.start, end: c.end, allDay: c.allDay, rrule: c.rrule }, hooksFor(hooks));
|
|
155
|
+
await notifyStaff(repo, created.id, hooks);
|
|
156
|
+
return (await repo.getEvent(created.id))!;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A host edits their request, or suggests changes to their live event. A
|
|
161
|
+
* request that hasn't gone live is updated and (re)sent for approval; a live
|
|
162
|
+
* event keeps showing what it shows until staff approve the changes.
|
|
163
|
+
*/
|
|
164
|
+
export async function updateEventRequest(repo: ContentRepository, who: Actor | null, id: string, input: EventRequestInput, hooks: SaveHooks): Promise<CalEvent> {
|
|
165
|
+
requireMember(who);
|
|
166
|
+
const e = await hostedEvent(repo, who, id);
|
|
167
|
+
// staff have unpublished edits of their own on this live event: don't mix a
|
|
168
|
+
// host's suggestion into them (declining it would throw both away)
|
|
169
|
+
if (live(e) && e.draft && !hostChanges(e)) throw new ApiError(409, 'The team is editing this event right now. Try again later.');
|
|
170
|
+
if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw new ApiError(429, 'That’s a lot of changes in an hour. Try again later.', 3600);
|
|
171
|
+
// what the host sees is the event with their own waiting changes on top
|
|
172
|
+
const current = (hostChanges(e) ? { ...e, ...(e.draft ?? {}) } : e) as CalEvent;
|
|
173
|
+
const c = await clean(repo, input, current);
|
|
174
|
+
const data: Parameters<typeof saveEvent>[2] = { title: c.title, start: c.start, end: c.end, allDay: c.allDay, rrule: c.rrule, calendarIds: c.calendarIds,
|
|
175
|
+
fields: filled({ ...current.fields, ...c.fields }) };
|
|
176
|
+
// untouched description or place: keep what staff may have added (formatting, images, address, map pin)
|
|
177
|
+
if (c.description !== bodyToText(current)) Object.assign(data, textToBody(c.description));
|
|
178
|
+
if (c.where !== current.location.name) data.location = EventLocation.parse({ name: c.where });
|
|
179
|
+
// live: saveEvent stages the edit (the event's own draft); not live: it writes it
|
|
180
|
+
await saveEvent(repo, id, data, hooksFor(hooks));
|
|
181
|
+
await repo.setEventRequest(id, pending(who.id, live(e) ? 'changes' : 'new', text(input.note, LIMITS.note), !live(e) && e.request?.created === true));
|
|
182
|
+
await notifyStaff(repo, id, hooks);
|
|
183
|
+
return (await repo.getEvent(id))!;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** a host takes back a request: a new event that never went live is deleted; suggested changes are dropped */
|
|
187
|
+
export async function withdrawEventRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks): Promise<{ deleted: boolean }> {
|
|
188
|
+
requireMember(who);
|
|
189
|
+
const e = await hostedEvent(repo, who, id);
|
|
190
|
+
if (!e.request) throw new ApiError(400, 'There’s no request on this event to withdraw.');
|
|
191
|
+
// only an event the member's own request made is deleted; a draft staff wrote stays
|
|
192
|
+
if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
|
|
193
|
+
await repo.deleteEvent(id);
|
|
194
|
+
await eventDeleted(repo, e, { waitUntil: waitUntilFrom(hooks.locals) });
|
|
195
|
+
return { deleted: true };
|
|
196
|
+
}
|
|
197
|
+
if (e.request.kind === 'changes') await repo.discardEventDraft(id);
|
|
198
|
+
await repo.setEventRequest(id, null);
|
|
199
|
+
return { deleted: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** the request staff are answering, unchanged since they opened it (`seen`: its `at`) */
|
|
203
|
+
async function answerable(repo: ContentRepository, id: string, seen?: number) {
|
|
204
|
+
const e = await repo.getEvent(id);
|
|
205
|
+
if (!e?.request || e.request.state !== 'pending') throw new ApiError(409, 'This request was already answered or withdrawn.');
|
|
206
|
+
if (seen !== undefined && e.request.at !== seen) throw new ApiError(409, 'This request changed after you opened it. Look at it again, then answer.');
|
|
207
|
+
return e as CalEvent & { request: EventRequest };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** staff approve: a new event is published; suggested changes go live. Pass `seen` (the request's `at` they reviewed) so later edits aren't approved unseen. */
|
|
211
|
+
export async function approveEventRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks, seen?: number): Promise<CalEvent> {
|
|
212
|
+
requireApprover(who);
|
|
213
|
+
const e = await answerable(repo, id, seen);
|
|
214
|
+
const h = { ...hooksFor(hooks), canPublish: true };
|
|
215
|
+
if (e.request.kind === 'changes') { if (e.draft) await applyEventUpdate(repo, id, h); }
|
|
216
|
+
else if (!live(e)) await saveEvent(repo, id, { status: 'published' }, h);
|
|
217
|
+
await repo.setEventRequest(id, null);
|
|
218
|
+
await notifyHost(repo, e, 'approved', '', hooks);
|
|
219
|
+
return (await repo.getEvent(id))!;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** staff decline, with a note for the host: a new request stays a draft they can edit and resend; suggested changes are dropped */
|
|
223
|
+
export async function declineEventRequest(repo: ContentRepository, who: Actor | null, id: string, reply: string, hooks: SaveHooks, seen?: number): Promise<CalEvent> {
|
|
224
|
+
requireApprover(who);
|
|
225
|
+
const e = await answerable(repo, id, seen);
|
|
226
|
+
if (e.request.kind === 'changes') await repo.discardEventDraft(id);
|
|
227
|
+
const answer = text(reply, LIMITS.note);
|
|
228
|
+
await repo.setEventRequest(id, { ...e.request, state: 'declined', reply: answer, reviewedBy: who.id, reviewedAt: Date.now() });
|
|
229
|
+
await notifyHost(repo, e, 'declined', answer, hooks);
|
|
230
|
+
return (await repo.getEvent(id))!;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** a host clears a declined answer they've read (a declined new request stays as a draft) */
|
|
234
|
+
export async function dismissEventRequest(repo: ContentRepository, who: Actor | null, id: string): Promise<void> {
|
|
235
|
+
if (!who) throw new ApiError(401, 'Sign in to continue.');
|
|
236
|
+
const e = await hostedEvent(repo, who, id);
|
|
237
|
+
if (e.request?.state === 'declined' && e.request.kind === 'changes') await repo.setEventRequest(id, null);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** hosts cancel or restore one date of their own event at once (no permission needed beyond hosting it) */
|
|
241
|
+
export async function setHostedOccurrence(repo: ContentRepository, who: Actor | null, id: string, key: string, cancelled: boolean, hooks: SaveHooks): Promise<CalEvent> {
|
|
242
|
+
if (!who) throw new ApiError(401, 'Sign in to continue.');
|
|
243
|
+
const e = await hostedEvent(repo, who, id);
|
|
244
|
+
if (!e.hostIds.includes(who.id)) throw new ApiError(404, 'Event not found.');
|
|
245
|
+
return (cancelled ? cancelOccurrence : restoreOccurrence)(repo, id, key, hooksFor(hooks));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** staff set who hosts an event (Team members only) */
|
|
249
|
+
export async function setEventHosts(repo: ContentRepository, who: Actor | null, id: string, hostIds: string[]): Promise<CalEvent> {
|
|
250
|
+
if (!who) throw new ApiError(401, 'Sign in to continue.');
|
|
251
|
+
if (!who.can('content.edit')) throw new ApiError(403, 'Your role doesn’t include “Create and edit content”.');
|
|
252
|
+
const e = await repo.getEvent(id);
|
|
253
|
+
if (!e) throw new ApiError(404, 'Event not found.');
|
|
254
|
+
const known = new Set((await repo.listUsers()).map((u) => u.id));
|
|
255
|
+
const ids = [...new Set(hostIds)].filter((h) => known.has(h)).slice(0, 10);
|
|
256
|
+
await repo.setEventHosts(id, ids);
|
|
257
|
+
return (await repo.getEvent(id))!;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- email ------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/** staff: "New event request" / "Changes to review" (to members.notify, else the owners) */
|
|
263
|
+
async function notifyStaff(repo: ContentRepository, id: string, hooks: SaveHooks) {
|
|
264
|
+
const e = await repo.getEvent(id);
|
|
265
|
+
if (!e?.request) return;
|
|
266
|
+
const to = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
|
|
267
|
+
await queue(repo, e, to, e.request.kind === 'changes' ? 'staff-changes' : 'staff-new', hooks);
|
|
268
|
+
}
|
|
269
|
+
/** the host who asked: "approved" / "declined" (with staff's reply) */
|
|
270
|
+
async function notifyHost(repo: ContentRepository, e: CalEvent, kind: 'approved' | 'declined', reply: string, hooks: SaveHooks) {
|
|
271
|
+
const by = e.request?.by ? await repo.getUser(e.request.by) : null;
|
|
272
|
+
if (by?.email) await queue(repo, e, [by.email], kind, hooks, reply);
|
|
273
|
+
}
|
|
274
|
+
async function queue(repo: ContentRepository, e: CalEvent, to: string[], kind: string, hooks: SaveHooks, reply = '') {
|
|
275
|
+
if (!to.length) return;
|
|
276
|
+
// like booking emails: the latest saved email settings, or 0 for environment-only setups
|
|
277
|
+
const version = (await repo.email.config().catch(() => null))?.version ?? 0;
|
|
278
|
+
const at = e.request?.at ?? Date.now();
|
|
279
|
+
for (const recipient of to) {
|
|
280
|
+
await repo.email.enqueue({ dedupe: `event-request:${e.id}:${at}:${kind}:${recipient}`, kind: 'event-request', recipient,
|
|
281
|
+
payload: JSON.stringify({ eventId: e.id, kind, at, reply }), configVersion: version, expiresAt: Date.now() + 7 * 86_400_000 });
|
|
282
|
+
}
|
|
283
|
+
await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
|
|
284
|
+
}
|
|
@@ -16,7 +16,7 @@ import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
|
|
|
16
16
|
import { eventDeleted } from './hooks';
|
|
17
17
|
import { waitUntilFrom } from '../webhooks';
|
|
18
18
|
|
|
19
|
-
async function uniqueSlug(repo: ContentRepository, title: string) {
|
|
19
|
+
export async function uniqueSlug(repo: ContentRepository, title: string) {
|
|
20
20
|
const base = slugify(title) || 'event';
|
|
21
21
|
let slug = base;
|
|
22
22
|
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
package/src/lib/editor/auth.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AUTH } from '../auth/config';
|
|
2
2
|
import { validateMobileBinding, authEnvironment, ClerkUnavailableError } from '../auth/request';
|
|
3
3
|
import type { ClerkBinding } from '../auth/store';
|
|
4
|
-
import {
|
|
4
|
+
import { isStaffRole } from '../roles';
|
|
5
|
+
import type { Role } from '../schema';
|
|
5
6
|
import { createHmac } from 'node:crypto';
|
|
6
7
|
import { env } from '../env';
|
|
7
8
|
import { sha256Hex } from '../api-keys';
|
|
@@ -161,12 +162,13 @@ async function pair(now: number) {
|
|
|
161
162
|
}
|
|
162
163
|
async function liveUser(repo: ContentRepository, id: string, sessionVersion: number) {
|
|
163
164
|
const [user, version] = await Promise.all([repo.getUser(id), repo.email.sessionVersion(id)]);
|
|
164
|
-
|
|
165
|
+
// only staff hold app tokens: someone moved to a site role without admin access loses theirs
|
|
166
|
+
return user && version === sessionVersion && isStaffRole(user.role) ? user : null;
|
|
165
167
|
}
|
|
166
168
|
function tokenResponse(t: { access: string; refresh: string }, user: { id: string; name: string; email: string; role: string }, device: { id: string; name: string }): TokenResponse {
|
|
167
169
|
return { access_token: t.access, token_type: 'Bearer', expires_in: Math.floor(EDITOR.accessTokenMs / 1000), refresh_token: t.refresh,
|
|
168
170
|
refresh_token_expires_in: Math.floor(EDITOR.refreshTokenMs / 1000),
|
|
169
|
-
user: { id: user.id, name: user.name, email: user.email, role:
|
|
171
|
+
user: { id: user.id, name: user.name, email: user.email, role: user.role }, device };
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
async function mobileBindingActive(...args: Parameters<typeof validateMobileBinding>) {
|
|
@@ -4,7 +4,8 @@ import { Booking, BookingLink, Calendar, LinksAvatarShape, LinksAvatarFit, Links
|
|
|
4
4
|
|
|
5
5
|
// Response shapes of the editor-app API (OpenAPI components, client types).
|
|
6
6
|
const str = z.string(), bool = z.boolean(), num = z.number(), date = str.datetime();
|
|
7
|
-
|
|
7
|
+
// a core role (owner, administrator, editor, author, contributor) or a staff role the site defines
|
|
8
|
+
const role = str.min(1);
|
|
8
9
|
const Pagination = z.object({ page: num.int(), limit: num.int(), nextPage: num.int().nullable() });
|
|
9
10
|
const User = z.object({ id: str, name: str, email: str, role });
|
|
10
11
|
|
package/src/lib/editor/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isStaffRole } from '../roles';
|
|
2
|
+
import type { Role } from '../schema';
|
|
2
3
|
import type { SqlDriver } from '../repo/sql';
|
|
3
4
|
|
|
4
5
|
type Row = Record<string, unknown>;
|
|
@@ -115,10 +116,11 @@ export class EditorStore {
|
|
|
115
116
|
FROM editor_tokens t JOIN editor_devices d ON d.id=t.device_id JOIN users u ON u.id=d.user_id
|
|
116
117
|
WHERE t.hash=? AND t.kind='access' AND t.expires_at>? AND d.revoked_at IS NULL`, [hash, now]);
|
|
117
118
|
const r = rows[0];
|
|
118
|
-
|
|
119
|
+
// only staff use the app: a role without admin access (or one the site removed) gets nothing
|
|
120
|
+
if (!r || !isStaffRole(String(r.u_role))) return null;
|
|
119
121
|
// first use proves the app received this pair (ends the refresh grace)
|
|
120
122
|
if (r.t_used == null) await this.run('UPDATE editor_tokens SET used_at=? WHERE hash=? AND used_at IS NULL', [now, hash]);
|
|
121
|
-
return { device: device(r), user: { id: String(r.user_id), name: String(r.u_name), email: String(r.u_email), role:
|
|
123
|
+
return { device: device(r), user: { id: String(r.user_id), name: String(r.u_name), email: String(r.u_email), role: String(r.u_role), sessionVersion: Number(r.u_sv) } };
|
|
122
124
|
}
|
|
123
125
|
/** the device a token (access or refresh) belongs to, for revocation */
|
|
124
126
|
async deviceForToken(hash: string): Promise<string | null> {
|
package/src/lib/email/service.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin, guestCodeMail, EmailLinkError } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, EmailLinkError } from './templates';
|
|
5
|
+
import { formatDate } from '../dates';
|
|
5
6
|
import { bookingMail } from '../booking/service';
|
|
6
7
|
import type { EmailJob, Mail } from './types';
|
|
7
8
|
|
|
@@ -31,6 +32,18 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
31
32
|
const raw = decrypt<string>(String(token.encrypted), `token:${token.id}`, locals);
|
|
32
33
|
return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
|
|
33
34
|
}
|
|
35
|
+
if (job.kind === 'event-request') {
|
|
36
|
+
const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined'; at: number; reply: string };
|
|
37
|
+
const e = await repo.getEvent(p.eventId);
|
|
38
|
+
if (!e) return null;
|
|
39
|
+
const staff = p.kind.startsWith('staff');
|
|
40
|
+
// staff hear only about requests still waiting (not withdrawn or already answered)
|
|
41
|
+
if (staff && (e.request?.state !== 'pending' || e.request.at !== p.at)) return null;
|
|
42
|
+
const by = e.request?.by ? await repo.getUser(e.request.by) : null;
|
|
43
|
+
const { memberEventsPath, MEMBERS } = await import('../calendar/requests');
|
|
44
|
+
const url = staff ? `${origin}/admin/calendar/events/${e.id}` : `${origin}${MEMBERS.enabled ? memberEventsPath() : '/'}`;
|
|
45
|
+
return eventRequestMail(p.kind, job.recipient, settings.siteTitle, { title: e.title, when: formatDate(e.startAt, settings), by: by?.name ?? '', note: e.request?.note ?? '' }, p.reply, url);
|
|
46
|
+
}
|
|
34
47
|
if (job.kind === 'welcome' || job.kind === 'password-changed') return accountMail(job.kind, job.recipient, settings.siteTitle, `${origin}/admin/login`);
|
|
35
48
|
if (job.kind === 'email-changed') return accountMail(job.kind, job.recipient, settings.siteTitle);
|
|
36
49
|
return null;
|
|
@@ -57,9 +70,9 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
57
70
|
}
|
|
58
71
|
await repo.email.pin(j,resolved.provider,resolved.fingerprint,config?.version || 0);
|
|
59
72
|
if (await repo.email.suppressed(j.recipient)) { await repo.email.finish(j,{state:'suppressed',error:'Recipient is suppressed after a bounce or complaint.'}); continue; }
|
|
60
|
-
if (j.kind === 'booking') {
|
|
73
|
+
if (j.kind === 'booking' || j.kind === 'event-request') {
|
|
61
74
|
const payload = JSON.parse(j.payload);
|
|
62
|
-
if ((payload.kind === 'reminder' && !current.config.reminders) || (payload.kind
|
|
75
|
+
if ((payload.kind === 'reminder' && !current.config.reminders) || (String(payload.kind).startsWith('staff-') && !current.config.staffNotifications)) {
|
|
63
76
|
await repo.email.finish(j,{state:'cancelled',error:'Optional notification disabled.'}); continue;
|
|
64
77
|
}
|
|
65
78
|
}
|
package/src/lib/email/store.ts
CHANGED
|
@@ -141,7 +141,9 @@ export class EmailStore {
|
|
|
141
141
|
{ sql: "UPDATE email_tokens SET encrypted='' WHERE expires_at <= ? OR used_by != '' OR revoked=1", args:[now] },
|
|
142
142
|
]);
|
|
143
143
|
}
|
|
144
|
-
async sessionVersion(id: string): Promise<number | null> {
|
|
144
|
+
async sessionVersion(id: string): Promise<number | null> { return (await this.sessionState(id))?.version ?? null; }
|
|
145
|
+
/** the session version and role in one read (every signed-in request asks for both) */
|
|
146
|
+
async sessionState(id: string): Promise<{ version: number; role: string } | null> { const r = await (await this.getDriver()).all('SELECT session_version, role FROM users WHERE id=?', [id]); return r[0] ? { version: Number(r[0].session_version), role: String(r[0].role) } : null; }
|
|
145
147
|
async invitations(): Promise<Invitation[]> {
|
|
146
148
|
return (await (await this.getDriver()).all("SELECT id,email,name,role,expires_at,created_at,invited_by,CASE WHEN revoked=1 THEN 'revoked' WHEN used_by!='' THEN 'accepted' WHEN expires_at < ? THEN 'expired' ELSE 'pending' END state FROM email_tokens WHERE purpose='invite' ORDER BY created_at DESC LIMIT 100", [Date.now()])).map((r) => ({ id: String(r.id), email:String(r.email),name:String(r.name),role:r.role as Invitation['role'],expiresAt:Number(r.expires_at),createdAt:Number(r.created_at),state:String(r.state),invitedBy:String(r.invited_by ?? '') }));
|
|
147
149
|
}
|
|
@@ -12,6 +12,18 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
|
|
|
12
12
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
|
|
13
13
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
|
|
14
14
|
}
|
|
15
|
+
/** members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered */
|
|
16
|
+
export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
|
|
17
|
+
const copy = {
|
|
18
|
+
'staff-new': [`New event request: ${event.title}`, `${event.by || 'A member'} asked for “${event.title}” (${event.when}). It stays a draft until someone approves it.`, 'Review it'],
|
|
19
|
+
'staff-changes': [`Changes to review: ${event.title}`, `${event.by || 'A host'} suggested changes to “${event.title}” (${event.when}). The live event stays as it is until someone approves them.`, 'Review the changes'],
|
|
20
|
+
approved: [`Approved: ${event.title}`, `Your event “${event.title}” (${event.when}) was approved and is now on the calendar.`, 'See your events'],
|
|
21
|
+
declined: [`Not approved: ${event.title}`, `Your request for “${event.title}” (${event.when}) wasn’t approved.`, 'See your events'],
|
|
22
|
+
}[kind];
|
|
23
|
+
const extra = kind.startsWith('staff') ? (event.note ? `Their note: “${event.note}”` : '') : reply ? `From the team: “${reply}”` : '';
|
|
24
|
+
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(extra ? ['', extra] : []), '', `${copy[2]}: ${url}`].join('\n'),
|
|
25
|
+
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:22px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${extra ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px">${escape(extra)}</p>` : ''}<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p></div></body></html>` };
|
|
26
|
+
}
|
|
15
27
|
/** a guest's app sign-in code; no link, so it works on any device */
|
|
16
28
|
export function guestCodeMail(to: string, site: string, code: string): Mail {
|
|
17
29
|
const intro = `Enter this code in the ${site} app to see your bookings. It expires in 10 minutes. If you did not ask for it, ignore this message.`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type
|
|
3
|
+
import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
4
4
|
import { ApiError } from '../api';
|
|
5
5
|
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
|
|
6
6
|
import { calendarToJson, eventToJson } from '../calendar/api';
|
|
@@ -23,7 +23,9 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
|
|
|
23
23
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
24
24
|
return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// tag/excludeTag: a public tag's name or slug (internal "#" tags are never matched, as they are never shown)
|
|
27
|
+
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(),
|
|
28
|
+
tag: z.string().min(1).max(100).optional(), excludeTag: z.string().min(1).max(100).optional(), q: z.string().max(100).optional() }).strict();
|
|
27
29
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
28
30
|
requireCalendar();
|
|
29
31
|
const q = eventsQuery.parse(queryParams(url));
|
|
@@ -44,7 +46,9 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
44
46
|
const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
|
|
45
47
|
if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
|
|
46
48
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
|
|
49
|
+
const tagged = tagFilter(q.tag, q.excludeTag);
|
|
47
50
|
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
51
|
+
&& tagged(publicTags(e.tags))
|
|
48
52
|
&& (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
|
|
49
53
|
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
|
|
50
54
|
occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
|