@acms-subir/core 0.3.55 → 0.3.57
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 +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- 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/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- package/src/lib/calendar/api.ts +23 -2
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +9 -2
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +212 -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/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- 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/calendar.ts +1 -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 +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -20,7 +20,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
20
20
|
import { EventLocation, type CalEvent, type MovedDate } from '../schema';
|
|
21
21
|
import { ApiError, coded } from '../api';
|
|
22
22
|
import { eventSideEffects, type SaveHooks } from '../content-service';
|
|
23
|
-
import { addWallDays, addWallMinutes, eventRoomIds, 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, partOf, partMinutes, type SeriesPart } 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';
|
|
@@ -103,14 +103,14 @@ const started = () => refuse(400, 'started', 'That date has already started or p
|
|
|
103
103
|
|
|
104
104
|
// The new wall times: `start` in the event's form, `end` defaulting to the
|
|
105
105
|
// date's own length (all-day: the same number of days)
|
|
106
|
-
function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
|
|
106
|
+
function newTimes(e: CalEvent, input: MoveInput, part: SeriesPart): { start: string; end: string } {
|
|
107
107
|
const s = parseWall(input.start);
|
|
108
108
|
if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
|
|
109
109
|
if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
|
|
110
110
|
let end = input.end;
|
|
111
111
|
if (!end) {
|
|
112
|
-
|
|
113
|
-
const length =
|
|
112
|
+
// the length of its part's dates (a change from a date on may have its own)
|
|
113
|
+
const length = partMinutes(part);
|
|
114
114
|
end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
|
|
115
115
|
}
|
|
116
116
|
try {
|
|
@@ -162,7 +162,9 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
|
|
|
162
162
|
const settings = await repo.getSettings();
|
|
163
163
|
const current = dateToChange(e, key, settings);
|
|
164
164
|
const prior = e.movedDates[key];
|
|
165
|
-
|
|
165
|
+
// its part of the series (CalEvent.segments): the length, rooms and place it has without a move
|
|
166
|
+
const part = partOf(e, current);
|
|
167
|
+
const times = newTimes(e, input, part);
|
|
166
168
|
|
|
167
169
|
let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
|
|
168
170
|
if (roomIds !== undefined) {
|
|
@@ -175,11 +177,11 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
|
|
|
175
177
|
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
178
|
}
|
|
177
179
|
// the event's own rooms aren't an override
|
|
178
|
-
if (sameRooms(roomIds,
|
|
180
|
+
if (sameRooms(roomIds, part.roomIds)) roomIds = undefined;
|
|
179
181
|
}
|
|
180
182
|
let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
|
|
181
183
|
if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
|
|
182
|
-
if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
|
|
184
|
+
if (location && JSON.stringify(location) === JSON.stringify(part.location ?? e.location)) location = undefined;
|
|
183
185
|
const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
|
|
184
186
|
|
|
185
187
|
const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
|
|
@@ -306,6 +308,8 @@ export interface DateRow {
|
|
|
306
308
|
/** "Thu, Jan 10 · 6:00 – 7:00 PM" */
|
|
307
309
|
when: string;
|
|
308
310
|
cancelled: boolean;
|
|
311
|
+
/** cancelled because every date from some date on is (a cancelled part, CalEvent.segments) */
|
|
312
|
+
partCancelled: boolean;
|
|
309
313
|
/** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
|
|
310
314
|
moved: { from: string; note: string } | null;
|
|
311
315
|
roomIds: string[];
|
|
@@ -319,19 +323,21 @@ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentR
|
|
|
319
323
|
const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
320
324
|
const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
321
325
|
const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
|
|
322
|
-
const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
|
|
323
|
-
const length = es && ee ? wallMinutesBetween(es, ee) : 60;
|
|
324
326
|
return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
|
|
325
327
|
.slice(0, limit)
|
|
326
328
|
.map((o) => {
|
|
327
329
|
const m = o.moved ? e.movedDates[o.key] : undefined;
|
|
328
330
|
const k = parseWall(o.key)!;
|
|
331
|
+
const part = partOf(e, o);
|
|
332
|
+
const length = partMinutes(part);
|
|
329
333
|
return {
|
|
330
334
|
key: o.key,
|
|
331
335
|
start: m?.start ?? o.key,
|
|
332
336
|
end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
|
|
333
337
|
when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
|
|
334
338
|
cancelled: o.cancelled,
|
|
339
|
+
// cancelled with the rest of its part (cancelFromDate): restored from the change, not one by one
|
|
340
|
+
partCancelled: part.cancelled,
|
|
335
341
|
moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
|
|
336
342
|
roomIds: o.roomIds,
|
|
337
343
|
};
|
|
@@ -0,0 +1,212 @@
|
|
|
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' || q.kind === 'from-date') {
|
|
114
|
+
// a host's date move, or change from a date on ("Thu, Jan 17, 6:00 PM → Wed, Jan 16, 5:00 PM · Weekly on Wednesday")
|
|
115
|
+
title = e?.title ?? 'A deleted event';
|
|
116
|
+
line = await dateRequestWhen(repo, q);
|
|
117
|
+
if (tab === 'pending' && e) {
|
|
118
|
+
const found = await dateRequestClashes(repo, q);
|
|
119
|
+
clash = found.length
|
|
120
|
+
? { 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` : ''}` }
|
|
121
|
+
: { taken: false, text: 'Its rooms are free then' };
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
title = e?.title ?? String(q.data.title ?? 'A deleted event');
|
|
125
|
+
line = q.key ? f.key(q.key) : 'every date';
|
|
126
|
+
}
|
|
127
|
+
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)}`;
|
|
128
|
+
rows.push({
|
|
129
|
+
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)}`,
|
|
130
|
+
title, line, who, email: q.requester?.email ?? users.find((u) => u.id === q.userId)?.email ?? '', note: q.note, clash, answer, reply: q.reply,
|
|
131
|
+
href: q.eventId ? `/admin/calendar/events/${q.eventId}` : '', at: q.updatedAt, canReply: true, sort: q.state === 'pending' ? q.createdAt : q.reviewedAt,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// members' requests staged on their events (lib/calendar/requests.ts): waiting ones, and declined ones until the host dismisses them
|
|
135
|
+
if (tab !== 'approved' && MEMBERS.enabled && page === 1) {
|
|
136
|
+
const staged = tab === 'pending' ? await pendingRequests(repo) : events.filter((e) => e.request?.state === 'declined');
|
|
137
|
+
for (const e of staged) rows.push({ ...await memberRow(repo, e, settings, userName, now), sort: e.request!.at });
|
|
138
|
+
}
|
|
139
|
+
rows.sort((a, b) => (tab === 'pending' ? a.sort - b.sort : b.sort - a.sort));
|
|
140
|
+
return { rows: rows.map(({ sort: _, ...r }) => r), more: found.length > ROWS_PER_PAGE };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function memberRow(repo: ContentRepository, e: CalEvent, settings: Settings, userName: Map<string, string>, now: number): Promise<RequestRow> {
|
|
144
|
+
const req = e.request!;
|
|
145
|
+
const shown = mergeEventDraft(e);
|
|
146
|
+
const o = featuredOccurrence(shown, settings, now);
|
|
147
|
+
const f = fmt(settings);
|
|
148
|
+
let clash: RequestRow['clash'] = null;
|
|
149
|
+
if (req.state === 'pending' && shown.roomHold.resourceIds.length) {
|
|
150
|
+
const { clashes } = await eventClashes(repo, settings, { ...shown, status: 'published' } as CalEvent, { now, horizonDays: 120 });
|
|
151
|
+
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' };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
key: `e:${e.id}`, dom: `e-${e.id}`, id: e.id, kind: req.kind === 'new' ? 'member-new' : 'member-changes',
|
|
155
|
+
head: `${req.kind === 'new' ? 'New event' : 'Changes'} · from a host on My events · ${ago(now, req.at)}`,
|
|
156
|
+
title: shown.title || 'Untitled event', line: o ? `${formatOccurrence(o, f.tz, f.locale).date} · ${formatOccurrence(o, f.tz, f.locale).time}` : '',
|
|
157
|
+
who: userName.get(req.by) ?? 'A member', email: '', note: req.note, clash,
|
|
158
|
+
answer: req.state === 'declined' ? `Declined${req.reviewedBy ? ` by ${userName.get(req.reviewedBy) ?? 'staff'}` : ''} · ${ago(now, req.reviewedAt)}` : '',
|
|
159
|
+
reply: req.reply, href: `/admin/calendar/events/${e.id}`, at: req.at, canReply: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** How many wait, for the tab's count: rows of their own plus members' requests. */
|
|
164
|
+
export async function pendingCount(repo: ContentRepository): Promise<number> {
|
|
165
|
+
return (await repo.requests.count({ states: ['pending'] })) + (MEMBERS.enabled ? (await pendingRequests(repo)).length : 0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lines = (v: FormDataEntryValue | null) => String(v ?? '').split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
169
|
+
const num = (v: FormDataEntryValue | null, fallback: number) => { const n = Number.parseInt(String(v ?? ''), 10); return Number.isFinite(n) ? n : fallback; };
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The settings form: types as rows (type-label-N, type-tag-N, type-cal-N and
|
|
173
|
+
* type-<locale>-N; a row without a label is dropped), the words in each
|
|
174
|
+
* language, the phone, who's told, the daily cap and how long answered
|
|
175
|
+
* requests are kept.
|
|
176
|
+
*/
|
|
177
|
+
export function settingsFromForm(data: FormData, current: RequestSettings, calendarIds: string[]): RequestSettings {
|
|
178
|
+
const types: RequestSettings['types'] = [];
|
|
179
|
+
// ids of the kinds already saved, so a new kind never takes one (renamed kinds keep theirs)
|
|
180
|
+
const keptIds = new Set(Array.from({ length: 12 }, (_, i) => String(data.get(`type-id-${i}`) ?? '')).filter(Boolean));
|
|
181
|
+
for (let i = 0; i < 12; i++) {
|
|
182
|
+
const label = String(data.get(`type-label-${i}`) ?? '').trim().slice(0, 60);
|
|
183
|
+
if (!label) continue;
|
|
184
|
+
if (types.some((t) => t.label.toLowerCase() === label.toLowerCase())) throw new ApiError(400, `Two kinds are called “${label}”. Give each kind its own name.`);
|
|
185
|
+
// a kind keeps its id when renamed (requests point at it); a new one gets a free id from its name
|
|
186
|
+
const kept = String(data.get(`type-id-${i}`) ?? '');
|
|
187
|
+
const base = slugify(label.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')).slice(0, 36) || 'kind';
|
|
188
|
+
let id = kept && !types.some((t) => t.id === kept) ? kept : base;
|
|
189
|
+
for (let n = 2; types.some((t) => t.id === id) || (!kept && keptIds.has(id)); n++) id = `${base}-${n}`;
|
|
190
|
+
const cal = String(data.get(`type-cal-${i}`) ?? '');
|
|
191
|
+
types.push({ id, label, tag: String(data.get(`type-tag-${i}`) ?? '').trim().slice(0, 60), calendarId: calendarIds.includes(cal) ? cal : '',
|
|
192
|
+
translations: Object.fromEntries(extraLocales().map((l) => [l, String(data.get(`type-${l}-${i}`) ?? '').trim().slice(0, 60)]).filter(([, v]) => v)) });
|
|
193
|
+
}
|
|
194
|
+
const notify = lines(data.get('notify'));
|
|
195
|
+
const bad = notify.find((a) => !validAddress(a));
|
|
196
|
+
if (bad) throw new ApiError(400, `“${bad.slice(0, 80)}” isn’t an email address.`);
|
|
197
|
+
if (notify.length > 20) throw new ApiError(400, 'Add up to 20 addresses.');
|
|
198
|
+
const parsed = RequestSettings.safeParse({
|
|
199
|
+
open: data.get('open') === '1', types,
|
|
200
|
+
replyTime: String(data.get('replyTime') ?? '').trim(), notice: String(data.get('notice') ?? '').trim(),
|
|
201
|
+
translations: Object.fromEntries(extraLocales().map((l) => [l, { replyTime: String(data.get(`replyTime-${l}`) ?? '').trim(), notice: String(data.get(`notice-${l}`) ?? '').trim() }])),
|
|
202
|
+
phone: String(data.get('phone') ?? '').trim(), notify, phones: data.has('phones') ? data.getAll('phones').includes('1') : current.phones,
|
|
203
|
+
perDay: num(data.get('perDay'), current.perDay), keepDays: num(data.get('keepDays'), current.keepDays),
|
|
204
|
+
});
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
const field = String(parsed.error.issues[0]?.path[0] ?? '');
|
|
207
|
+
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.',
|
|
208
|
+
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.' };
|
|
209
|
+
throw new ApiError(400, why[field] ?? 'Check the request settings.');
|
|
210
|
+
}
|
|
211
|
+
return parsed.data;
|
|
212
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|