@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,66 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { useLocale, useTheme } from '@acms-subir/native';
|
|
4
|
+
|
|
5
|
+
// One month to pick a day from (YYYY-MM-DD), with no native date picker to
|
|
6
|
+
// install: ‹ › change the month, and days before `min` or after `max` can't
|
|
7
|
+
// be picked. Days are plain calendar days (the site's, for requests), so
|
|
8
|
+
// everything here is UTC and never shifts with the phone's time zone.
|
|
9
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
10
|
+
const iso = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
|
|
11
|
+
|
|
12
|
+
export function DayPicker({ value, onChange, min, max }: { value: string; onChange: (day: string) => void; min: string; max?: string }) {
|
|
13
|
+
const t = useTheme();
|
|
14
|
+
const { locale } = useLocale();
|
|
15
|
+
const [month, setMonth] = useState(() => (value || min).slice(0, 7));
|
|
16
|
+
const [y, m] = month.split('-').map(Number);
|
|
17
|
+
const f = (o: Intl.DateTimeFormatOptions) => {
|
|
18
|
+
try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }); } catch { return new Intl.DateTimeFormat(undefined, { ...o, timeZone: 'UTC' }); }
|
|
19
|
+
};
|
|
20
|
+
const monthName = f({ month: 'long', year: 'numeric' });
|
|
21
|
+
const at = (n: number) => new Date(Date.UTC(y, m - 1 + n, 1));
|
|
22
|
+
const shift = (n: number) => setMonth(`${at(n).getUTCFullYear()}-${pad(at(n).getUTCMonth() + 1)}`);
|
|
23
|
+
const earlier = month > min.slice(0, 7), later = !max || month < max.slice(0, 7);
|
|
24
|
+
const length = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
25
|
+
// weeks start on Sunday
|
|
26
|
+
const cells = [...Array<string | null>(at(0).getUTCDay()).fill(null), ...Array.from({ length }, (_, i) => iso(y, m, i + 1))];
|
|
27
|
+
const weekday = f({ weekday: 'short' });
|
|
28
|
+
const fullDay = f({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
|
29
|
+
const radius = Math.min(t.radius, 10);
|
|
30
|
+
const arrow = (n: number, on: boolean, glyph: string) => (
|
|
31
|
+
// read out as the month it goes to ("December 2026"), so it needs no words of its own
|
|
32
|
+
<Pressable accessibilityRole="button" accessibilityLabel={monthName.format(at(n))} accessibilityState={{ disabled: !on }} disabled={!on} onPress={() => shift(n)}
|
|
33
|
+
style={{ minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center', opacity: on ? 1 : 0.3 }}>
|
|
34
|
+
<Text style={[t.styles.text.heading, { color: t.colors.accent }]}>{glyph}</Text>
|
|
35
|
+
</Pressable>
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<View style={[t.styles.card, { padding: 8, gap: 4 }]}>
|
|
39
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
40
|
+
{arrow(-1, earlier, '‹')}
|
|
41
|
+
<Text accessibilityRole="header" style={[t.styles.text.body, { flex: 1, textAlign: 'center', fontWeight: '600' }]}>{monthName.format(at(0))}</Text>
|
|
42
|
+
{arrow(1, later, '›')}
|
|
43
|
+
</View>
|
|
44
|
+
<View style={{ flexDirection: 'row' }} importantForAccessibility="no-hide-descendants" accessibilityElementsHidden>
|
|
45
|
+
{/* 2026-01-04 was a Sunday */}
|
|
46
|
+
{Array.from({ length: 7 }, (_, i) => (
|
|
47
|
+
<Text key={i} style={[t.styles.text.small, { width: `${100 / 7}%`, textAlign: 'center' }]}>{weekday.format(new Date(Date.UTC(2026, 0, 4 + i)))}</Text>
|
|
48
|
+
))}
|
|
49
|
+
</View>
|
|
50
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
|
51
|
+
{cells.map((d, i) => {
|
|
52
|
+
if (!d) return <View key={`_${i}`} style={{ width: `${100 / 7}%`, height: 44 }} />;
|
|
53
|
+
const off = d < min || (!!max && d > max), on = d === value;
|
|
54
|
+
return (
|
|
55
|
+
<Pressable key={d} accessibilityRole="button" accessibilityLabel={fullDay.format(new Date(`${d}T00:00:00Z`))} accessibilityState={{ selected: on, disabled: off }}
|
|
56
|
+
disabled={off} onPress={() => onChange(d)} style={{ width: `${100 / 7}%`, height: 44, padding: 2 }}>
|
|
57
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius, backgroundColor: on ? t.colors.accent : undefined }}>
|
|
58
|
+
<Text style={[t.styles.text.body, { color: on ? t.colors.onAccent : off ? t.colors.line : t.colors.ink }, on && { fontWeight: '600' }]}>{Number(d.slice(8))}</Text>
|
|
59
|
+
</View>
|
|
60
|
+
</Pressable>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</View>
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Pressable, Text, type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
import { Link } from 'expo-router';
|
|
3
|
+
import { useStrings, useTheme } from '@acms-subir/native';
|
|
4
|
+
|
|
5
|
+
// "Planning something? Request an event" for the end of the events list,
|
|
6
|
+
// e.g. ListFooterComponent={<RequestEventLink />} in app/events/index.tsx.
|
|
7
|
+
export function RequestEventLink({ style }: { style?: StyleProp<ViewStyle> }) {
|
|
8
|
+
const t = useTheme();
|
|
9
|
+
const words = useStrings();
|
|
10
|
+
return (
|
|
11
|
+
<Link href="/request" asChild>
|
|
12
|
+
<Pressable accessibilityRole="link" style={({ pressed }) => [{ minHeight: 44, paddingVertical: 16, justifyContent: 'center', opacity: pressed ? 0.7 : 1 }, style]}>
|
|
13
|
+
<Text style={[t.styles.text.muted, { textAlign: 'center' }]}>
|
|
14
|
+
{words.requestPlanning} <Text style={{ color: t.colors.accent, fontWeight: '600' }}>{words.requestEvent}</Text>
|
|
15
|
+
</Text>
|
|
16
|
+
</Pressable>
|
|
17
|
+
</Link>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Pressable, Text, type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
import { Link } from 'expo-router';
|
|
3
|
+
import { useStrings, useTheme } from '@acms-subir/native';
|
|
4
|
+
|
|
5
|
+
// The More tab's row that opens /request: "Request an event" ("Solicitar un
|
|
6
|
+
// evento"). Always shown, even while the site isn't taking requests: the
|
|
7
|
+
// screen then says so and gives the office phone. Hiding it would hide a
|
|
8
|
+
// feature from App Review, so don't wrap it in a check.
|
|
9
|
+
export function RequestEventRow({ style }: { style?: StyleProp<ViewStyle> }) {
|
|
10
|
+
const t = useTheme();
|
|
11
|
+
const words = useStrings();
|
|
12
|
+
return (
|
|
13
|
+
<Link href="/request" asChild>
|
|
14
|
+
<Pressable accessibilityRole="button" style={({ pressed }) => [{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 52, paddingVertical: 12, opacity: pressed ? 0.7 : 1 }, style]}>
|
|
15
|
+
<Text style={[t.styles.text.body, { flex: 1 }]}>{words.requestEvent}</Text>
|
|
16
|
+
<Text style={[t.styles.text.heading, { color: t.colors.muted }]} importantForAccessibility="no" accessibilityElementsHidden>›</Text>
|
|
17
|
+
</Pressable>
|
|
18
|
+
</Link>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "client",
|
|
3
|
+
"title": "Event requests",
|
|
4
|
+
"description": "Request an event without an account: something new (what, kind, date and times, repeats, rooms with a live \"looks free\" line, people) or a change to an event, then name, email and phone, and Request sent with the reply time. When the site isn't taking requests the screen still opens and says so, with the office phone. Includes a row for the More tab and a link for the end of the events list. Needs requests turned on for the site.",
|
|
5
|
+
"packages": [
|
|
6
|
+
"expo-crypto",
|
|
7
|
+
"expo-secure-store"
|
|
8
|
+
],
|
|
9
|
+
"routes": [
|
|
10
|
+
"/request"
|
|
11
|
+
],
|
|
12
|
+
"next": [
|
|
13
|
+
"Turn requests on for the site: acms({ requests: true }) in astro.config.mjs (needs the calendar), then restart or deploy. Set the notice, reply time, office phone, kinds and the rooms people may ask for in Admin → Calendar → Requests; staff answer requests there",
|
|
14
|
+
"Add the row to your More tab: import { RequestEventRow } from '../../components/RequestEventRow' in app/(tabs)/more.tsx (or '../components/RequestEventRow' from a screen in app/) and render <RequestEventRow /> in its list. Keep it there even while requests are closed: the screen says so and gives the office phone, and App Review looks for it",
|
|
15
|
+
"Optional, at the end of the events list: ListFooterComponent={<RequestEventLink />} in app/events/index.tsx (from '../../components/RequestEventLink')",
|
|
16
|
+
"Open the screen from site links: in onNavigate, send '/request' (or your acms({ requests: { path } }); '/es/request' in Spanish) to router.push('/request')",
|
|
17
|
+
"The live \"looks free\" line under the rooms needs bookings on the site (it uses GET /api/v1/rooms/free, free or busy only); without them the screen works the same, without that line",
|
|
18
|
+
"App Store Connect → App Review Information → Notes: \"Request an event: open the More tab and tap Request an event. No sign-in is needed. The screen asks for a name, an email address, an optional phone number and details or a message about the event (something new, or a change to an existing event). Staff review every request on the website before anything goes on the calendar, and answer by email. If you send a test request, please write App Review in the message so staff can decline it.\"",
|
|
19
|
+
"App Privacy (App Store Connect) and Data safety (Google Play) for what the screen sends: Contact Info → Name, Email Address, Phone Number (Play: Personal info → Name, Email address, Phone number) and User Content → Other User Content (Play: App activity → Other user-generated content). Purpose: App Functionality; linked to the person; not used for tracking; not shared; optional (people choose to send it). Name, email and phone also stay on the phone in secure storage for next time"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -38,7 +38,7 @@ function ago(ms: number, now: number) {
|
|
|
38
38
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed', test: 'Test by', form: 'New form response' };
|
|
41
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed', test: 'Test by', form: 'New form response', request: 'New event request' };
|
|
42
42
|
|
|
43
43
|
export default function MobileApp() {
|
|
44
44
|
const [state, setState] = useState<AdminPushState | null>(null);
|
|
@@ -462,7 +462,7 @@ function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel:
|
|
|
462
462
|
{n.body && <div className="ma-body">{n.body}</div>}
|
|
463
463
|
{n.url && <div className="t-sub ma-url">{n.url}</div>}
|
|
464
464
|
</td>
|
|
465
|
-
<td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.source === 'form' ? 'Staff on the form’s alert list' : n.topics.map(topicLabel).join(', ')}</td>
|
|
465
|
+
<td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.source === 'form' ? 'Staff on the form’s alert list' : n.source === 'request' ? 'Staff on the requests alert list' : n.topics.map(topicLabel).join(', ')}</td>
|
|
466
466
|
<td data-l="From">{SOURCE[n.source]}{(n.source === 'manual' || n.source === 'test') && n.author ? ` ${n.author}` : n.source === 'manual' || n.source === 'test' ? ' an owner' : ''}</td>
|
|
467
467
|
<td data-l="Delivered" className="num ma-result">
|
|
468
468
|
{n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
|
|
@@ -28,12 +28,22 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
|
|
|
28
28
|
};
|
|
29
29
|
useEffect(() => { if (eventId && repeats) void load(); }, [eventId, repeats, limit]);
|
|
30
30
|
|
|
31
|
-
if (!eventId || !repeats || !data || !data.dates.length) return null;
|
|
32
|
-
|
|
33
31
|
const open = (d: DateRow) => {
|
|
34
32
|
setNote(null);
|
|
35
33
|
setForm({ key: d.key, day: datePart(d.start), endDay: datePart(d.end), from: timePart(d.start), to: timePart(d.end), roomIds: d.roomIds, note: d.moved?.note ?? '' });
|
|
36
34
|
};
|
|
35
|
+
// ?move=<key> (approving a request to change one date, Calendar → Requests)
|
|
36
|
+
// opens that date's Move…, looking further ahead when it isn't in the first few
|
|
37
|
+
const [wanted, setWanted] = useState(() => (typeof location === 'undefined' ? '' : new URLSearchParams(location.search).get('move') ?? ''));
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!wanted || !data) return;
|
|
40
|
+
const d = data.dates.find((x) => x.key === wanted);
|
|
41
|
+
if (d) { open(d); setWanted(''); requestAnimationFrame(() => document.getElementById('ev-dates')?.scrollIntoView({ block: 'start' })); }
|
|
42
|
+
else if (limit < 52) setLimit(52);
|
|
43
|
+
else setWanted('');
|
|
44
|
+
}, [data, wanted]);
|
|
45
|
+
|
|
46
|
+
if (!eventId || !repeats || !data || !data.dates.length) return null;
|
|
37
47
|
const run = async (key: string, work: () => Promise<{ error?: { message: string }; data?: object }>, done: string) => {
|
|
38
48
|
setBusy(key);
|
|
39
49
|
const r = await work();
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -5,6 +5,7 @@ import { BRAND } from '../lib/brand';
|
|
|
5
5
|
import { CONTENT } from '../lib/content';
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
7
|
import { ANNOUNCEMENTS } from '../lib/announcements/config';
|
|
8
|
+
import { REQUESTS } from '../lib/calendar/request-config';
|
|
8
9
|
import { OWNER_ENV } from '../lib/auth';
|
|
9
10
|
import { validateRequestSession } from '../lib/auth/request';
|
|
10
11
|
import { accessFor } from '../lib/permissions/access';
|
|
@@ -36,6 +37,8 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
|
36
37
|
]);
|
|
37
38
|
// pending booking requests, shown as a count on the Bookings nav item
|
|
38
39
|
const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
|
|
40
|
+
// event requests waiting for staff (acms({ requests })): a count on Requests
|
|
41
|
+
const pendingAsks = REQUESTS.enabled ? await repo.requests.count({ states: ['pending'] }) : 0;
|
|
39
42
|
const me = {
|
|
40
43
|
name: sessionUser?.name || 'Admin',
|
|
41
44
|
role: roleLabel(sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'),
|
|
@@ -74,6 +77,7 @@ const icons: Record<string, string> = {
|
|
|
74
77
|
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
|
|
75
78
|
shield: 'M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4',
|
|
76
79
|
calendar: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
|
|
80
|
+
inbox: 'M3 13h5l1.5 3h5L16 13h5M5.5 5h13L21 13v6H3v-6z',
|
|
77
81
|
calendars: 'M4 6h.01M4 12h.01M4 18h.01M8 6h12M8 12h12M8 18h12',
|
|
78
82
|
bookings: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
79
83
|
resources: 'M3 21V9l9-5 9 5v12M9 21v-6h6v6',
|
|
@@ -130,6 +134,9 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
130
134
|
: []),
|
|
131
135
|
// TV screens: saved display links (lib/display), with private details behind their key
|
|
132
136
|
...(can('schedule.manage') ? [{ key: 'calendar:screens', label: 'Screens', href: '/admin/calendar/screens', icon: icons.monitor, count: null }] : []),
|
|
137
|
+
...(REQUESTS.enabled && can('content.edit')
|
|
138
|
+
? [{ key: 'calendar:requests', label: 'Requests', href: '/admin/calendar/requests', icon: icons.inbox, count: pendingAsks || null, attention: pendingAsks > 0 }]
|
|
139
|
+
: []),
|
|
133
140
|
],
|
|
134
141
|
}]
|
|
135
142
|
: []),
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { RequestSettings, slugify, type CalEvent, type RequestRecord, type Settings } from '../schema';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import { validAddress } from '../email/config';
|
|
5
|
+
import { extraLocales, localeName } from '../i18n';
|
|
6
|
+
import { siteLocale } from '../dates';
|
|
7
|
+
import { mergeEventDraft } from '../drafts';
|
|
8
|
+
import { eventClashes } from '../booking/holds';
|
|
9
|
+
import { featuredOccurrence, formatOccurrence } from './events';
|
|
10
|
+
import { requestWhen } from './request-mail';
|
|
11
|
+
import { requestClashes, requestSettings, type NewEventDetails } from './request-service';
|
|
12
|
+
import { pendingRequests } from './requests';
|
|
13
|
+
import { dateRequestClashes, dateRequestWhen } from './host-requests';
|
|
14
|
+
import { MEMBERS } from './members-config';
|
|
15
|
+
|
|
16
|
+
// Calendar → Requests (pages/admin/calendar/requests.astro): one list of
|
|
17
|
+
// requests from the app, the website and hosts (rows of their own, plus
|
|
18
|
+
// members' requests staged on events), each with what takes its rooms then,
|
|
19
|
+
// and the settings form. Staff see who asked; the public never does.
|
|
20
|
+
|
|
21
|
+
export type RequestTab = 'pending' | 'approved' | 'declined';
|
|
22
|
+
|
|
23
|
+
export interface RequestRow {
|
|
24
|
+
/** 'r:<id>' for request rows, 'e:<eventId>' for members' requests on an event */
|
|
25
|
+
key: string;
|
|
26
|
+
/** the same as an HTML id ('r-<id>', 'e-<eventId>'): a request's id is the sender's key, so it can't share a namespace with event ids */
|
|
27
|
+
dom: string;
|
|
28
|
+
id: string;
|
|
29
|
+
kind: RequestRecord['kind'] | 'member-new' | 'member-changes';
|
|
30
|
+
/** "New event · from the app · 2h ago" */
|
|
31
|
+
head: string;
|
|
32
|
+
title: string;
|
|
33
|
+
line: string;
|
|
34
|
+
who: string;
|
|
35
|
+
/** where Reply goes ('' = nowhere to write) */
|
|
36
|
+
email: string;
|
|
37
|
+
note: string;
|
|
38
|
+
/** "Sanctuary is free then" / "Sanctuary on Wed, Dec 2: Advent rehearsal 4:30 – 6:00 PM" */
|
|
39
|
+
clash: { text: string; taken: boolean } | null;
|
|
40
|
+
/** staff's answer, for decided ones: "Approved by Ana · 2d ago" and their words */
|
|
41
|
+
answer: string;
|
|
42
|
+
reply: string;
|
|
43
|
+
/** the event it's about, or the draft it became */
|
|
44
|
+
href: string;
|
|
45
|
+
/** the version staff are looking at (the `seen` guard) */
|
|
46
|
+
at: number;
|
|
47
|
+
canReply: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ago = (now: number, ts: number) => {
|
|
51
|
+
const s = Math.max(0, Math.floor((now - ts) / 1000));
|
|
52
|
+
if (s < 60) return 'just now';
|
|
53
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
54
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
55
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
56
|
+
};
|
|
57
|
+
const SOURCE = { app: 'from the app', web: 'from the website', member: 'from a host' } as const;
|
|
58
|
+
const KIND = { new: 'New event', change: 'Change', move: 'Move a date', 'from-date': 'Change from a date on' } as const;
|
|
59
|
+
|
|
60
|
+
function fmt(settings: Settings) {
|
|
61
|
+
const tz = settings.timezone || 'UTC', locale = siteLocale(settings);
|
|
62
|
+
const f = (o: Intl.DateTimeFormatOptions) => {
|
|
63
|
+
try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
|
|
64
|
+
catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
|
|
65
|
+
};
|
|
66
|
+
const day = f({ weekday: 'short', month: 'short', day: 'numeric' });
|
|
67
|
+
const time = f({ hour: 'numeric', minute: '2-digit' });
|
|
68
|
+
return { locale, tz, slot: (s: number, e: number) => `${day.format(s)}: ${time.formatRange(s, e)}`, key: (k: string) => {
|
|
69
|
+
try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${k.slice(0, 10)}T12:00:00Z`)); }
|
|
70
|
+
catch { return k.slice(0, 10); }
|
|
71
|
+
} };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const ROWS_PER_PAGE = 50;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One page of a tab (ROWS_PER_PAGE), newest answers first; pending ones oldest
|
|
78
|
+
* first (first come, first answered). `more`: there's a next page. Members'
|
|
79
|
+
* requests come on the first page.
|
|
80
|
+
*/
|
|
81
|
+
export async function requestRows(repo: ContentRepository, settings: Settings, tab: RequestTab, now = Date.now(), page = 1): Promise<{ rows: RequestRow[]; more: boolean }> {
|
|
82
|
+
const f = fmt(settings);
|
|
83
|
+
const [found, users, rooms, events] = await Promise.all([
|
|
84
|
+
repo.requests.list({ states: tab === 'pending' ? ['pending'] : [tab], order: tab === 'pending' ? 'oldest' : 'decided', limit: ROWS_PER_PAGE + 1, offset: (page - 1) * ROWS_PER_PAGE }),
|
|
85
|
+
repo.listUsers(), repo.listResources(), repo.listEvents(),
|
|
86
|
+
]);
|
|
87
|
+
const userName = new Map(users.map((u) => [u.id, u.name || u.email]));
|
|
88
|
+
const roomName = new Map(rooms.map((r) => [r.id, r.name]));
|
|
89
|
+
const eventById = new Map(events.map((e) => [e.id, e]));
|
|
90
|
+
const types = requestSettings(settings).types;
|
|
91
|
+
const records = found.slice(0, ROWS_PER_PAGE);
|
|
92
|
+
const rows: (RequestRow & { sort: number })[] = [];
|
|
93
|
+
for (const q of records) {
|
|
94
|
+
const who = q.requester
|
|
95
|
+
? [q.requester.name, q.requester.email, q.requester.phone, q.requester.locale ? localeName(q.requester.locale) : ''].filter(Boolean).join(' · ')
|
|
96
|
+
: userName.get(q.userId) ?? 'A member';
|
|
97
|
+
const e = q.eventId ? eventById.get(q.eventId) : undefined;
|
|
98
|
+
let title = '', line = '', clash: RequestRow['clash'] = null;
|
|
99
|
+
if (q.kind === 'new') {
|
|
100
|
+
const d = q.data as unknown as NewEventDetails;
|
|
101
|
+
const type = types.find((t) => t.id === d.type);
|
|
102
|
+
title = d.title;
|
|
103
|
+
line = [requestWhen(d.start, d.end, settings, f.locale), d.repeat === 'weekly' ? 'every week' : d.repeat === 'other' ? d.repeatText : '',
|
|
104
|
+
d.roomsUnsure ? 'room: not sure' : d.roomIds.map((id) => roomName.get(id) ?? 'a deleted room').join(', '),
|
|
105
|
+
d.people ? `about ${d.people} people` : '', type?.label ?? ''].filter(Boolean).join(' · ');
|
|
106
|
+
if (tab === 'pending' && d.roomIds.length) {
|
|
107
|
+
const found = await requestClashes(repo, q, settings, rooms);
|
|
108
|
+
const names = d.roomIds.map((id) => roomName.get(id)).filter(Boolean).join(' and ');
|
|
109
|
+
clash = found.length
|
|
110
|
+
? { taken: true, text: `${found[0].room} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
|
|
111
|
+
: { taken: false, text: `${names} ${d.roomIds.length > 1 ? 'are' : 'is'} free then` };
|
|
112
|
+
}
|
|
113
|
+
} else if (q.kind === 'move') {
|
|
114
|
+
title = e?.title ?? 'A deleted event';
|
|
115
|
+
line = await dateRequestWhen(repo, q);
|
|
116
|
+
if (tab === 'pending' && e) {
|
|
117
|
+
const found = await dateRequestClashes(repo, q);
|
|
118
|
+
clash = found.length
|
|
119
|
+
? { taken: true, text: `${roomName.get(found[0].resourceId) ?? 'A room'} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
|
|
120
|
+
: { taken: false, text: 'Its rooms are free then' };
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
title = e?.title ?? String(q.data.title ?? 'A deleted event');
|
|
124
|
+
line = q.key ? f.key(q.key) : 'every date';
|
|
125
|
+
}
|
|
126
|
+
const answer = q.state === 'pending' ? '' : `${q.state === 'approved' ? 'Approved' : q.state === 'declined' ? 'Declined' : 'Withdrawn'}${q.reviewedBy ? ` by ${userName.get(q.reviewedBy) ?? 'staff'}` : ''} · ${ago(now, q.reviewedAt || q.updatedAt)}`;
|
|
127
|
+
rows.push({
|
|
128
|
+
key: `r:${q.id}`, dom: `r-${q.id}`, id: q.id, kind: q.kind, head: `${KIND[q.kind]} · ${SOURCE[q.source]} · ${ago(now, q.createdAt)}`,
|
|
129
|
+
title, line, who, email: q.requester?.email ?? users.find((u) => u.id === q.userId)?.email ?? '', note: q.note, clash, answer, reply: q.reply,
|
|
130
|
+
href: q.eventId ? `/admin/calendar/events/${q.eventId}` : '', at: q.updatedAt, canReply: true, sort: q.state === 'pending' ? q.createdAt : q.reviewedAt,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// members' requests staged on their events (lib/calendar/requests.ts): waiting ones, and declined ones until the host dismisses them
|
|
134
|
+
if (tab !== 'approved' && MEMBERS.enabled && page === 1) {
|
|
135
|
+
const staged = tab === 'pending' ? await pendingRequests(repo) : events.filter((e) => e.request?.state === 'declined');
|
|
136
|
+
for (const e of staged) rows.push({ ...await memberRow(repo, e, settings, userName, now), sort: e.request!.at });
|
|
137
|
+
}
|
|
138
|
+
rows.sort((a, b) => (tab === 'pending' ? a.sort - b.sort : b.sort - a.sort));
|
|
139
|
+
return { rows: rows.map(({ sort: _, ...r }) => r), more: found.length > ROWS_PER_PAGE };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function memberRow(repo: ContentRepository, e: CalEvent, settings: Settings, userName: Map<string, string>, now: number): Promise<RequestRow> {
|
|
143
|
+
const req = e.request!;
|
|
144
|
+
const shown = mergeEventDraft(e);
|
|
145
|
+
const o = featuredOccurrence(shown, settings, now);
|
|
146
|
+
const f = fmt(settings);
|
|
147
|
+
let clash: RequestRow['clash'] = null;
|
|
148
|
+
if (req.state === 'pending' && shown.roomHold.resourceIds.length) {
|
|
149
|
+
const { clashes } = await eventClashes(repo, settings, { ...shown, status: 'published' } as CalEvent, { now, horizonDays: 120 });
|
|
150
|
+
clash = clashes.length ? { taken: true, text: `${clashes.length} date${clashes.length === 1 ? '' : 's'} clash with other events (open it to see)` } : { taken: false, text: 'Its rooms are free then' };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
key: `e:${e.id}`, dom: `e-${e.id}`, id: e.id, kind: req.kind === 'new' ? 'member-new' : 'member-changes',
|
|
154
|
+
head: `${req.kind === 'new' ? 'New event' : 'Changes'} · from a host on My events · ${ago(now, req.at)}`,
|
|
155
|
+
title: shown.title || 'Untitled event', line: o ? `${formatOccurrence(o, f.tz, f.locale).date} · ${formatOccurrence(o, f.tz, f.locale).time}` : '',
|
|
156
|
+
who: userName.get(req.by) ?? 'A member', email: '', note: req.note, clash,
|
|
157
|
+
answer: req.state === 'declined' ? `Declined${req.reviewedBy ? ` by ${userName.get(req.reviewedBy) ?? 'staff'}` : ''} · ${ago(now, req.reviewedAt)}` : '',
|
|
158
|
+
reply: req.reply, href: `/admin/calendar/events/${e.id}`, at: req.at, canReply: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** How many wait, for the tab's count: rows of their own plus members' requests. */
|
|
163
|
+
export async function pendingCount(repo: ContentRepository): Promise<number> {
|
|
164
|
+
return (await repo.requests.count({ states: ['pending'] })) + (MEMBERS.enabled ? (await pendingRequests(repo)).length : 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const lines = (v: FormDataEntryValue | null) => String(v ?? '').split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
168
|
+
const num = (v: FormDataEntryValue | null, fallback: number) => { const n = Number.parseInt(String(v ?? ''), 10); return Number.isFinite(n) ? n : fallback; };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The settings form: types as rows (type-label-N, type-tag-N, type-cal-N and
|
|
172
|
+
* type-<locale>-N; a row without a label is dropped), the words in each
|
|
173
|
+
* language, the phone, who's told, the daily cap and how long answered
|
|
174
|
+
* requests are kept.
|
|
175
|
+
*/
|
|
176
|
+
export function settingsFromForm(data: FormData, current: RequestSettings, calendarIds: string[]): RequestSettings {
|
|
177
|
+
const types: RequestSettings['types'] = [];
|
|
178
|
+
// ids of the kinds already saved, so a new kind never takes one (renamed kinds keep theirs)
|
|
179
|
+
const keptIds = new Set(Array.from({ length: 12 }, (_, i) => String(data.get(`type-id-${i}`) ?? '')).filter(Boolean));
|
|
180
|
+
for (let i = 0; i < 12; i++) {
|
|
181
|
+
const label = String(data.get(`type-label-${i}`) ?? '').trim().slice(0, 60);
|
|
182
|
+
if (!label) continue;
|
|
183
|
+
if (types.some((t) => t.label.toLowerCase() === label.toLowerCase())) throw new ApiError(400, `Two kinds are called “${label}”. Give each kind its own name.`);
|
|
184
|
+
// a kind keeps its id when renamed (requests point at it); a new one gets a free id from its name
|
|
185
|
+
const kept = String(data.get(`type-id-${i}`) ?? '');
|
|
186
|
+
const base = slugify(label.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')).slice(0, 36) || 'kind';
|
|
187
|
+
let id = kept && !types.some((t) => t.id === kept) ? kept : base;
|
|
188
|
+
for (let n = 2; types.some((t) => t.id === id) || (!kept && keptIds.has(id)); n++) id = `${base}-${n}`;
|
|
189
|
+
const cal = String(data.get(`type-cal-${i}`) ?? '');
|
|
190
|
+
types.push({ id, label, tag: String(data.get(`type-tag-${i}`) ?? '').trim().slice(0, 60), calendarId: calendarIds.includes(cal) ? cal : '',
|
|
191
|
+
translations: Object.fromEntries(extraLocales().map((l) => [l, String(data.get(`type-${l}-${i}`) ?? '').trim().slice(0, 60)]).filter(([, v]) => v)) });
|
|
192
|
+
}
|
|
193
|
+
const notify = lines(data.get('notify'));
|
|
194
|
+
const bad = notify.find((a) => !validAddress(a));
|
|
195
|
+
if (bad) throw new ApiError(400, `“${bad.slice(0, 80)}” isn’t an email address.`);
|
|
196
|
+
if (notify.length > 20) throw new ApiError(400, 'Add up to 20 addresses.');
|
|
197
|
+
const parsed = RequestSettings.safeParse({
|
|
198
|
+
open: data.get('open') === '1', types,
|
|
199
|
+
replyTime: String(data.get('replyTime') ?? '').trim(), notice: String(data.get('notice') ?? '').trim(),
|
|
200
|
+
translations: Object.fromEntries(extraLocales().map((l) => [l, { replyTime: String(data.get(`replyTime-${l}`) ?? '').trim(), notice: String(data.get(`notice-${l}`) ?? '').trim() }])),
|
|
201
|
+
phone: String(data.get('phone') ?? '').trim(), notify, phones: data.has('phones') ? data.getAll('phones').includes('1') : current.phones,
|
|
202
|
+
perDay: num(data.get('perDay'), current.perDay), keepDays: num(data.get('keepDays'), current.keepDays),
|
|
203
|
+
});
|
|
204
|
+
if (!parsed.success) {
|
|
205
|
+
const field = String(parsed.error.issues[0]?.path[0] ?? '');
|
|
206
|
+
const why: Record<string, string> = { replyTime: 'Keep the reply time under 60 characters.', notice: 'Keep the notice under 400 characters.', phone: 'Keep the phone number under 40 characters.',
|
|
207
|
+
perDay: 'Pick a daily limit from 0 to 10,000.', keepDays: 'Keep answered requests from 7 to 1,095 days.', translations: 'Keep each translation as short as the original.' };
|
|
208
|
+
throw new ApiError(400, why[field] ?? 'Check the request settings.');
|
|
209
|
+
}
|
|
210
|
+
return parsed.data;
|
|
211
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// acms({ requests }) config (integration.mjs resolves it): off unless a site
|
|
4
|
+
// turns it on, and only with the calendar. `path` is the public form ('/request'
|
|
5
|
+
// by default, '/es/request' in Spanish); `page: false` keeps the app API and
|
|
6
|
+
// Calendar → Requests but drops that form, so a site can build its own.
|
|
7
|
+
export const REQUESTS: { enabled: boolean; path: string; page: boolean } =
|
|
8
|
+
{ enabled: false, path: '/request', page: true, ...userConfig.requests };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { RequestRecord, Settings } from '../schema';
|
|
3
|
+
import type { Mail } from '../email/types';
|
|
4
|
+
import { siteLang, siteLocale } from '../dates';
|
|
5
|
+
import { I18N, localeName } from '../i18n';
|
|
6
|
+
import { fill } from '../live/words';
|
|
7
|
+
import { parseWall } from './events';
|
|
8
|
+
import { zonedToUtc } from './ics';
|
|
9
|
+
import { requestSettings, type NewEventDetails, type RequestMailPayload } from './request-service';
|
|
10
|
+
import { requestStaffWords, requestStrings } from './request-words';
|
|
11
|
+
|
|
12
|
+
// The emails about event requests (queued by lib/calendar/request-service.ts
|
|
13
|
+
// as kind 'calendar-request') and the staff push. To the person: in the
|
|
14
|
+
// language they asked in, and never with words they typed (their title or
|
|
15
|
+
// message), so the form can't be used to email someone else a message; only
|
|
16
|
+
// the date, time, kind, rooms and number of people, or the event's own title.
|
|
17
|
+
// Staff get everything, in the site's main language, with Reply-To set to
|
|
18
|
+
// the person. Plain HTML like the other emails, plus a text part.
|
|
19
|
+
|
|
20
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
21
|
+
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
22
|
+
|
|
23
|
+
/** the site's main language, and the locale for its dates (keeping the site's region when it's the same language) */
|
|
24
|
+
function language(settings: Settings, lang = '') {
|
|
25
|
+
const l = lang || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
26
|
+
const base = l.toLowerCase().split('-')[0];
|
|
27
|
+
return { lang: l, locale: base === siteLang(settings) ? siteLocale(settings) : l };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** "Sat, Nov 7 · 2:00 – 5:00 PM" for site-local wall times, in a language */
|
|
31
|
+
export function requestWhen(start: string, end: string, settings: Settings, locale: string): string {
|
|
32
|
+
const tz = settings.timezone || 'UTC';
|
|
33
|
+
const s = parseWall(start), e = parseWall(end);
|
|
34
|
+
if (!s || s.dateOnly) return start;
|
|
35
|
+
const at = (w: NonNullable<typeof s>) => zonedToUtc(w.y, w.m, w.d, w.hh, w.mm, 0, tz);
|
|
36
|
+
const f = (o: Intl.DateTimeFormatOptions) => {
|
|
37
|
+
try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
|
|
38
|
+
catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
|
|
39
|
+
};
|
|
40
|
+
const date = f({ weekday: 'short', month: 'short', day: 'numeric' }).format(at(s));
|
|
41
|
+
const time = f({ hour: 'numeric', minute: '2-digit' });
|
|
42
|
+
return `${date} · ${e && !e.dateOnly ? time.formatRange(at(s), at(e)) : time.format(at(s))}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** a date (YYYY-MM-DD…) as "Sat, Nov 7" */
|
|
46
|
+
function dayText(key: string, settings: Settings, locale: string) {
|
|
47
|
+
const w = parseWall(key.slice(0, 10));
|
|
48
|
+
if (!w) return key;
|
|
49
|
+
try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(Date.UTC(w.y, w.m - 1, w.d)); }
|
|
50
|
+
catch { return key.slice(0, 10); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Summary { what: string; lines: string[] }
|
|
54
|
+
/** the request as the person may see it again: never their own words */
|
|
55
|
+
async function personSummary(repo: ContentRepository, q: RequestRecord, settings: Settings, lang: string, locale: string): Promise<Summary> {
|
|
56
|
+
const w = requestStrings(lang);
|
|
57
|
+
if (q.kind === 'change') {
|
|
58
|
+
const e = q.eventId ? await repo.getEvent(q.eventId) : null;
|
|
59
|
+
const title = e?.title ?? String(q.data.title ?? '');
|
|
60
|
+
return { what: fill(w.changeTo, { title }), lines: [q.key ? dayText(q.key, settings, locale) : w.everyDate] };
|
|
61
|
+
}
|
|
62
|
+
if (q.kind !== 'new') return { what: '', lines: [] };
|
|
63
|
+
const d = q.data as unknown as NewEventDetails;
|
|
64
|
+
const rooms = await repo.listResources();
|
|
65
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
66
|
+
const parts = [
|
|
67
|
+
requestWhen(d.start, d.end, settings, locale),
|
|
68
|
+
...(d.repeat === 'weekly' ? [w.weekly] : []),
|
|
69
|
+
d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
|
|
70
|
+
d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
|
|
71
|
+
type ? (lang && type.translations[lang]) || type.label : '',
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
return { what: '', lines: [parts.join(' · ')] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function page(site: string, heading: string, paras: string[], quote: string, button?: { label: string; url: string }, foot = '') {
|
|
77
|
+
const box = quote ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px;white-space:pre-line">${esc(quote)}</p>` : '';
|
|
78
|
+
const btn = button ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${esc(button.url)}">${esc(button.label)}</a></p>` : '';
|
|
79
|
+
return `<!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">${esc(site)}</p><h1 style="font-size:22px">${esc(heading)}</h1>`
|
|
80
|
+
+ paras.map((p) => `<p>${esc(p)}</p>`).join('') + box + btn + (foot ? `<p style="color:#7d776e;font-size:14px">${esc(foot)}</p>` : '') + '</div></body></html>';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One queued request email, or null when it no longer applies. */
|
|
84
|
+
export async function requestMail(repo: ContentRepository, recipient: string, p: RequestMailPayload, origin: string): Promise<Mail | null> {
|
|
85
|
+
const q = await repo.requests.get(p.requestId);
|
|
86
|
+
if (!q) return null;
|
|
87
|
+
const settings = await repo.getSettings();
|
|
88
|
+
const site = settings.siteTitle;
|
|
89
|
+
if (p.kind === 'staff-new') {
|
|
90
|
+
// staff hear only about requests still waiting
|
|
91
|
+
if (q.state !== 'pending') return null;
|
|
92
|
+
return staffMail(repo, q, recipient, settings, origin);
|
|
93
|
+
}
|
|
94
|
+
const { lang, locale } = language(settings, q.requester?.locale);
|
|
95
|
+
const w = requestStrings(lang).mail;
|
|
96
|
+
const s = await personSummary(repo, q, settings, lang, locale);
|
|
97
|
+
const rq = requestSettings(settings);
|
|
98
|
+
const replyTime = (q.requester?.locale && rq.translations[q.requester.locale]?.replyTime) || rq.replyTime;
|
|
99
|
+
const change = q.kind === 'change';
|
|
100
|
+
const [subject, intro, quote] = p.kind === 'received' ? [w.receivedSubject, [w.received, ...(replyTime ? [fill(w.within, { time: replyTime })] : [])], '']
|
|
101
|
+
: p.kind === 'reply' ? [w.replySubject, [w.reply], p.message ?? '']
|
|
102
|
+
: p.kind === 'approved' ? [w.approvedSubject, [change ? w.approvedChange : w.approved], p.message ? `${w.fromTeam} ${p.message}` : '']
|
|
103
|
+
: [w.declinedSubject, [change ? w.declinedChange : w.declined], p.message ? `${w.fromTeam} ${p.message}` : ''];
|
|
104
|
+
const details = [s.what, ...s.lines].filter(Boolean);
|
|
105
|
+
const foot = p.kind === 'received' ? w.notYou : '';
|
|
106
|
+
return {
|
|
107
|
+
to: recipient, subject: `${subject} · ${site}`,
|
|
108
|
+
text: [subject, '', ...intro, '', ...details, ...(quote ? ['', quote] : []), ...(foot ? ['', foot] : [])].join('\n'),
|
|
109
|
+
html: page(site, subject, [...intro, details.join(' — ')], quote, undefined, foot),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function staffMail(repo: ContentRepository, q: RequestRecord, to: string, settings: Settings, origin: string): Promise<Mail> {
|
|
114
|
+
const { lang, locale } = language(settings);
|
|
115
|
+
const w = requestStaffWords(lang);
|
|
116
|
+
const site = settings.siteTitle;
|
|
117
|
+
const user = q.userId ? await repo.getUser(q.userId) : null;
|
|
118
|
+
const who = q.requester ?? { name: user?.name ?? '', email: user?.email ?? '', phone: '', locale: '' };
|
|
119
|
+
const rows: [string, string][] = [];
|
|
120
|
+
let subject: string;
|
|
121
|
+
if (q.kind === 'new') {
|
|
122
|
+
const d = q.data as unknown as NewEventDetails;
|
|
123
|
+
const rooms = await repo.listResources();
|
|
124
|
+
const type = requestSettings(settings).types.find((t) => t.id === d.type);
|
|
125
|
+
subject = fill(w.subjectNew, { title: oneLine(d.title) });
|
|
126
|
+
rows.push([w.when, requestWhen(d.start, d.end, settings, locale)]);
|
|
127
|
+
if (d.repeat !== 'once') rows.push([w.repeats, d.repeat === 'weekly' ? requestStrings(lang).weekly : d.repeatText]);
|
|
128
|
+
rows.push([w.rooms, d.roomsUnsure ? w.roomsUnsure : d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name ?? '').filter(Boolean).join(', ') || '—']);
|
|
129
|
+
if (d.people) rows.push([w.people, String(d.people)]);
|
|
130
|
+
if (type) rows.push([w.type, type.label]);
|
|
131
|
+
} else {
|
|
132
|
+
const e = q.eventId ? await repo.getEvent(q.eventId) : null;
|
|
133
|
+
const title = e?.title ?? String(q.data.title ?? '');
|
|
134
|
+
subject = fill(q.kind === 'change' ? w.subjectChange : w.subjectMove, { title: oneLine(title) });
|
|
135
|
+
rows.push([w.date, q.key ? dayText(q.key, settings, locale) : w.everyDate]);
|
|
136
|
+
const d = q.data as { start?: string; end?: string };
|
|
137
|
+
if (q.kind !== 'change' && d.start && d.end) rows.push([w.when, requestWhen(d.start, d.end, settings, locale)]);
|
|
138
|
+
}
|
|
139
|
+
rows.push([w.from, [who.name, who.email, who.phone].filter(Boolean).join(' · ')]);
|
|
140
|
+
if (who.locale) rows.push([w.language, localeName(who.locale, true)]);
|
|
141
|
+
const quote = q.note ? `${q.kind === 'change' ? w.whatChange : w.message}: “${q.note}”` : '';
|
|
142
|
+
const url = `${origin}/admin/calendar/requests#r-${q.id}`;
|
|
143
|
+
const intro = w.intro[q.source];
|
|
144
|
+
return {
|
|
145
|
+
to, subject: `${subject} · ${site}`, ...(who.email ? { replyTo: who.email } : {}),
|
|
146
|
+
text: [subject, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(quote ? ['', quote] : []), '', `${w.open}: ${url}`, '', w.why].join('\n'),
|
|
147
|
+
html: page(site, subject, [intro, ...rows.map(([k, v]) => `${k}: ${v}`)], quote, { label: w.open, url }, w.why),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The staff push: that a request arrived and for when, nothing the person typed. */
|
|
152
|
+
export async function requestPush(_repo: ContentRepository, q: RequestRecord, settings: Settings): Promise<{ title: string; body: string }> {
|
|
153
|
+
const { lang, locale } = language(settings);
|
|
154
|
+
const w = requestStaffWords(lang);
|
|
155
|
+
const d = q.data as { start?: string; end?: string };
|
|
156
|
+
const when = d.start && d.end ? requestWhen(d.start, d.end, settings, locale) : q.key ? dayText(q.key, settings, locale) : '';
|
|
157
|
+
return { title: w.pushTitle, body: when ? fill(w.pushBody, { when }) : '' };
|
|
158
|
+
}
|