@acms-subir/core 0.3.55 → 0.3.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/docs/calendar.md +75 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +51 -0
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/editor/event/SingleDates.tsx +12 -2
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/request-admin.ts +211 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/types.ts +1 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +43 -2
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +56 -1
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { RequestRecord, Settings } from '../schema';
|
|
3
|
+
import { requestWhen } from './request-mail';
|
|
4
|
+
import { requestSettings, type NewEventDetails, type RequestInput } from './request-service';
|
|
5
|
+
import { requestStrings } from './request-words';
|
|
6
|
+
import { fill } from '../live/words';
|
|
7
|
+
|
|
8
|
+
// The built-in request form (pages/request/index.astro): what the posted
|
|
9
|
+
// form means as a request, and the "Request sent" summary. A site's own form
|
|
10
|
+
// can use the server API instead.
|
|
11
|
+
|
|
12
|
+
const str = (data: FormData, name: string) => String(data.get(name) ?? '');
|
|
13
|
+
|
|
14
|
+
/** The posted form as RequestInput (the date and times come as separate fields). */
|
|
15
|
+
export function formRequest(data: FormData, locale: string): RequestInput {
|
|
16
|
+
const kind = str(data, 'kind') === 'change' ? 'change' : 'new';
|
|
17
|
+
const common = { name: str(data, 'name'), email: str(data, 'email'), phone: str(data, 'phone'), locale, website: str(data, 'website') };
|
|
18
|
+
if (kind === 'change') return { kind, event: str(data, 'event'), date: str(data, 'onDate'), message: str(data, 'whatChange'), ...common };
|
|
19
|
+
const date = str(data, 'date');
|
|
20
|
+
const repeat = str(data, 'repeat');
|
|
21
|
+
const people = Number.parseInt(str(data, 'people'), 10);
|
|
22
|
+
return {
|
|
23
|
+
kind, type: str(data, 'type'), title: str(data, 'title'),
|
|
24
|
+
start: date && str(data, 'start') ? `${date}T${str(data, 'start')}` : date, end: str(data, 'end'),
|
|
25
|
+
repeat: repeat === 'weekly' || repeat === 'other' ? repeat : 'once', repeatText: str(data, 'repeatText'),
|
|
26
|
+
rooms: data.getAll('rooms').map(String).slice(0, 10), roomsUnsure: data.get('roomsUnsure') === '1',
|
|
27
|
+
people: Number.isFinite(people) && people > 0 ? Math.min(people, 1_000_000) : null, message: str(data, 'message'), ...common,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** "Request sent": the request as the person sent it, in their language. */
|
|
32
|
+
export async function sentSummary(repo: ContentRepository, q: RequestRecord, settings: Settings, lang: string, locale: string): Promise<{ title: string; line: string }> {
|
|
33
|
+
const w = requestStrings(lang);
|
|
34
|
+
if (q.kind === 'change') {
|
|
35
|
+
const e = await repo.getEvent(q.eventId);
|
|
36
|
+
const day = q.key ? new Date(`${q.key.slice(0, 10)}T12:00:00Z`) : null;
|
|
37
|
+
let date = w.everyDate;
|
|
38
|
+
if (day) { try { date = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(day); } catch { date = q.key.slice(0, 10); } }
|
|
39
|
+
return { title: fill(w.changeTo, { title: e?.title ?? String(q.data.title ?? '') }), line: date };
|
|
40
|
+
}
|
|
41
|
+
const d = q.data as unknown as NewEventDetails;
|
|
42
|
+
const rooms = await repo.listResources();
|
|
43
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
44
|
+
const line = [
|
|
45
|
+
requestWhen(d.start, d.end, settings, locale),
|
|
46
|
+
d.repeat === 'weekly' ? w.weekly : d.repeat === 'other' ? d.repeatText : '',
|
|
47
|
+
d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
|
|
48
|
+
d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
|
|
49
|
+
type ? (lang && type.translations[lang]) || type.label : '',
|
|
50
|
+
].filter(Boolean).join(' · ');
|
|
51
|
+
return { title: d.title, line };
|
|
52
|
+
}
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { RequestRecord, RoomHold, type CalEvent, type RequestSettings, type Resource, type Settings } from '../schema';
|
|
4
|
+
import { ApiError, coded } from '../api';
|
|
5
|
+
import { sha256Hex } from '../api-keys';
|
|
6
|
+
import { validAddress } from '../email/config';
|
|
7
|
+
import { kickEmail } from '../email/service';
|
|
8
|
+
import { I18N } from '../i18n';
|
|
9
|
+
import { PUSH } from '../push/config';
|
|
10
|
+
import { isStaffRole } from '../roles';
|
|
11
|
+
import { waitUntilFrom } from '../webhooks';
|
|
12
|
+
import { saveEvent, type SaveHooks } from '../content-service';
|
|
13
|
+
import { roomBusy } from '../booking/holds';
|
|
14
|
+
import { REQUESTS } from './request-config';
|
|
15
|
+
import { MEMBERS } from './members-config';
|
|
16
|
+
import { eventIsPublic, expandEvents, findOccurrence, parseWall } from './events';
|
|
17
|
+
import { zonedToUtc } from './ics';
|
|
18
|
+
import { uniqueSlug } from './schedule';
|
|
19
|
+
import type { Actor } from './requests';
|
|
20
|
+
|
|
21
|
+
// Event requests from people without an account (acms({ requests })): anyone
|
|
22
|
+
// asks for a new event, or a change to one, from the app (POST
|
|
23
|
+
// /api/v1/requests) or the website's form. Each is a row of its own
|
|
24
|
+
// (RequestRecord, kind 'new' or 'change'; lib/calendar/request-store.ts).
|
|
25
|
+
// Staff answer on Calendar → Requests: approving a new one makes a filled-in
|
|
26
|
+
// draft (its type's tag and calendar, the rooms held); approving a change
|
|
27
|
+
// opens the event, or that date's Move… dialog. Reply and Decline email the
|
|
28
|
+
// person in the language they asked in. Hosts' date moves (kind 'move' and
|
|
29
|
+
// 'from-date') share the table and these answers.
|
|
30
|
+
//
|
|
31
|
+
// The rules live here, so the built-in form, the app API and a site's own
|
|
32
|
+
// pages (through @acms-subir/core/server) share them.
|
|
33
|
+
|
|
34
|
+
const DAY = 86_400_000;
|
|
35
|
+
export const REQUEST_LIMITS = { title: 200, message: 2000, repeatText: 200, name: 120, phone: 40, people: 100_000, perMinute: 10, perAddressDay: 20, perEmailDay: 5, mailsPerDay: 3 };
|
|
36
|
+
|
|
37
|
+
/** Why a request was refused (error.code, with error.status). The built-in form shows each in the visitor's language. */
|
|
38
|
+
export type RequestRefusal = 'closed' | 'trap' | 'busy' | 'full' | 'changed' | 'title' | 'type' | 'start' | 'end' | 'past' | 'far'
|
|
39
|
+
| 'repeat' | 'room' | 'people' | 'event' | 'date' | 'what' | 'name' | 'email' | 'phone';
|
|
40
|
+
const refuse = (status: number, code: RequestRefusal, message: string, retryAfter?: number) => coded(status, code, message, retryAfter);
|
|
41
|
+
|
|
42
|
+
/** what the app or a form sends (POST /api/v1/requests) */
|
|
43
|
+
export const RequestInput = z.object({
|
|
44
|
+
kind: z.enum(['new', 'change']),
|
|
45
|
+
// new: one of the site's types (GET /api/v1/requests/options), a title, when (site-local
|
|
46
|
+
// YYYY-MM-DDTHH:mm; the end may be just HH:mm: that day, or the next when it's before the start), how it repeats, rooms, people
|
|
47
|
+
type: z.string().max(40).optional(),
|
|
48
|
+
title: z.string().max(400).optional(),
|
|
49
|
+
start: z.string().max(25).optional(),
|
|
50
|
+
end: z.string().max(25).optional(),
|
|
51
|
+
repeat: z.enum(['once', 'weekly', 'other']).optional(),
|
|
52
|
+
repeatText: z.string().max(400).optional(),
|
|
53
|
+
rooms: z.array(z.string().max(200)).max(10).optional(),
|
|
54
|
+
roomsUnsure: z.boolean().optional(),
|
|
55
|
+
people: z.number().int().min(0).max(1_000_000).nullable().optional(),
|
|
56
|
+
// change: the event's slug, and one date of it (YYYY-MM-DD) or none for every date
|
|
57
|
+
event: z.string().max(200).optional(),
|
|
58
|
+
date: z.string().max(25).optional(),
|
|
59
|
+
// new: anything else; change: what should change (required)
|
|
60
|
+
message: z.string().max(4000).optional(),
|
|
61
|
+
name: z.string().max(400),
|
|
62
|
+
email: z.string().max(400),
|
|
63
|
+
phone: z.string().max(100).optional(),
|
|
64
|
+
// the language they asked in (a site language; anything else is the main one)
|
|
65
|
+
locale: z.string().max(35).optional(),
|
|
66
|
+
// spam trap: apps leave it out or empty
|
|
67
|
+
website: z.string().max(200).optional(),
|
|
68
|
+
}).strict();
|
|
69
|
+
export type RequestInput = z.infer<typeof RequestInput>;
|
|
70
|
+
|
|
71
|
+
/** a new event's details as stored (RequestRecord.data) */
|
|
72
|
+
export interface NewEventDetails {
|
|
73
|
+
type: string; title: string; start: string; end: string;
|
|
74
|
+
repeat: 'once' | 'weekly' | 'other'; repeatText: string;
|
|
75
|
+
roomIds: string[]; roomsUnsure: boolean; people: number | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const text = (v: unknown, max: number) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, '').trim().slice(0, max);
|
|
79
|
+
const line = (v: unknown, max: number) => text(v, max).replace(/\s+/g, ' ');
|
|
80
|
+
const WALL = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
|
81
|
+
const wallAt = (s: string, tz: string) => { const w = parseWall(s)!; return zonedToUtc(w.y, w.m, w.d, w.hh, w.mm, 0, tz); };
|
|
82
|
+
const validWall = (s: string) => {
|
|
83
|
+
const w = WALL.test(s) ? parseWall(s) : null;
|
|
84
|
+
return !!w && w.m >= 1 && w.m <= 12 && w.d >= 1 && w.d <= new Date(Date.UTC(w.y, w.m, 0)).getUTCDate() && w.hh < 24 && w.mm < 60;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** the request settings in effect (Calendar → Requests) */
|
|
88
|
+
export const requestSettings = (settings: Settings): RequestSettings => settings.calendar.requests;
|
|
89
|
+
|
|
90
|
+
/** a site language other than the main one, else '' (the main language) */
|
|
91
|
+
export function requestLocale(raw: string | undefined): string {
|
|
92
|
+
const want = String(raw ?? '').trim().toLowerCase();
|
|
93
|
+
if (!want || !I18N.enabled) return '';
|
|
94
|
+
const hit = I18N.locales.find((l) => l.toLowerCase() === want) ?? I18N.locales.find((l) => l.toLowerCase() === want.split('-')[0]);
|
|
95
|
+
return hit && hit !== I18N.defaultLocale ? hit : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** rooms anyone may ask for (Resource.requestable), in the admin's order */
|
|
99
|
+
export async function requestableRooms(repo: ContentRepository): Promise<Resource[]> {
|
|
100
|
+
return (await repo.listResources()).filter((r) => r.requestable).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** an event "Change to an event" offers: its next dates (site-local YYYY-MM-DD) */
|
|
104
|
+
export interface ChangeChoice { slug: string; title: string; dates: string[] }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Public events with a date in the next year, by title (at most 300), each
|
|
108
|
+
* with its dates in the next 120 days (at most 30): what a change can be about.
|
|
109
|
+
*/
|
|
110
|
+
export async function changeChoices(repo: ContentRepository, settings: Settings, now = Date.now()): Promise<ChangeChoice[]> {
|
|
111
|
+
const calendars = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
|
|
112
|
+
const events = (await repo.listEvents()).filter((e) => eventIsPublic(e, calendars) && !e.cancelled);
|
|
113
|
+
const tz = settings.timezone || 'UTC';
|
|
114
|
+
const day = (o: { start: number; allDay: boolean; key: string }) => (o.allDay ? o.key.slice(0, 10) : new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(o.start));
|
|
115
|
+
const out = new Map<string, ChangeChoice>();
|
|
116
|
+
for (const o of expandEvents(events, settings, { displayTz: tz, now, windowStartMs: now, windowEndMs: now + 366 * DAY })) {
|
|
117
|
+
if (o.cancelled && !o.event.rrule) continue;
|
|
118
|
+
const c = out.get(o.event.id) ?? out.set(o.event.id, { slug: o.event.slug, title: o.event.title, dates: [] }).get(o.event.id)!;
|
|
119
|
+
const d = day(o);
|
|
120
|
+
if (!o.cancelled && o.start < now + 120 * DAY && c.dates.length < 30 && !c.dates.includes(d)) c.dates.push(d);
|
|
121
|
+
}
|
|
122
|
+
return [...out.values()].sort((a, b) => a.title.localeCompare(b.title)).slice(0, 300);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** a type's label in this language ('' = the main language) */
|
|
126
|
+
const typeLabel = (t: RequestSettings['types'][number], locale: string) => (locale && t.translations[locale]) || t.label;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What the form needs (GET /api/v1/requests/options): whether requests are
|
|
130
|
+
* open, the types, rooms and events to choose from, and the site's words (in
|
|
131
|
+
* this language where they're translated). The phone number is there for
|
|
132
|
+
* when requests are closed.
|
|
133
|
+
*/
|
|
134
|
+
export async function requestOptions(repo: ContentRepository, locale = '', now = Date.now()) {
|
|
135
|
+
const settings = await repo.getSettings();
|
|
136
|
+
const rq = requestSettings(settings);
|
|
137
|
+
const lang = requestLocale(locale);
|
|
138
|
+
const words = (lang && rq.translations[lang]) || null;
|
|
139
|
+
return {
|
|
140
|
+
open: rq.open,
|
|
141
|
+
types: rq.types.map((t) => ({ id: t.id, label: typeLabel(t, lang) })),
|
|
142
|
+
rooms: (await requestableRooms(repo)).map((r) => ({ slug: r.slug, name: r.name, color: /^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '' })),
|
|
143
|
+
replyTime: words?.replyTime || rq.replyTime,
|
|
144
|
+
notice: words?.notice || rq.notice,
|
|
145
|
+
phone: rq.phone,
|
|
146
|
+
timezone: settings.timezone || 'UTC',
|
|
147
|
+
events: rq.open ? await changeChoices(repo, settings, now) : [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- asking -------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** the cleaned request, or why not: what gets stored */
|
|
154
|
+
async function clean(repo: ContentRepository, input: RequestInput, settings: Settings, now: number) {
|
|
155
|
+
const rq = requestSettings(settings);
|
|
156
|
+
const tz = settings.timezone || 'UTC';
|
|
157
|
+
const name = line(input.name, REQUEST_LIMITS.name);
|
|
158
|
+
if (!name) throw refuse(400, 'name', 'Add your name.');
|
|
159
|
+
const email = text(input.email, 254).toLowerCase();
|
|
160
|
+
if (!validAddress(email)) throw refuse(400, 'email', 'Add a valid email address.');
|
|
161
|
+
const phone = line(input.phone, REQUEST_LIMITS.phone);
|
|
162
|
+
if (phone && (!/^[+\d\s().\-–#*x]+$/i.test(phone) || (phone.match(/\d/g)?.length ?? 0) < 5)) throw refuse(400, 'phone', 'Check the phone number, or leave it empty.');
|
|
163
|
+
const requester = { name, email, phone, locale: requestLocale(input.locale) };
|
|
164
|
+
|
|
165
|
+
if (input.kind === 'change') {
|
|
166
|
+
const slug = text(input.event, 200);
|
|
167
|
+
const e = slug ? await repo.getEventBySlug(slug) : null;
|
|
168
|
+
const calendars = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
|
|
169
|
+
if (!e || !eventIsPublic(e, calendars)) throw refuse(400, 'event', 'Choose an event from the list.');
|
|
170
|
+
const key = text(input.date, 25) ? dateKey(e, text(input.date, 25), settings, now) : '';
|
|
171
|
+
const note = text(input.message, REQUEST_LIMITS.message);
|
|
172
|
+
if (!note) throw refuse(400, 'what', 'Tell us what should change.');
|
|
173
|
+
return { kind: 'change' as const, requester, eventId: e.id, key, note, data: { title: e.title } as Record<string, unknown> };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const title = line(input.title, REQUEST_LIMITS.title);
|
|
177
|
+
if (!title) throw refuse(400, 'title', 'Say what you’re planning.');
|
|
178
|
+
const type = text(input.type, 40);
|
|
179
|
+
if (rq.types.length ? !rq.types.some((t) => t.id === type) : type) throw refuse(400, 'type', 'Choose a kind from the list.');
|
|
180
|
+
const start = text(input.start, 16);
|
|
181
|
+
if (!validWall(start)) throw refuse(400, 'start', 'Pick a date and a start time.');
|
|
182
|
+
let end = text(input.end, 16);
|
|
183
|
+
// just a time: that day, or the next when it's before the start (20:00 → 01:00)
|
|
184
|
+
if (/^\d{2}:\d{2}$/.test(end)) end = `${end >= start.slice(11) ? start.slice(0, 10) : shiftDays(start, 1).slice(0, 10)}T${end}`;
|
|
185
|
+
if (!validWall(end)) throw refuse(400, 'end', 'Pick an end time.');
|
|
186
|
+
const startAt = wallAt(start, tz), endAt = wallAt(end, tz);
|
|
187
|
+
if (endAt <= startAt) throw refuse(400, 'end', 'The end has to come after the start.');
|
|
188
|
+
if (endAt - startAt > DAY) throw refuse(400, 'end', 'Keep it within one day, and tell us more in your message.');
|
|
189
|
+
if (startAt <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed.');
|
|
190
|
+
if (startAt > now + 2 * 366 * DAY) throw refuse(400, 'far', 'Pick a date within the next two years.');
|
|
191
|
+
const repeat = input.repeat ?? 'once';
|
|
192
|
+
const repeatText = repeat === 'other' ? line(input.repeatText, REQUEST_LIMITS.repeatText) : '';
|
|
193
|
+
if (repeat === 'other' && !repeatText) throw refuse(400, 'repeat', 'Tell us how it repeats.');
|
|
194
|
+
const rooms = await requestableRooms(repo);
|
|
195
|
+
const roomIds: string[] = [];
|
|
196
|
+
const roomsUnsure = input.roomsUnsure === true;
|
|
197
|
+
for (const slug of roomsUnsure ? [] : input.rooms ?? []) {
|
|
198
|
+
const room = rooms.find((r) => r.slug === slug);
|
|
199
|
+
if (!room) throw refuse(400, 'room', 'Choose a room from the list.');
|
|
200
|
+
if (!roomIds.includes(room.id)) roomIds.push(room.id);
|
|
201
|
+
}
|
|
202
|
+
const people = input.people || null;
|
|
203
|
+
if (people !== null && people > REQUEST_LIMITS.people) throw refuse(400, 'people', 'Check the number of people.');
|
|
204
|
+
const data: NewEventDetails = { type, title, start, end, repeat, repeatText, roomIds, roomsUnsure, people };
|
|
205
|
+
return { kind: 'new' as const, requester, eventId: '', key: '', note: text(input.message, REQUEST_LIMITS.message), data: data as unknown as Record<string, unknown> };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** the occurrence key of an event's date (YYYY-MM-DD, or a key) that hasn't passed */
|
|
209
|
+
function dateKey(e: CalEvent, date: string, settings: Settings, now: number): string {
|
|
210
|
+
const day = date.slice(0, 10);
|
|
211
|
+
const tz = settings.timezone || 'UTC';
|
|
212
|
+
const w = /^\d{4}-\d{2}-\d{2}$/.test(day) ? parseWall(day) : null;
|
|
213
|
+
if (!w) throw refuse(400, 'date', 'Pick the date from the event’s dates.');
|
|
214
|
+
const from = zonedToUtc(w.y, w.m, w.d, 0, 0, 0, tz);
|
|
215
|
+
if (from + DAY <= now) throw refuse(400, 'past', 'Pick a date that hasn’t passed.');
|
|
216
|
+
// an all-day date is keyed by its day (apps send all-day dates as that day too)
|
|
217
|
+
if (e.allDay) { const o = findOccurrence(e, day, settings); if (o) return o.key; }
|
|
218
|
+
// a date that happens that day (a moved one at its new day), else one moved away from it
|
|
219
|
+
const hit = expandEvents([e], settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: from + DAY }).find((o) => o.start >= from && o.start < from + DAY);
|
|
220
|
+
const key = hit?.key ?? Object.keys(e.movedDates ?? {}).find((k) => k.slice(0, 10) === day);
|
|
221
|
+
if (!key) throw refuse(400, 'date', 'That event doesn’t happen on that date.');
|
|
222
|
+
return key;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What was sent, as a hash (RequestRecord.data.sent): a retry must send the
|
|
227
|
+
* same, while the event, the clock and the rooms may have moved on since.
|
|
228
|
+
*/
|
|
229
|
+
async function fingerprint(input: RequestInput): Promise<string> {
|
|
230
|
+
const { website: _, ...sent } = input;
|
|
231
|
+
return sha256Hex(JSON.stringify(Object.keys(sent).sort().map((k) => [k, sent[k as keyof typeof sent]])));
|
|
232
|
+
}
|
|
233
|
+
/** the client address for daily limits: an IPv6 address by its /64, which one connection usually owns */
|
|
234
|
+
function addressBucket(address: string) {
|
|
235
|
+
const a = address.trim().toLowerCase();
|
|
236
|
+
if (!a.includes(':') || /^::ffff:\d/.test(a)) return a.replace(/^::ffff:/, '');
|
|
237
|
+
const [head, tail = ''] = a.split('::');
|
|
238
|
+
const left = head ? head.split(':') : [], right = tail ? tail.split(':') : [];
|
|
239
|
+
const full = a.includes('::') ? [...left, ...Array(Math.max(0, 8 - left.length - right.length)).fill('0'), ...right] : left;
|
|
240
|
+
return full.slice(0, 4).map((h) => h.replace(/^0+(?=.)/, '')).join(':') + '::/64';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface SubmitContext {
|
|
244
|
+
source: 'app' | 'web';
|
|
245
|
+
/** a UUID v4 the sender keeps for retries (the app's Idempotency-Key, the form's hidden id): it becomes the request's id */
|
|
246
|
+
key: string;
|
|
247
|
+
/** the client address (rate limits) */
|
|
248
|
+
address: string;
|
|
249
|
+
locals?: unknown;
|
|
250
|
+
now?: number;
|
|
251
|
+
}
|
|
252
|
+
const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Someone without an account asks for a new event or a change. A retry with
|
|
256
|
+
* the same key answers as before (and costs nothing); the same key with other
|
|
257
|
+
* details is a 409. New requests: 10 a minute per address and the site's
|
|
258
|
+
* daily cap. They get an email saying it arrived (without their own words,
|
|
259
|
+
* so the form can't send someone else a message), and staff get an alert.
|
|
260
|
+
*/
|
|
261
|
+
export async function submitRequest(repo: ContentRepository, raw: unknown, ctx: SubmitContext): Promise<{ id: string; created: boolean; request: RequestRecord }> {
|
|
262
|
+
if (!REQUESTS.enabled) throw new ApiError(404, 'Not found.');
|
|
263
|
+
if (!UUID4.test(ctx.key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
|
|
264
|
+
const input = RequestInput.parse(raw);
|
|
265
|
+
if (input.website) throw refuse(400, 'trap', 'Could not send this request.');
|
|
266
|
+
const id = ctx.key.toLowerCase();
|
|
267
|
+
const sent = await fingerprint(input);
|
|
268
|
+
// a retry answers as before, whatever has changed since, and costs nothing
|
|
269
|
+
const replay = (prior: RequestRecord) => {
|
|
270
|
+
if (prior.data.sent !== sent) throw refuse(409, 'changed', 'This request was already sent with other details. Reload to send a new one.');
|
|
271
|
+
return { id, created: false, request: prior };
|
|
272
|
+
};
|
|
273
|
+
const prior = await repo.requests.get(id);
|
|
274
|
+
if (prior) return replay(prior);
|
|
275
|
+
const bucket = await sha256Hex(addressBucket(ctx.address));
|
|
276
|
+
if (!await repo.email.rateLimit(`requests:ip:${bucket}`, REQUEST_LIMITS.perMinute, 60_000)) throw refuse(429, 'busy', 'Too many requests. Try again in a minute.');
|
|
277
|
+
const now = ctx.now ?? Date.now();
|
|
278
|
+
const settings = await repo.getSettings();
|
|
279
|
+
const rq = requestSettings(settings);
|
|
280
|
+
// closed: say so before anything about the fields
|
|
281
|
+
if (!rq.open) throw refuse(409, 'closed', rq.phone ? `We aren’t taking requests here right now. Call ${rq.phone}.` : 'We aren’t taking requests here right now.');
|
|
282
|
+
const c = await clean(repo, input, settings, now);
|
|
283
|
+
// a day's worth per sender (address and email) before the site's own cap, so one sender can't use it all up
|
|
284
|
+
const full = () => refuse(429, 'full', 'We’ve had a lot of requests today. Please try again tomorrow.', 86_400);
|
|
285
|
+
if (!await repo.email.rateLimit(`requests:ipday:${bucket}`, REQUEST_LIMITS.perAddressDay, DAY)) throw full();
|
|
286
|
+
if (!await repo.email.rateLimit(`requests:email:${await sha256Hex(c.requester.email)}`, REQUEST_LIMITS.perEmailDay, DAY)) throw full();
|
|
287
|
+
if (rq.perDay && !await repo.email.rateLimit('requests:day', rq.perDay, DAY)) throw full();
|
|
288
|
+
const record = RequestRecord.parse({ id, ...c, data: { ...c.data, sent }, state: 'pending', source: ctx.source, createdAt: now, updatedAt: now });
|
|
289
|
+
const saved = await repo.requests.create(record);
|
|
290
|
+
if (!saved) {
|
|
291
|
+
// the same key, sent twice at once: the first one won
|
|
292
|
+
const won = await repo.requests.get(id);
|
|
293
|
+
if (!won) throw refuse(409, 'changed', 'This request was already sent with other details. Reload to send a new one.');
|
|
294
|
+
return replay(won);
|
|
295
|
+
}
|
|
296
|
+
// a problem with emails never loses a request
|
|
297
|
+
await announce(repo, saved, ctx.locals).catch((e) => console.error('acms requests: alerts failed', e));
|
|
298
|
+
return { id, created: true, request: saved };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- telling people ----------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/** what a queued request email carries: ids and the kind, plus staff's words for replies */
|
|
304
|
+
export interface RequestMailPayload {
|
|
305
|
+
requestId: string;
|
|
306
|
+
kind: 'received' | 'staff-new' | 'reply' | 'approved' | 'declined';
|
|
307
|
+
/** the request's updatedAt when it was queued (staff alerts skip one answered since) */
|
|
308
|
+
at: number;
|
|
309
|
+
message?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function enqueue(repo: ContentRepository, q: RequestRecord, to: string[], kind: RequestMailPayload['kind'], message = '') {
|
|
313
|
+
if (!to.length) return;
|
|
314
|
+
const version = (await repo.email.config().catch(() => null))?.version ?? 0;
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
for (const recipient of to) {
|
|
317
|
+
const payload: RequestMailPayload = { requestId: q.id, kind, at: q.updatedAt, ...(message ? { message } : {}) };
|
|
318
|
+
// a reply is its own email each time; the rest go once
|
|
319
|
+
const dedupe = `calendar-request:${q.id}:${kind}${kind === 'reply' ? `:${now}` : ''}:${recipient}`;
|
|
320
|
+
await repo.email.enqueue({ dedupe, kind: 'calendar-request', recipient, payload: JSON.stringify(payload), configVersion: version, expiresAt: now + 7 * DAY });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** who hears about new requests: Calendar → Requests' list, else acms({ members.notify }), else the owners */
|
|
325
|
+
export async function requestRecipients(repo: ContentRepository, settings: Settings): Promise<string[]> {
|
|
326
|
+
const rq = requestSettings(settings);
|
|
327
|
+
const list = rq.notify.length ? rq.notify : MEMBERS.notify;
|
|
328
|
+
if (list.length) return [...new Set(list.map((e) => e.trim().toLowerCase()))].filter(validAddress);
|
|
329
|
+
return [...new Set((await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email.trim().toLowerCase()))];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** the person who asked: their email, and the language for it */
|
|
333
|
+
async function requesterAddress(repo: ContentRepository, q: RequestRecord): Promise<string> {
|
|
334
|
+
if (q.requester) return q.requester.email;
|
|
335
|
+
const user = q.userId ? await repo.getUser(q.userId) : null;
|
|
336
|
+
return user?.email ?? '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Staff hear about a new request: an email to each address on the list and,
|
|
341
|
+
* when Calendar → Requests says so, a push to the linked phones of staff on
|
|
342
|
+
* that list (saying only that one arrived, and for when). Hosts' move
|
|
343
|
+
* requests call this too.
|
|
344
|
+
*/
|
|
345
|
+
export async function requestAlert(repo: ContentRepository, q: RequestRecord, locals?: unknown) {
|
|
346
|
+
const settings = await repo.getSettings();
|
|
347
|
+
const to = await requestRecipients(repo, settings);
|
|
348
|
+
await enqueue(repo, q, to, 'staff-new');
|
|
349
|
+
const waitUntil = waitUntilFrom(locals);
|
|
350
|
+
await kickEmail(repo, locals, waitUntil).catch(() => {});
|
|
351
|
+
if (!requestSettings(settings).phones || !PUSH.enabled) return;
|
|
352
|
+
const push = alertPhones(repo, q, to, settings, locals).catch((e) => console.error('acms requests: push failed', e));
|
|
353
|
+
if (waitUntil) waitUntil(push); else await push;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function alertPhones(repo: ContentRepository, q: RequestRecord, to: string[], settings: Settings, locals: unknown) {
|
|
357
|
+
const users = (await repo.listUsers()).filter((u) => u.email && isStaffRole(u.role) && to.includes(u.email.trim().toLowerCase()));
|
|
358
|
+
const devices = (await Promise.all(users.map((u) => repo.push.staffDevices(u.id)))).flat();
|
|
359
|
+
if (!devices.length) return;
|
|
360
|
+
const [{ sendNotification }, { requestPush }] = await Promise.all([import('../push/service'), import('./request-mail')]);
|
|
361
|
+
// no link (the app can't open the admin) and nothing the person typed
|
|
362
|
+
await sendNotification(repo, { ...await requestPush(repo, q, settings), url: '', audience: [], devices: devices.map((d) => d.id),
|
|
363
|
+
locale: null, source: 'request', sourceKey: `request:${q.id}` }, { locals });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** a new request from the form or the app: "we got it" to the person (a few a day per address), and the staff alert */
|
|
367
|
+
async function announce(repo: ContentRepository, q: RequestRecord, locals?: unknown) {
|
|
368
|
+
const email = q.requester?.email;
|
|
369
|
+
if (email && await repo.email.rateLimit(`requests:mail:${await sha256Hex(email)}`, REQUEST_LIMITS.mailsPerDay, DAY)) await enqueue(repo, q, [email], 'received');
|
|
370
|
+
await requestAlert(repo, q, locals);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- answering ---------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
function requireAnswerer(who: Actor | null): asserts who is Actor {
|
|
376
|
+
if (!who) throw new ApiError(401, 'Sign in to continue.');
|
|
377
|
+
if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which answering requests needs.');
|
|
378
|
+
}
|
|
379
|
+
async function pendingRequest(repo: ContentRepository, id: string, seen?: number) {
|
|
380
|
+
const q = await repo.requests.get(id);
|
|
381
|
+
if (!q) throw new ApiError(404, 'Request not found.');
|
|
382
|
+
if (q.state !== 'pending') throw new ApiError(409, 'This request was already answered or withdrawn.');
|
|
383
|
+
if (seen !== undefined && q.updatedAt !== seen) throw new ApiError(409, 'This request changed after you opened it. Look at it again, then answer.');
|
|
384
|
+
return q;
|
|
385
|
+
}
|
|
386
|
+
/** state change as a compare-and-set: someone else answering first gets the 409 */
|
|
387
|
+
async function decide(repo: ContentRepository, q: RequestRecord, patch: Parameters<ContentRepository['requests']['update']>[1]) {
|
|
388
|
+
const done = await repo.requests.update(q.id, patch, { state: 'pending', updatedAt: q.updatedAt });
|
|
389
|
+
if (!done) throw new ApiError(409, 'This request was answered or changed while you looked at it. Look at it again.');
|
|
390
|
+
return done;
|
|
391
|
+
}
|
|
392
|
+
async function tell(repo: ContentRepository, q: RequestRecord, kind: 'reply' | 'approved' | 'declined', message: string, hooks: Pick<SaveHooks, 'locals'>) {
|
|
393
|
+
const to = await requesterAddress(repo, q);
|
|
394
|
+
if (!to) return;
|
|
395
|
+
await enqueue(repo, q, [to], kind, message);
|
|
396
|
+
await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface Answered {
|
|
400
|
+
request: RequestRecord;
|
|
401
|
+
/** the draft a new request became, or the event a change is about (null when it's gone) */
|
|
402
|
+
event: CalEvent | null;
|
|
403
|
+
/** where staff go next: the draft, the event (with ?move=<key> for one date), else the list */
|
|
404
|
+
next: string;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Staff approve. A host's date move runs (lib/calendar/host-requests.ts,
|
|
409
|
+
* which emails the host). A new event becomes a draft that's already filled in (its
|
|
410
|
+
* title and times, weekly when asked, its type's tag and calendar, the rooms
|
|
411
|
+
* held while it's a draft), never with the person's details; a change keeps
|
|
412
|
+
* the event as it is and points staff at it. The person gets an email (with
|
|
413
|
+
* `message`, if any) unless `email` is false. `seen`: the request's
|
|
414
|
+
* updatedAt staff reviewed, so a later edit isn't approved unseen.
|
|
415
|
+
*/
|
|
416
|
+
export async function approveRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks, seen?: number, opts: { message?: string; email?: boolean } = {}): Promise<Answered> {
|
|
417
|
+
requireAnswerer(who);
|
|
418
|
+
const q = await pendingRequest(repo, id, seen);
|
|
419
|
+
const message = text(opts.message, REQUEST_LIMITS.message);
|
|
420
|
+
const answer = { state: 'approved' as const, reviewedBy: who.id, reviewedAt: Date.now(), reply: message };
|
|
421
|
+
let done: RequestRecord, event: CalEvent | null, next: string;
|
|
422
|
+
if (q.kind === 'new') {
|
|
423
|
+
event = await draftFrom(repo, q, hooks);
|
|
424
|
+
try { done = await decide(repo, q, { ...answer, eventId: event.id }); }
|
|
425
|
+
catch (e) { await repo.deleteEvent(event.id); throw e; }
|
|
426
|
+
next = `/admin/calendar/events/${event.id}`;
|
|
427
|
+
} else if (q.kind === 'change') {
|
|
428
|
+
event = await repo.getEvent(q.eventId);
|
|
429
|
+
if (!event) throw new ApiError(409, 'That event was deleted. Decline the request, or reply to the person.');
|
|
430
|
+
done = await decide(repo, q, answer);
|
|
431
|
+
next = `/admin/calendar/events/${event.id}${q.key ? `?move=${encodeURIComponent(q.key)}` : ''}`;
|
|
432
|
+
} else if (q.kind === 'move') {
|
|
433
|
+
// a host's date move (lib/calendar/host-requests.ts): it runs, and the host hears about it there
|
|
434
|
+
const { approveDateRequest } = await import('./host-requests');
|
|
435
|
+
const { request, moved } = await approveDateRequest(repo, who, id, hooks, seen);
|
|
436
|
+
return { request, event: moved.event, next: `/admin/calendar/events/${request.eventId}` };
|
|
437
|
+
} else {
|
|
438
|
+
throw new ApiError(400, 'Open the event to answer this request.');
|
|
439
|
+
}
|
|
440
|
+
if (opts.email !== false) await tell(repo, done, 'approved', message, hooks);
|
|
441
|
+
return { request: done, event, next };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** the draft a new request becomes */
|
|
445
|
+
async function draftFrom(repo: ContentRepository, q: RequestRecord, hooks: SaveHooks): Promise<CalEvent> {
|
|
446
|
+
const d = q.data as unknown as NewEventDetails;
|
|
447
|
+
const settings = await repo.getSettings();
|
|
448
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
449
|
+
const native = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
|
|
450
|
+
const calendarId = [type?.calendarId, settings.calendar.defaultCalendarId].find((c) => c && native.has(c)) ?? '';
|
|
451
|
+
// rooms deleted since are left out
|
|
452
|
+
const rooms = new Set((await repo.listResources()).map((r) => r.id));
|
|
453
|
+
const created = await repo.createEvent({
|
|
454
|
+
title: d.title, slug: await uniqueSlug(repo, d.title), start: d.start, allDay: false, status: 'draft',
|
|
455
|
+
calendarIds: calendarId ? [calendarId] : [], tags: type?.tag ? [type.tag] : [],
|
|
456
|
+
roomHold: RoomHold.parse({ resourceIds: d.roomIds.filter((r) => rooms.has(r)), whileDraft: d.roomIds.length > 0 }),
|
|
457
|
+
});
|
|
458
|
+
// times and repeat go through the editor's own validation; a draft that couldn't be finished doesn't stay holding rooms
|
|
459
|
+
try { return await saveEvent(repo, created.id, { start: d.start, end: d.end, allDay: false, rrule: d.repeat === 'weekly' ? 'FREQ=WEEKLY' : '' }, { url: hooks.url, locals: hooks.locals }); }
|
|
460
|
+
catch (e) { await repo.deleteEvent(created.id).catch(() => {}); throw e; }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Staff decline, with an optional reason for the person (emailed in their language unless `email` is false). */
|
|
464
|
+
export async function declineRequest(repo: ContentRepository, who: Actor | null, id: string, reply: string, hooks: SaveHooks, seen?: number, opts: { email?: boolean } = {}): Promise<RequestRecord> {
|
|
465
|
+
requireAnswerer(who);
|
|
466
|
+
const q = await pendingRequest(repo, id, seen);
|
|
467
|
+
if (q.kind === 'move') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
|
|
468
|
+
if (q.kind !== 'new' && q.kind !== 'change') throw new ApiError(400, 'Open the event to answer this request.');
|
|
469
|
+
const reason = text(reply, REQUEST_LIMITS.message);
|
|
470
|
+
const done = await decide(repo, q, { state: 'declined', reviewedBy: who.id, reviewedAt: Date.now(), reply: reason });
|
|
471
|
+
if (opts.email !== false) await tell(repo, done, 'declined', reason, hooks);
|
|
472
|
+
return done;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Staff delete a request from the app or the website as spam: gone at once, and nobody is emailed. Hosts' requests are declined instead. */
|
|
476
|
+
export async function deleteRequest(repo: ContentRepository, who: Actor | null, id: string): Promise<void> {
|
|
477
|
+
requireAnswerer(who);
|
|
478
|
+
const q = await repo.requests.get(id);
|
|
479
|
+
if (!q) throw new ApiError(404, 'Request not found.');
|
|
480
|
+
if (q.source === 'member') throw new ApiError(400, 'A host’s request can be declined, not deleted.');
|
|
481
|
+
await repo.requests.delete(id);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Staff write to the person (their language's greeting around staff's words); the request keeps its state. */
|
|
485
|
+
export async function replyToRequest(repo: ContentRepository, who: Actor | null, id: string, message: string, hooks: SaveHooks): Promise<RequestRecord> {
|
|
486
|
+
requireAnswerer(who);
|
|
487
|
+
const q = await repo.requests.get(id);
|
|
488
|
+
if (!q) throw new ApiError(404, 'Request not found.');
|
|
489
|
+
const words = text(message, REQUEST_LIMITS.message);
|
|
490
|
+
if (!words) throw new ApiError(400, 'Write your reply first.');
|
|
491
|
+
if (!await requesterAddress(repo, q)) throw new ApiError(409, 'There’s no email address to reply to.');
|
|
492
|
+
if (!await repo.email.rateLimit(`requests:reply:${q.id}`, 10, DAY)) throw new ApiError(429, 'That’s a lot of replies to one request today. Try again tomorrow.', 86_400);
|
|
493
|
+
const done = (await repo.requests.update(q.id, { reply: words }))!;
|
|
494
|
+
await tell(repo, done, 'reply', words, hooks);
|
|
495
|
+
return done;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Requests go keepDays (Calendar → Requests) after their answer, or after they arrived if nobody answered (the scheduler runs this). */
|
|
499
|
+
export async function pruneRequests(repo: ContentRepository, now = Date.now()): Promise<number> {
|
|
500
|
+
const keep = requestSettings(await repo.getSettings()).keepDays;
|
|
501
|
+
return repo.requests.prune(now - keep * DAY);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---- the list staff see -------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
/** a room that's taken for a requested time (names are for staff) */
|
|
507
|
+
export interface RequestClash { room: string; start: number; end: number; title: string }
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Whether a new request's rooms are free: its time and, for weekly ones, the
|
|
511
|
+
* same time for 12 weeks. Lists what takes them (first ones first).
|
|
512
|
+
*/
|
|
513
|
+
export async function requestClashes(repo: ContentRepository, q: RequestRecord, settings: Settings, rooms?: Resource[]): Promise<RequestClash[]> {
|
|
514
|
+
if (q.kind !== 'new') return [];
|
|
515
|
+
const d = q.data as unknown as NewEventDetails;
|
|
516
|
+
if (!d.roomIds?.length || !validWall(d.start) || !validWall(d.end)) return [];
|
|
517
|
+
const tz = settings.timezone || 'UTC';
|
|
518
|
+
const start = wallAt(d.start, tz), end = wallAt(d.end, tz);
|
|
519
|
+
const names = new Map((rooms ?? await repo.listResources()).map((r) => [r.id, r.name]));
|
|
520
|
+
const weeks = d.repeat === 'weekly' ? 12 : 1;
|
|
521
|
+
// to the last week's end at its own wall time (a clock change moves it)
|
|
522
|
+
const busy = await roomBusy(repo, settings, { resourceIds: d.roomIds, from: start, to: Math.max(end, wallAt(shiftDays(d.end, (weeks - 1) * 7), tz)) });
|
|
523
|
+
const out: RequestClash[] = [];
|
|
524
|
+
for (let w = 0; w < weeks; w++) {
|
|
525
|
+
// the same wall time each week (across a clock change too)
|
|
526
|
+
const s = wallAt(shiftDays(d.start, w * 7), tz), e = wallAt(shiftDays(d.end, w * 7), tz);
|
|
527
|
+
for (const b of busy) if (b.start < e && b.end > s) out.push({ room: names.get(b.resourceId) ?? '', start: b.start, end: b.end, title: b.title });
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
function shiftDays(wall: string, days: number) {
|
|
532
|
+
const w = parseWall(wall)!;
|
|
533
|
+
const t = new Date(Date.UTC(w.y, w.m - 1, w.d + days));
|
|
534
|
+
return `${t.toISOString().slice(0, 10)}${wall.slice(10)}`;
|
|
535
|
+
}
|
|
@@ -97,8 +97,8 @@ export class RequestStore {
|
|
|
97
97
|
async delete(id: string): Promise<boolean> {
|
|
98
98
|
return (await this.all('DELETE FROM event_requests WHERE id=? RETURNING id', [id])).length > 0;
|
|
99
99
|
}
|
|
100
|
-
/** Deletes requests answered or withdrawn before `before` (ms)
|
|
100
|
+
/** Deletes requests answered or withdrawn before `before` (ms), and ones still waiting that arrived before it. Returns how many went. */
|
|
101
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;
|
|
102
|
+
return (await this.all(`DELETE FROM event_requests WHERE (state<>'pending' AND reviewed_at>0 AND reviewed_at<?) OR (state='pending' AND created_at<?) RETURNING id`, [before, before])).length;
|
|
103
103
|
}
|
|
104
104
|
}
|