@acms-subir/core 0.3.52 → 0.3.54
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 +29 -0
- package/cli/migrations.generated.mjs +18 -1
- package/docs/calendar.md +20 -2
- package/docs/mobile.md +1 -1
- package/docs/server.md +61 -1
- package/integration.mjs +11 -5
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/email/service.ts +12 -2
- package/src/lib/email/templates.ts +12 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +7 -0
- package/src/lib/repo/types.ts +6 -1
- package/src/lib/schema.ts +47 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +53 -0
- package/src/styles/forms.css +8 -0
- package/src/virtual.d.ts +1 -1
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
// RSVP emails always go, because those people's own booking changed.
|
|
19
19
|
import type { ContentRepository } from '../repo/types';
|
|
20
20
|
import { EventLocation, type CalEvent, type MovedDate } from '../schema';
|
|
21
|
-
import { ApiError } from '../api';
|
|
21
|
+
import { ApiError, coded } from '../api';
|
|
22
22
|
import { eventSideEffects, type SaveHooks } from '../content-service';
|
|
23
|
-
import { addWallDays, addWallMinutes, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
|
|
23
|
+
import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
|
|
24
24
|
import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
|
|
25
25
|
import { kickEmail } from '../email/service';
|
|
26
26
|
import { waitUntilFrom } from '../webhooks';
|
|
@@ -47,6 +47,8 @@ export interface MoveInput {
|
|
|
47
47
|
export interface MoveOptions {
|
|
48
48
|
/** no app notice and no staff email (a sync catching up); RSVP emails still go */
|
|
49
49
|
quiet?: boolean;
|
|
50
|
+
/** who to record as having moved it (MovedDate.by), when not the actor: the host whose request staff approved */
|
|
51
|
+
by?: string;
|
|
50
52
|
now?: number;
|
|
51
53
|
}
|
|
52
54
|
export interface MoveResult {
|
|
@@ -62,10 +64,11 @@ export interface MoveResult {
|
|
|
62
64
|
* repeating event), cancelled-event, not-a-date (the key isn't one of its
|
|
63
65
|
* dates), cancelled-date, started (the date has started or passed),
|
|
64
66
|
* original-passed (moving back to a time that has passed), past (the new time
|
|
65
|
-
* has passed), bad-time, room-taken (a host, 409)
|
|
67
|
+
* has passed), bad-time, room-taken (a host, 409), room-not-allowed (a host
|
|
68
|
+
* picking a room the event doesn't use and nobody may request, 403).
|
|
66
69
|
*/
|
|
67
|
-
export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken';
|
|
68
|
-
const refuse = (status: number, code: MoveRefusal, message: string) =>
|
|
70
|
+
export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken' | 'room-not-allowed';
|
|
71
|
+
const refuse = (status: number, code: MoveRefusal, message: string) => coded(status, code, message);
|
|
69
72
|
|
|
70
73
|
const DAY = 86_400_000;
|
|
71
74
|
const NOTE_MAX = 500;
|
|
@@ -73,16 +76,16 @@ const NOTE_MAX = 500;
|
|
|
73
76
|
type Role = 'staff' | 'host';
|
|
74
77
|
function roleOf(actor: MoveActor | null, e: CalEvent): Role {
|
|
75
78
|
if (actor === GOOGLE_SYNC) return 'staff';
|
|
76
|
-
if (!actor) throw
|
|
79
|
+
if (!actor) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
77
80
|
if (actor.can('content.edit')) return 'staff';
|
|
78
81
|
if (e.hostIds.includes(actor.id)) return 'host';
|
|
79
|
-
throw
|
|
82
|
+
throw coded(404, 'not-found', 'Event not found.');
|
|
80
83
|
}
|
|
81
84
|
const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
|
|
82
85
|
|
|
83
86
|
async function load(repo: ContentRepository, id: string) {
|
|
84
87
|
const e = await repo.getEvent(id);
|
|
85
|
-
if (!e || e.status === 'archived') throw
|
|
88
|
+
if (!e || e.status === 'archived') throw coded(404, 'not-found', 'Event not found.');
|
|
86
89
|
return e;
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -137,7 +140,7 @@ async function refuseForHost(repo: ContentRepository, clashes: BusySlot[]) {
|
|
|
137
140
|
const names = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
|
|
138
141
|
const rooms = [...new Set(clashes.map((c) => names.get(c.resourceId) ?? 'The room'))];
|
|
139
142
|
const list = rooms.length > 1 ? `${rooms.slice(0, -1).join(', ')} and ${rooms[rooms.length - 1]}` : rooms[0];
|
|
140
|
-
throw refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`);
|
|
143
|
+
throw Object.assign(refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`), { rooms });
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
|
|
@@ -145,14 +148,15 @@ const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a
|
|
|
145
148
|
const sameMove = (a: MovedDate, b: MovedDate) => a.start === b.start && a.end === b.end && a.note === b.note
|
|
146
149
|
&& JSON.stringify(a.roomIds ?? null) === JSON.stringify(b.roomIds ?? null) && JSON.stringify(a.location ?? null) === JSON.stringify(b.location ?? null);
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
151
|
+
// What a move would do, checked but not written: the same refusals as
|
|
152
|
+
// moveOccurrence, except a host's taken room, which comes back as clashes
|
|
153
|
+
type MovePlan =
|
|
154
|
+
| { kind: 'same'; e: CalEvent }
|
|
155
|
+
| { kind: 'isBack' }
|
|
156
|
+
| { kind: 'move'; e: CalEvent; role: Role; settings: Settings; current: Occurrence; next: CalEvent; note: string; clashes: BusySlot[] };
|
|
157
|
+
type Settings = Awaited<ReturnType<ContentRepository['getSettings']>>;
|
|
158
|
+
|
|
159
|
+
async function planMove(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, now: number, by?: string): Promise<MovePlan> {
|
|
156
160
|
const e = await load(repo, id);
|
|
157
161
|
const role = roleOf(actor, e);
|
|
158
162
|
const settings = await repo.getSettings();
|
|
@@ -162,8 +166,14 @@ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor |
|
|
|
162
166
|
|
|
163
167
|
let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
|
|
164
168
|
if (roomIds !== undefined) {
|
|
165
|
-
const
|
|
169
|
+
const resources = await repo.listResources();
|
|
170
|
+
const known = new Set(resources.map((r) => r.id));
|
|
166
171
|
roomIds = roomIds.filter((r) => known.has(r));
|
|
172
|
+
// hosts pick among the rooms the series already uses and the ones anyone may ask for
|
|
173
|
+
if (role === 'host') {
|
|
174
|
+
const allowed = new Set([...eventRoomIds(e), ...resources.filter((r) => r.requestable).map((r) => r.id)]);
|
|
175
|
+
if (roomIds.some((r) => !allowed.has(r))) throw refuse(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
|
|
176
|
+
}
|
|
167
177
|
// the event's own rooms aren't an override
|
|
168
178
|
if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
|
|
169
179
|
}
|
|
@@ -172,34 +182,29 @@ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor |
|
|
|
172
182
|
if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
|
|
173
183
|
const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
|
|
174
184
|
|
|
175
|
-
const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: actorId(actor!), at: now };
|
|
176
|
-
if (prior && sameMove(prior, entry)) return {
|
|
185
|
+
const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
|
|
186
|
+
if (prior && sameMove(prior, entry)) return { kind: 'same', e };
|
|
177
187
|
const at = movedInstants(e, entry, settings);
|
|
178
188
|
if (!at) throw refuse(400, 'bad-time', 'Pick a valid time.');
|
|
179
189
|
|
|
180
190
|
// back where the series puts it, with nothing else different: that's moving it back
|
|
181
191
|
const series = findOccurrence({ ...e, movedDates: {} }, key, settings)!;
|
|
182
|
-
if (at.start === series.start && at.end === series.end && !roomIds && !location) return
|
|
192
|
+
if (at.start === series.start && at.end === series.end && !roomIds && !location) return { kind: 'isBack' };
|
|
183
193
|
if (current.start <= now) throw started();
|
|
184
194
|
if (at.start <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed yet.');
|
|
185
195
|
|
|
186
196
|
const next: CalEvent = { ...e, movedDates: { ...e.movedDates, [key]: entry } };
|
|
187
|
-
|
|
188
|
-
if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
|
|
189
|
-
|
|
190
|
-
await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
|
|
191
|
-
const after = (await repo.getEvent(id))!;
|
|
192
|
-
const moved = findOccurrence(after, key, settings)!;
|
|
193
|
-
await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
|
|
194
|
-
return { event: after, clashes, changed: true };
|
|
197
|
+
return { kind: 'move', e, role, settings, current, next, note, clashes: await clashesFor(repo, next, key, at, settings) };
|
|
195
198
|
}
|
|
196
199
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
type BackPlan =
|
|
201
|
+
| { kind: 'same'; e: CalEvent }
|
|
202
|
+
| { kind: 'back'; e: CalEvent; role: Role; settings: Settings; rest: CalEvent['movedDates']; current: Occurrence | null; back: Occurrence | null; clashes: BusySlot[] };
|
|
203
|
+
|
|
204
|
+
async function planBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, now: number): Promise<BackPlan> {
|
|
200
205
|
const e = await load(repo, id);
|
|
201
206
|
const role = roleOf(actor, e);
|
|
202
|
-
if (!Object.hasOwn(e.movedDates, key)) return {
|
|
207
|
+
if (!Object.hasOwn(e.movedDates, key)) return { kind: 'same', e };
|
|
203
208
|
const settings = await repo.getSettings();
|
|
204
209
|
const { [key]: _gone, ...rest } = e.movedDates;
|
|
205
210
|
const next: CalEvent = { ...e, movedDates: rest };
|
|
@@ -211,6 +216,49 @@ export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveAct
|
|
|
211
216
|
if (back.start <= now) throw refuse(400, 'original-passed', 'Its original time has already passed.');
|
|
212
217
|
}
|
|
213
218
|
const clashes = back ? await clashesFor(repo, next, key, back, settings) : [];
|
|
219
|
+
return { kind: 'back', e, role, settings, rest, current, back, clashes };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What moveOccurrence (or, `input` null, moveOccurrenceBack) would do, without doing it: the same refusals, except that a host's taken room comes back in `clashes` instead of a 409. A move gives the date's new wall times and note too. */
|
|
223
|
+
export async function checkMove(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput | null, opts: Pick<MoveOptions, 'now'> = {}): Promise<{ clashes: BusySlot[]; changed: boolean; back?: boolean; start?: string; end?: string; note?: string }> {
|
|
224
|
+
const now = opts.now ?? Date.now();
|
|
225
|
+
const plan = input ? await planMove(repo, actor, id, key, input, now) : await planBack(repo, actor, id, key, now);
|
|
226
|
+
if (plan.kind === 'same') return { clashes: [], changed: false };
|
|
227
|
+
// a move to where the series has it is a move back
|
|
228
|
+
if (plan.kind === 'isBack') return { ...(await checkMove(repo, actor, id, key, null, opts)), back: true };
|
|
229
|
+
if (plan.kind === 'move') {
|
|
230
|
+
const m = plan.next.movedDates[key];
|
|
231
|
+
return { clashes: plan.clashes, changed: true, start: m.start, end: m.end, note: m.note };
|
|
232
|
+
}
|
|
233
|
+
return { clashes: plan.clashes, changed: true };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Move one date of a repeating event to a new time (and maybe other rooms or
|
|
238
|
+
* another place). Moving an already moved date replaces its move; moving it
|
|
239
|
+
* back to exactly where it was is moveOccurrenceBack. Moving it to where it
|
|
240
|
+
* already is changes nothing (`changed: false`): retries never email twice.
|
|
241
|
+
* Hosts pick rooms among the event's own and the requestable ones.
|
|
242
|
+
*/
|
|
243
|
+
export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
|
|
244
|
+
const plan = await planMove(repo, actor, id, key, input, opts.now ?? Date.now(), opts.by);
|
|
245
|
+
if (plan.kind === 'same') return { event: plan.e, clashes: [], changed: false };
|
|
246
|
+
if (plan.kind === 'isBack') return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
|
|
247
|
+
const { role, settings, current, next, note, clashes } = plan;
|
|
248
|
+
if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
|
|
249
|
+
|
|
250
|
+
await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
|
|
251
|
+
const after = (await repo.getEvent(id))!;
|
|
252
|
+
const moved = findOccurrence(after, key, settings)!;
|
|
253
|
+
await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
|
|
254
|
+
return { event: after, clashes, changed: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
|
|
258
|
+
export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
|
|
259
|
+
const plan = await planBack(repo, actor, id, key, opts.now ?? Date.now());
|
|
260
|
+
if (plan.kind === 'same') return { event: plan.e, clashes: [], changed: false };
|
|
261
|
+
const { role, settings, rest, current, back, clashes } = plan;
|
|
214
262
|
if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
|
|
215
263
|
await repo.setEventMovedDates(id, rest, undefined, { quiet: opts.quiet });
|
|
216
264
|
const after = (await repo.getEvent(id))!;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// v67: calendar requests on their own rows (RequestRecord in schema.ts,
|
|
2
|
+
// stored by lib/calendar/request-store.ts). Not public (nothing here reaches
|
|
3
|
+
// pages or the public API), so no content version or event_changes
|
|
4
|
+
// triggers; included in backups. No imports: the CLI's migrate runner
|
|
5
|
+
// bundles this file.
|
|
6
|
+
export const REQUEST_TABLE = [
|
|
7
|
+
`CREATE TABLE event_requests (
|
|
8
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, state TEXT NOT NULL, source TEXT NOT NULL,
|
|
9
|
+
user_id TEXT NOT NULL DEFAULT '', requester TEXT,
|
|
10
|
+
event_id TEXT NOT NULL DEFAULT '', occurrence_key TEXT NOT NULL DEFAULT '',
|
|
11
|
+
data TEXT NOT NULL DEFAULT '{}', note TEXT NOT NULL DEFAULT '', reply TEXT NOT NULL DEFAULT '',
|
|
12
|
+
reviewed_by TEXT NOT NULL DEFAULT '', reviewed_at INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
14
|
+
)`,
|
|
15
|
+
`CREATE INDEX event_requests_state ON event_requests (state, created_at)`,
|
|
16
|
+
`CREATE INDEX event_requests_event ON event_requests (event_id)`,
|
|
17
|
+
];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
2
|
+
import { RequestRecord, type RequestKind, type RequestSource, type RequestState } from '../schema';
|
|
3
|
+
|
|
4
|
+
// Storage for calendar requests kept on their own rows (RequestRecord in
|
|
5
|
+
// schema.ts). Shared by the D1 and libSQL adapters (plain SQLite). The rules
|
|
6
|
+
// (who may ask, approve or decline) live in lib/calendar/requests.ts.
|
|
7
|
+
|
|
8
|
+
type Row = Record<string, unknown>;
|
|
9
|
+
const json = (raw: unknown) => { try { return raw == null ? null : JSON.parse(String(raw)); } catch { return null; } };
|
|
10
|
+
const record = (r: Row): RequestRecord => RequestRecord.parse({
|
|
11
|
+
id: r.id, kind: r.kind, state: r.state, source: r.source, userId: r.user_id, requester: json(r.requester),
|
|
12
|
+
eventId: r.event_id, key: r.occurrence_key, data: json(r.data) ?? {}, note: r.note, reply: r.reply,
|
|
13
|
+
reviewedBy: r.reviewed_by, reviewedAt: Number(r.reviewed_at), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** The INSERT for one request (create and backup restore). */
|
|
17
|
+
export const requestInsert = (q: RequestRecord): SqlStatement => ({
|
|
18
|
+
sql: `INSERT INTO event_requests (id,kind,state,source,user_id,requester,event_id,occurrence_key,data,note,reply,reviewed_by,reviewed_at,created_at,updated_at)
|
|
19
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
20
|
+
args: [q.id, q.kind, q.state, q.source, q.userId, q.requester ? JSON.stringify(q.requester) : null, q.eventId, q.key,
|
|
21
|
+
JSON.stringify(q.data), q.note, q.reply, q.reviewedBy, q.reviewedAt, q.createdAt, q.updatedAt],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export interface RequestQuery {
|
|
25
|
+
states?: RequestState[];
|
|
26
|
+
kinds?: RequestKind[];
|
|
27
|
+
sources?: RequestSource[];
|
|
28
|
+
eventId?: string;
|
|
29
|
+
userId?: string;
|
|
30
|
+
/** 'oldest' / 'newest' by when they were sent; 'decided' = latest answer first */
|
|
31
|
+
order?: 'oldest' | 'newest' | 'decided';
|
|
32
|
+
/** -1 = all (backups) */
|
|
33
|
+
limit?: number;
|
|
34
|
+
offset?: number;
|
|
35
|
+
}
|
|
36
|
+
/** what changes when someone answers, withdraws or edits a request */
|
|
37
|
+
export type RequestPatch = Partial<Pick<RequestRecord, 'state' | 'reply' | 'reviewedBy' | 'reviewedAt' | 'eventId' | 'data' | 'note'>>;
|
|
38
|
+
const PATCH: [keyof RequestPatch, string][] = [
|
|
39
|
+
['state', 'state'], ['reply', 'reply'], ['reviewedBy', 'reviewed_by'], ['reviewedAt', 'reviewed_at'], ['eventId', 'event_id'], ['data', 'data'], ['note', 'note'],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function where(q: RequestQuery) {
|
|
43
|
+
const sql: string[] = [], args: unknown[] = [];
|
|
44
|
+
const any = (col: string, values?: string[]) => {
|
|
45
|
+
if (values === undefined) return;
|
|
46
|
+
// an empty list matches nothing
|
|
47
|
+
sql.push(values.length ? `${col} IN (${values.map(() => '?').join(',')})` : '0');
|
|
48
|
+
args.push(...values);
|
|
49
|
+
};
|
|
50
|
+
any('state', q.states); any('kind', q.kinds); any('source', q.sources);
|
|
51
|
+
if (q.eventId !== undefined) { sql.push('event_id=?'); args.push(q.eventId); }
|
|
52
|
+
if (q.userId !== undefined) { sql.push('user_id=?'); args.push(q.userId); }
|
|
53
|
+
return { sql: sql.length ? `WHERE ${sql.join(' AND ')}` : '', args };
|
|
54
|
+
}
|
|
55
|
+
const ORDER = { oldest: 'created_at ASC, id', newest: 'created_at DESC, id', decided: 'reviewed_at DESC, created_at DESC, id' };
|
|
56
|
+
|
|
57
|
+
export class RequestStore {
|
|
58
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
59
|
+
private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
|
|
60
|
+
|
|
61
|
+
/** Saves a new request; null when one with this id exists already (an idempotent retry: read it with get). */
|
|
62
|
+
async create(q: RequestRecord): Promise<RequestRecord | null> {
|
|
63
|
+
const insert = requestInsert(RequestRecord.parse(q));
|
|
64
|
+
const rows = await this.all(`${insert.sql} ON CONFLICT(id) DO NOTHING RETURNING *`, insert.args);
|
|
65
|
+
return rows[0] ? record(rows[0]) : null;
|
|
66
|
+
}
|
|
67
|
+
async get(id: string): Promise<RequestRecord | null> {
|
|
68
|
+
const r = await this.all('SELECT * FROM event_requests WHERE id=?', [id]);
|
|
69
|
+
return r[0] ? record(r[0]) : null;
|
|
70
|
+
}
|
|
71
|
+
async list(q: RequestQuery = {}): Promise<RequestRecord[]> {
|
|
72
|
+
const w = where(q);
|
|
73
|
+
return (await this.all(`SELECT * FROM event_requests ${w.sql} ORDER BY ${ORDER[q.order ?? 'newest']} LIMIT ? OFFSET ?`,
|
|
74
|
+
[...w.args, q.limit ?? 100, q.offset ?? 0])).map(record);
|
|
75
|
+
}
|
|
76
|
+
async count(q: Pick<RequestQuery, 'states' | 'kinds' | 'sources' | 'eventId' | 'userId'> = {}): Promise<number> {
|
|
77
|
+
const w = where(q);
|
|
78
|
+
return Number((await this.all(`SELECT COUNT(*) AS n FROM event_requests ${w.sql}`, w.args))[0]?.n ?? 0);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Writes the given fields and bumps updatedAt (so a reviewer's `seen` guard
|
|
82
|
+
* notices an edit). `onlyIf` makes it a compare-and-set: null when the row
|
|
83
|
+
* is no longer in that state or was changed since `updatedAt`.
|
|
84
|
+
*/
|
|
85
|
+
async update(id: string, patch: RequestPatch, onlyIf: { state?: RequestState; updatedAt?: number } = {}): Promise<RequestRecord | null> {
|
|
86
|
+
const set = PATCH.filter(([k]) => patch[k] !== undefined);
|
|
87
|
+
const args: unknown[] = set.map(([k]) => (k === 'data' ? JSON.stringify(patch.data) : patch[k]));
|
|
88
|
+
// strictly later than before, so two writes in one millisecond still differ
|
|
89
|
+
const sql = [...set.map(([, c]) => `${c}=?`), 'updated_at=MAX(?, updated_at + 1)'];
|
|
90
|
+
args.push(Date.now(), id);
|
|
91
|
+
const guard: string[] = [];
|
|
92
|
+
if (onlyIf.state) { guard.push('state=?'); args.push(onlyIf.state); }
|
|
93
|
+
if (onlyIf.updatedAt !== undefined) { guard.push('updated_at=?'); args.push(onlyIf.updatedAt); }
|
|
94
|
+
const rows = await this.all(`UPDATE event_requests SET ${sql.join(',')} WHERE id=?${guard.map((g) => ` AND ${g}`).join('')} RETURNING *`, args);
|
|
95
|
+
return rows[0] ? record(rows[0]) : null;
|
|
96
|
+
}
|
|
97
|
+
async delete(id: string): Promise<boolean> {
|
|
98
|
+
return (await this.all('DELETE FROM event_requests WHERE id=? RETURNING id', [id])).length > 0;
|
|
99
|
+
}
|
|
100
|
+
/** Deletes requests answered or withdrawn before `before` (ms); pending ones stay. Returns how many went. */
|
|
101
|
+
async prune(before: number): Promise<number> {
|
|
102
|
+
return (await this.all(`DELETE FROM event_requests WHERE state<>'pending' AND reviewed_at>0 AND reviewed_at<? RETURNING id`, [before])).length;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import type { CalEvent, EventRequest } from '../schema';
|
|
3
3
|
import { EventLocation } from '../schema';
|
|
4
|
-
import { ApiError } from '../api';
|
|
4
|
+
import { ApiError, coded } from '../api';
|
|
5
5
|
import { applyEventUpdate, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
|
|
6
6
|
import { CALENDAR } from './config';
|
|
7
7
|
import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
|
|
@@ -74,38 +74,38 @@ export function bodyToText(e: Pick<CalEvent, 'bodyHtml'>): string {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function requireMember(who: Actor | null): asserts who is Actor {
|
|
77
|
-
if (!who) throw
|
|
78
|
-
if (!who.can('events.request')) throw
|
|
77
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
78
|
+
if (!who.can('events.request')) throw coded(403, 'no-request-role', 'Your role doesn’t include “Request events”. Ask the site’s team if you need it.');
|
|
79
79
|
}
|
|
80
80
|
function requireApprover(who: Actor | null): asserts who is Actor {
|
|
81
|
-
if (!who) throw
|
|
81
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
82
82
|
if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which approving requests needs.');
|
|
83
83
|
}
|
|
84
84
|
/** a host of this event, or the member who asked for it */
|
|
85
85
|
export const isHost = (e: CalEvent, userId: string) => e.hostIds.includes(userId) || (e.request?.by === userId && e.request.kind === 'new');
|
|
86
86
|
async function hostedEvent(repo: ContentRepository, who: Actor, id: string) {
|
|
87
87
|
const e = await repo.getEvent(id);
|
|
88
|
-
if (!e || e.status === 'archived' || !isHost(e, who.id)) throw
|
|
88
|
+
if (!e || e.status === 'archived' || !isHost(e, who.id)) throw coded(404, 'not-found', 'Event not found.');
|
|
89
89
|
return e;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** the request form's input → event fields, validated (`current`: the event being edited, whose own values may be kept) */
|
|
93
93
|
async function clean(repo: ContentRepository, input: EventRequestInput, current?: CalEvent) {
|
|
94
94
|
const title = line(input.title, LIMITS.title);
|
|
95
|
-
if (!title) throw
|
|
95
|
+
if (!title) throw coded(400, 'title', 'Give the event a title.');
|
|
96
96
|
const start = text(input.start, 16), allDay = input.allDay === true;
|
|
97
|
-
if (!(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(start)) throw
|
|
97
|
+
if (!(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(start)) throw coded(400, 'start', 'Pick a date and start time.');
|
|
98
98
|
const end = text(input.end, 16);
|
|
99
|
-
if (end && !(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(end)) throw
|
|
100
|
-
if (end && end < start) throw
|
|
99
|
+
if (end && !(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(end)) throw coded(400, 'end', 'Pick a valid end time.');
|
|
100
|
+
if (end && end < start) throw coded(400, 'end-before-start', 'The end has to come after the start.');
|
|
101
101
|
const rrule = text(input.repeat, 80).toUpperCase();
|
|
102
102
|
// a pattern staff set (weekly on two days, yearly…) may be kept as it is
|
|
103
|
-
if (rrule && !REPEAT.test(rrule) && rrule !== current?.rrule.toUpperCase()) throw
|
|
103
|
+
if (rrule && !REPEAT.test(rrule) && rrule !== current?.rrule.toUpperCase()) throw coded(400, 'repeat', 'Choose how often it repeats from the list.');
|
|
104
104
|
// members pick among the public calendars; any other calendars staff put it in stay
|
|
105
105
|
const calendars = await repo.listCalendars();
|
|
106
106
|
const open = new Set(calendars.filter((c) => c.kind === 'native' && c.visibility === 'public').map((c) => c.id));
|
|
107
107
|
const chosen = input.calendarId ? String(input.calendarId) : '';
|
|
108
|
-
if (chosen && !open.has(chosen) && !current?.calendarIds.includes(chosen)) throw
|
|
108
|
+
if (chosen && !open.has(chosen) && !current?.calendarIds.includes(chosen)) throw coded(400, 'calendar', 'Choose a calendar from the list.');
|
|
109
109
|
const calendarIds = current && (!chosen || current.calendarIds.includes(chosen))
|
|
110
110
|
? current.calendarIds
|
|
111
111
|
: [...(current?.calendarIds ?? []).filter((id) => !open.has(id)), ...(chosen ? [chosen] : [])];
|
|
@@ -113,7 +113,7 @@ async function clean(repo: ContentRepository, input: EventRequestInput, current?
|
|
|
113
113
|
for (const f of CALENDAR.eventFields) {
|
|
114
114
|
if (f.private || input.fields?.[f.key] === undefined) continue;
|
|
115
115
|
const value = text(input.fields[f.key], LIMITS.field);
|
|
116
|
-
if (f.type === 'select' && value && !f.options.includes(value)) throw
|
|
116
|
+
if (f.type === 'select' && value && !f.options.includes(value)) throw Object.assign(coded(400, 'field', `Choose ${f.label} from the list.`), { label: f.label });
|
|
117
117
|
fields[f.key] = value; // '' clears it
|
|
118
118
|
}
|
|
119
119
|
return { title, start, end: end || undefined, allDay, rrule, calendarIds, fields, description: text(input.description, LIMITS.description), where: line(input.where, LIMITS.where) };
|
|
@@ -139,9 +139,9 @@ export async function pendingRequests(repo: ContentRepository): Promise<CalEvent
|
|
|
139
139
|
/** a member asks for a new event: a draft they host, waiting for approval */
|
|
140
140
|
export async function submitEventRequest(repo: ContentRepository, who: Actor | null, input: EventRequestInput, hooks: SaveHooks): Promise<CalEvent> {
|
|
141
141
|
requireMember(who);
|
|
142
|
-
if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw
|
|
142
|
+
if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of requests in an hour. Try again later.', 3600);
|
|
143
143
|
const open = (await hostedEvents(repo, who.id)).filter((e) => e.request?.state === 'pending').length;
|
|
144
|
-
if (open >= LIMITS.pending) throw
|
|
144
|
+
if (open >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
|
|
145
145
|
const c = await clean(repo, input);
|
|
146
146
|
const created = await repo.createEvent({
|
|
147
147
|
title: c.title, slug: await uniqueSlug(repo, c.title), start: c.start, allDay: c.allDay, status: 'draft',
|
|
@@ -165,8 +165,8 @@ export async function updateEventRequest(repo: ContentRepository, who: Actor | n
|
|
|
165
165
|
const e = await hostedEvent(repo, who, id);
|
|
166
166
|
// staff have unpublished edits of their own on this live event: don't mix a
|
|
167
167
|
// host's suggestion into them (declining it would throw both away)
|
|
168
|
-
if (live(e) && e.draft && !hostChanges(e)) throw
|
|
169
|
-
if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw
|
|
168
|
+
if (live(e) && e.draft && !hostChanges(e)) throw coded(409, 'staff-editing', 'The team is editing this event right now. Try again later.');
|
|
169
|
+
if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of changes in an hour. Try again later.', 3600);
|
|
170
170
|
// what the host sees is the event with their own waiting changes on top
|
|
171
171
|
const current = (hostChanges(e) ? { ...e, ...(e.draft ?? {}) } : e) as CalEvent;
|
|
172
172
|
const c = await clean(repo, input, current);
|
|
@@ -186,7 +186,7 @@ export async function updateEventRequest(repo: ContentRepository, who: Actor | n
|
|
|
186
186
|
export async function withdrawEventRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks): Promise<{ deleted: boolean }> {
|
|
187
187
|
requireMember(who);
|
|
188
188
|
const e = await hostedEvent(repo, who, id);
|
|
189
|
-
if (!e.request) throw
|
|
189
|
+
if (!e.request) throw coded(400, 'nothing-to-withdraw', 'There’s no request on this event to withdraw.');
|
|
190
190
|
// only an event the member's own request made is deleted; a draft staff wrote stays
|
|
191
191
|
if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
|
|
192
192
|
await repo.deleteEvent(id);
|
|
@@ -201,8 +201,8 @@ export async function withdrawEventRequest(repo: ContentRepository, who: Actor |
|
|
|
201
201
|
/** the request staff are answering, unchanged since they opened it (`seen`: its `at`) */
|
|
202
202
|
async function answerable(repo: ContentRepository, id: string, seen?: number) {
|
|
203
203
|
const e = await repo.getEvent(id);
|
|
204
|
-
if (!e?.request || e.request.state !== 'pending') throw
|
|
205
|
-
if (seen !== undefined && e.request.at !== seen) throw
|
|
204
|
+
if (!e?.request || e.request.state !== 'pending') throw coded(409, 'answered', 'This request was already answered or withdrawn.');
|
|
205
|
+
if (seen !== undefined && e.request.at !== seen) throw coded(409, 'changed', 'This request changed after you opened it. Look at it again, then answer.');
|
|
206
206
|
return e as CalEvent & { request: EventRequest };
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -231,25 +231,25 @@ export async function declineEventRequest(repo: ContentRepository, who: Actor |
|
|
|
231
231
|
|
|
232
232
|
/** a host clears a declined answer they've read (a declined new request stays as a draft) */
|
|
233
233
|
export async function dismissEventRequest(repo: ContentRepository, who: Actor | null, id: string): Promise<void> {
|
|
234
|
-
if (!who) throw
|
|
234
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
235
235
|
const e = await hostedEvent(repo, who, id);
|
|
236
236
|
if (e.request?.state === 'declined' && e.request.kind === 'changes') await repo.setEventRequest(id, null);
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/** hosts cancel or restore one date of their own event at once (no permission needed beyond hosting it) */
|
|
240
240
|
export async function setHostedOccurrence(repo: ContentRepository, who: Actor | null, id: string, key: string, cancelled: boolean, hooks: SaveHooks): Promise<CalEvent> {
|
|
241
|
-
if (!who) throw
|
|
241
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
242
242
|
const e = await hostedEvent(repo, who, id);
|
|
243
|
-
if (!e.hostIds.includes(who.id)) throw
|
|
243
|
+
if (!e.hostIds.includes(who.id)) throw coded(404, 'not-found', 'Event not found.');
|
|
244
244
|
return (cancelled ? cancelOccurrence : restoreOccurrence)(repo, id, key, hooksFor(hooks));
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/** staff set who hosts an event (Team members only) */
|
|
248
248
|
export async function setEventHosts(repo: ContentRepository, who: Actor | null, id: string, hostIds: string[]): Promise<CalEvent> {
|
|
249
|
-
if (!who) throw
|
|
249
|
+
if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
|
|
250
250
|
if (!who.can('content.edit')) throw new ApiError(403, 'Your role doesn’t include “Create and edit content”.');
|
|
251
251
|
const e = await repo.getEvent(id);
|
|
252
|
-
if (!e) throw
|
|
252
|
+
if (!e) throw coded(404, 'not-found', 'Event not found.');
|
|
253
253
|
const known = new Set((await repo.listUsers()).map((u) => u.id));
|
|
254
254
|
const ids = [...new Set(hostIds)].filter((h) => known.has(h)).slice(0, 10);
|
|
255
255
|
await repo.setEventHosts(id, ids);
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { formatDate } from './dates';
|
|
|
5
5
|
import { CONTENT, collectionUrl } from './content';
|
|
6
6
|
import { renderCalendarBlock, isNativeSource } from './calendar/render';
|
|
7
7
|
import { renderEventCards, type EventCardAttrs } from './calendar/cards';
|
|
8
|
+
import { localePath } from './i18n';
|
|
8
9
|
|
|
9
10
|
// Dynamic blocks: bodyHtml is a save-time cache, so cards whose content must
|
|
10
11
|
// stay fresh (post lists, calendars) serialize as placeholder divs and get
|
|
@@ -131,7 +132,8 @@ function eventCardAttrs(tag: string): EventCardAttrs {
|
|
|
131
132
|
};
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
// locale: the page's language; a form block opens the form in it (/es/forms/<id>)
|
|
136
|
+
export async function expandDynamicBlocks(html: string, repo: ContentRepository, locale = ''): Promise<string> {
|
|
135
137
|
html = stampHeadingAnchors(html);
|
|
136
138
|
const hasForms = html.includes('data-dyn="form"');
|
|
137
139
|
const hasPostList = html.includes('data-dyn="post-list"');
|
|
@@ -168,7 +170,7 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
168
170
|
// A same-origin frame gives each visitor an uncached form/receipt even
|
|
169
171
|
// when the containing content page is served from a shared cache.
|
|
170
172
|
calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
|
|
171
|
-
? `<iframe class="pc-form-embed" src="
|
|
173
|
+
? `<iframe class="pc-form-embed" src="${localePath(`/forms/${encodeURIComponent(form.id)}`, locale)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
|
|
172
174
|
}));
|
|
173
175
|
}
|
|
174
176
|
const anchored = html;
|
package/src/lib/email/service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError, type EventRequestMailKind } from './templates';
|
|
5
5
|
import { formatDate, siteLang } from '../dates';
|
|
6
6
|
import { I18N } from '../i18n';
|
|
7
7
|
import { bookingMail } from '../booking/service';
|
|
@@ -47,9 +47,19 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
47
47
|
return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
|
|
48
48
|
}
|
|
49
49
|
if (job.kind === 'event-request') {
|
|
50
|
-
const p = JSON.parse(job.payload) as { eventId: string; kind:
|
|
50
|
+
const p = JSON.parse(job.payload) as { eventId: string; requestId?: string; kind: EventRequestMailKind; at: number; reply: string; by?: string; note?: string; when?: string };
|
|
51
51
|
const e = await repo.getEvent(p.eventId);
|
|
52
52
|
if (!e) return null;
|
|
53
|
+
// a host's date request (lib/calendar/host-requests.ts): staff hear about it while it waits unchanged, the host about its answer
|
|
54
|
+
if (p.requestId) {
|
|
55
|
+
const r = await repo.requests.get(p.requestId);
|
|
56
|
+
const staff = p.kind.startsWith('staff');
|
|
57
|
+
if (!r || (staff ? r.state !== 'pending' || r.updatedAt !== p.at : r.state === 'pending' || r.state === 'withdrawn')) return null;
|
|
58
|
+
const by = r.userId ? await repo.getUser(r.userId) : null;
|
|
59
|
+
const { memberEventsPath, MEMBERS } = await import('../calendar/requests');
|
|
60
|
+
const url = staff ? `${origin}/admin/calendar/events?scope=requests` : `${origin}${MEMBERS.enabled ? memberEventsPath() : '/'}`;
|
|
61
|
+
return eventRequestMail(p.kind, job.recipient, settings.siteTitle, { title: e.title, when: p.when ?? '', by: by?.name ?? '', note: r.note }, p.reply, url);
|
|
62
|
+
}
|
|
53
63
|
// a host moved one date (lib/calendar/moves.ts): the words were fixed when it happened
|
|
54
64
|
if (p.kind === 'staff-moved') {
|
|
55
65
|
const by = p.by ? await repo.getUser(p.by) : null;
|
|
@@ -12,17 +12,27 @@ 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
|
+
/** the kinds of eventRequestMail */
|
|
16
|
+
export type EventRequestMailKind = 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined'
|
|
17
|
+
| 'staff-move-request' | 'move-approved' | 'move-declined' | 'staff-from-date-request' | 'from-date-approved' | 'from-date-declined';
|
|
15
18
|
/**
|
|
16
19
|
* members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered;
|
|
17
|
-
* staff-moved: a host moved one date (lib/calendar/moves.ts), `when` = "old → new"
|
|
20
|
+
* staff-moved: a host moved one date (lib/calendar/moves.ts), `when` = "old → new";
|
|
21
|
+
* a host's date move or "from this date on" request (lib/calendar/host-requests.ts): to staff, then its answer to the host
|
|
18
22
|
*/
|
|
19
|
-
export function eventRequestMail(kind:
|
|
23
|
+
export function eventRequestMail(kind: EventRequestMailKind, to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
|
|
20
24
|
const copy = {
|
|
21
25
|
'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'],
|
|
22
26
|
'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'],
|
|
23
27
|
'staff-moved': [`Date moved: ${event.title}`, `${event.by || 'A host'} moved one date of “${event.title}”: ${event.when}. People who signed up for it were emailed the new time.`, 'Open the event'],
|
|
24
28
|
approved: [`Approved: ${event.title}`, `Your event “${event.title}” (${event.when}) was approved and is now on the calendar.`, 'See your events'],
|
|
25
29
|
declined: [`Not approved: ${event.title}`, `Your request for “${event.title}” (${event.when}) wasn’t approved.`, 'See your events'],
|
|
30
|
+
'staff-move-request': [`Date move to review: ${event.title}`, `${event.by || 'A host'} asked to move one date of “${event.title}”: ${event.when}. Nothing changes until someone approves it.`, 'Review it'],
|
|
31
|
+
'move-approved': [`Date moved: ${event.title}`, `Your request to move one date of “${event.title}” (${event.when}) was approved. People who signed up were emailed the new time.`, 'See your events'],
|
|
32
|
+
'move-declined': [`Date not moved: ${event.title}`, `Your request to move one date of “${event.title}” (${event.when}) wasn’t approved. The date stays as it was.`, 'See your events'],
|
|
33
|
+
'staff-from-date-request': [`Schedule change to review: ${event.title}`, `${event.by || 'A host'} asked to change “${event.title}” from one date on: ${event.when}. Nothing changes until someone applies it.`, 'Review it'],
|
|
34
|
+
'from-date-approved': [`Schedule changed: ${event.title}`, `Your request to change “${event.title}” from one date on (${event.when}) was applied. People who signed up were emailed their new dates.`, 'See your events'],
|
|
35
|
+
'from-date-declined': [`Schedule not changed: ${event.title}`, `Your request to change “${event.title}” from one date on (${event.when}) wasn’t approved. The schedule stays as it was.`, 'See your events'],
|
|
26
36
|
}[kind];
|
|
27
37
|
const extra = kind.startsWith('staff') ? (event.note ? `Their note: “${event.note}”` : '') : reply ? `From the team: “${reply}”` : '';
|
|
28
38
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(extra ? ['', extra] : []), '', `${copy[2]}: ${url}`].join('\n'),
|