@acms-subir/core 0.3.3 → 0.3.5
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 +42 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +32 -4
- package/src/components/import/ImportExport.tsx +4 -4
- package/src/components/import/runner.ts +20 -3
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/import/bundle.ts +2 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react';
|
|
2
2
|
import type { EventCta, EventLocation } from '../../lib/schema';
|
|
3
3
|
import type { EventFieldConfig } from '../../lib/calendar/config';
|
|
4
|
+
import WhereItLands from '../calendar/WhereItLands';
|
|
5
|
+
import RoomHolds, { type RoomHoldValue } from '../calendar/RoomHolds';
|
|
4
6
|
import { buildRepeat, describeRepeat, parseRepeat, WEEKDAY_CODES, weekdayOf, type RepeatParts } from '../../lib/calendar/rrule';
|
|
5
7
|
|
|
6
8
|
// The event half of the editor sidebar: When · Repeats · Where · Button ·
|
|
@@ -21,6 +23,7 @@ export interface EventMeta {
|
|
|
21
23
|
calendarIds: string[];
|
|
22
24
|
fields: Record<string, string>;
|
|
23
25
|
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
|
|
26
|
+
roomHold: RoomHoldValue;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface CalendarOption {
|
|
@@ -35,6 +38,11 @@ interface Props {
|
|
|
35
38
|
calendars: CalendarOption[];
|
|
36
39
|
fields: EventFieldConfig[];
|
|
37
40
|
siteTimezone: string;
|
|
41
|
+
// shows the "where it lands" mini month (Schedule data, minus this event)
|
|
42
|
+
eventId?: string;
|
|
43
|
+
// for the Rooms section (conflict wording, "Hold now" on drafts)
|
|
44
|
+
title?: string;
|
|
45
|
+
status?: string;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
const DAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
|
@@ -64,7 +72,7 @@ function zoneList(): string[] {
|
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }: Props) {
|
|
75
|
+
export function EventPanel({ meta, onChange, calendars, fields, siteTimezone, eventId, title = '', status = 'draft' }: Props) {
|
|
68
76
|
const repeat = useMemo(() => parseRepeat(meta.rrule, meta.start), [meta.rrule, meta.start]);
|
|
69
77
|
const [skipDraft, setSkipDraft] = useState('');
|
|
70
78
|
const zones = useMemo(zoneList, []);
|
|
@@ -136,6 +144,10 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
|
|
|
136
144
|
{!meta.allDay && !showEndDate && (
|
|
137
145
|
<button type="button" className="pe-ev-link" onClick={() => setShowEndDate(true)}>Ends on another day</button>
|
|
138
146
|
)}
|
|
147
|
+
{eventId && datePart(meta.start) && (
|
|
148
|
+
<WhereItLands date={datePart(meta.start)} eventId={eventId}
|
|
149
|
+
onPick={(d) => setStart(meta.allDay ? d : `${d}T${timePart(meta.start) || '10:00'}`)} />
|
|
150
|
+
)}
|
|
139
151
|
<label className="pe-switch-row">
|
|
140
152
|
<span><b>All day</b><small>No start or end time</small></span>
|
|
141
153
|
<input type="checkbox" role="switch" className="pe-switch" checked={meta.allDay} onChange={(e) => toggleAllDay(e.target.checked)} />
|
|
@@ -277,6 +289,15 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
|
|
|
277
289
|
</label>
|
|
278
290
|
</section>
|
|
279
291
|
|
|
292
|
+
<RoomHolds
|
|
293
|
+
times={{ eventId: eventId ?? '', title, start: meta.start, end: meta.end, allDay: meta.allDay, timezone: meta.timezone, rrule: meta.rrule, exdates: meta.exdates }}
|
|
294
|
+
hold={meta.roomHold}
|
|
295
|
+
status={status}
|
|
296
|
+
locationName={meta.location.name}
|
|
297
|
+
onChange={(roomHold) => onChange({ roomHold })}
|
|
298
|
+
onLocation={(name) => onChange({ location: { ...meta.location, name } })}
|
|
299
|
+
/>
|
|
300
|
+
|
|
280
301
|
<section className="pe-sb-section pe-ev">
|
|
281
302
|
<h3 className="pe-sb-title">Button</h3>
|
|
282
303
|
<div className="pe-ev-row">
|
|
@@ -327,7 +348,7 @@ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }:
|
|
|
327
348
|
<section className="pe-sb-section pe-ev">
|
|
328
349
|
<h3 className="pe-sb-title">Calendars</h3>
|
|
329
350
|
{calendars.length === 0 ? (
|
|
330
|
-
<p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/
|
|
351
|
+
<p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/settings?new=1" target="_blank" rel="noopener">Create one</a> to group events.</p>
|
|
331
352
|
) : (
|
|
332
353
|
<div className="pe-ev-cals">
|
|
333
354
|
{calendars.map((c) => {
|
|
@@ -31,6 +31,7 @@ import { I18N, localeHomeUrl } from '../../lib/i18n';
|
|
|
31
31
|
import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../lib/drafts';
|
|
32
32
|
import { CALENDAR, eventUrl, type EventFieldConfig } from '../../lib/calendar/config';
|
|
33
33
|
import { EventPanel, eventWhenLabel, type CalendarOption, type EventMeta } from './EventPanel';
|
|
34
|
+
import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
|
|
34
35
|
|
|
35
36
|
// The Ghost-style writing surface, shared by posts AND pages: distraction-free
|
|
36
37
|
// column, cards via "/", autosave, a collapsible settings sidebar, and an
|
|
@@ -149,6 +150,7 @@ const NO_EVENT: EventMeta = {
|
|
|
149
150
|
start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
|
|
150
151
|
location: { name: '', address: '', url: '' }, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
151
152
|
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
|
|
153
|
+
roomHold: NO_HOLD,
|
|
152
154
|
};
|
|
153
155
|
|
|
154
156
|
export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
|
|
@@ -176,7 +178,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
176
178
|
template: event!.template, publishedAt: event!.publishedAt, scheduledAt: event!.scheduledAt,
|
|
177
179
|
start: event!.start, end: event!.end, allDay: event!.allDay, timezone: event!.timezone, rrule: event!.rrule,
|
|
178
180
|
exdates: event!.exdates, cancelled: event!.cancelled, location: event!.location, cta: event!.cta,
|
|
179
|
-
calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp,
|
|
181
|
+
calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp, roomHold: event!.roomHold,
|
|
180
182
|
}
|
|
181
183
|
: {
|
|
182
184
|
...NO_EVENT,
|
|
@@ -192,7 +194,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
192
194
|
const col = kind === 'event'
|
|
193
195
|
? { singular: 'Event', plural: 'Events', base: CALENDAR.eventsBase, redirectFrom: [] }
|
|
194
196
|
: kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
|
|
195
|
-
const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar
|
|
197
|
+
const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar' : `/admin/c/${meta.collection}`;
|
|
196
198
|
const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
|
|
197
199
|
|
|
198
200
|
const [authors, setAuthors] = useState<AuthorOption[]>([]);
|
|
@@ -408,6 +410,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
408
410
|
calendarIds: m.calendarIds,
|
|
409
411
|
fields: m.fields,
|
|
410
412
|
rsvp: m.rsvp,
|
|
413
|
+
roomHold: m.roomHold,
|
|
411
414
|
scheduledAt: m.scheduledAt,
|
|
412
415
|
translations,
|
|
413
416
|
});
|
|
@@ -711,8 +714,17 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
711
714
|
toastTimer.current = setTimeout(() => setToast(null), 4500);
|
|
712
715
|
}
|
|
713
716
|
|
|
714
|
-
|
|
717
|
+
// publishing or scheduling an event that holds rooms: check for bookings
|
|
718
|
+
// that overlap first, and let the author keep or cancel each one
|
|
719
|
+
const [roomGate, setRoomGate] = useState<{ patch: Partial<Meta>; rooms: Parameters<typeof RoomConflictDialog>[0]['rooms'] } | null>(null);
|
|
720
|
+
async function transition(patch: Partial<Meta>, roomsChecked = false) {
|
|
715
721
|
setPublishOpen(false);
|
|
722
|
+
const m0 = metaRef.current;
|
|
723
|
+
if (kind === 'event' && !roomsChecked && (patch.status === 'published' || patch.status === 'scheduled') && m0.roomHold.resourceIds.length) {
|
|
724
|
+
const st = await fetchRoomStatus({ eventId: docId, title: m0.title, start: m0.start, end: m0.end, allDay: m0.allDay, timezone: m0.timezone, rrule: m0.rrule, exdates: m0.exdates }, m0.roomHold).catch(() => null);
|
|
725
|
+
const rooms = (st?.rooms ?? []).filter((r) => m0.roomHold.resourceIds.includes(r.id) && r.conflicts.length);
|
|
726
|
+
if (rooms.length) { setRoomGate({ patch, rooms }); return; }
|
|
727
|
+
}
|
|
716
728
|
const next = { ...metaRef.current, ...patch };
|
|
717
729
|
metaRef.current = next;
|
|
718
730
|
setMetaState(next);
|
|
@@ -902,6 +914,19 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
902
914
|
|
|
903
915
|
return (
|
|
904
916
|
<div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
|
|
917
|
+
{roomGate && (
|
|
918
|
+
<RoomConflictDialog
|
|
919
|
+
rooms={roomGate.rooms}
|
|
920
|
+
onCancel={() => setRoomGate(null)}
|
|
921
|
+
onConfirm={async ({ keep, cancel, message }) => {
|
|
922
|
+
if (cancel.length) await actions.events.cancelBookingsForEvent({ ids: cancel, message });
|
|
923
|
+
const hold = metaRef.current.roomHold;
|
|
924
|
+
const patch = { ...roomGate.patch, roomHold: { ...hold, keep: [...new Set([...hold.keep, ...keep])] } };
|
|
925
|
+
setRoomGate(null);
|
|
926
|
+
await transition(patch, true);
|
|
927
|
+
}}
|
|
928
|
+
/>
|
|
929
|
+
)}
|
|
905
930
|
{toast && (
|
|
906
931
|
<div className="pe-toast" role="status">
|
|
907
932
|
<span className="pe-toast-check">✓</span>
|
|
@@ -911,7 +936,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
911
936
|
)}
|
|
912
937
|
<header className="pe-topbar">
|
|
913
938
|
<div className="pe-topbar-left">
|
|
914
|
-
<a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : col.plural}</span></a>
|
|
939
|
+
<a className="pe-btn ghost sm" href={listHref} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); requestLeave('list'); }} aria-label={kind === 'page' ? 'Back to pages' : kind === 'event' ? 'Back to the schedule' : `Back to ${col.plural.toLowerCase()}`}>‹<span className="pe-hide-sm">{kind === 'page' ? 'Pages' : kind === 'event' ? 'Schedule' : col.plural}</span></a>
|
|
915
940
|
<span className={`pe-savestate ${saveState}`}>
|
|
916
941
|
<i className="pe-save-dot" aria-hidden="true" />
|
|
917
942
|
{STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>}<span className="pe-hide-sm"> · {saveLabel}</span>
|
|
@@ -1538,11 +1563,14 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1538
1563
|
|
|
1539
1564
|
{kind === 'event' && (
|
|
1540
1565
|
<EventPanel
|
|
1566
|
+
eventId={docId}
|
|
1541
1567
|
meta={meta}
|
|
1542
1568
|
onChange={(patch) => setMeta(patch)}
|
|
1543
1569
|
calendars={calendars}
|
|
1544
1570
|
fields={eventFields}
|
|
1545
1571
|
siteTimezone={siteTimezone}
|
|
1572
|
+
title={meta.title}
|
|
1573
|
+
status={meta.status}
|
|
1546
1574
|
/>
|
|
1547
1575
|
)}
|
|
1548
1576
|
|
|
@@ -98,7 +98,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
|
|
|
98
98
|
const key = planKeyFor(options);
|
|
99
99
|
const stale = !!plan && planKey !== key;
|
|
100
100
|
const assetFiles = bundle ? bundle.assets.filter((a) => a.kind !== 'media').length : 0;
|
|
101
|
-
const needsFiles = isGhost &&
|
|
101
|
+
const needsFiles = isGhost && !!bundle?.assets.some((a) => !/^https?:\/\//i.test(a.url)) && !siteUrl.trim() && !contentZip && !source?.zip;
|
|
102
102
|
const resumable = source ? readJournal(source.hash) : null;
|
|
103
103
|
const toImport = plan ? plan.counts.posts.create + plan.counts.posts.update + plan.counts.pages.create + plan.counts.pages.update : 0;
|
|
104
104
|
|
|
@@ -198,7 +198,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
|
|
|
198
198
|
<span className="btn sm">{contentZip ? 'Change ZIP' : 'Add content folder ZIP'}</span>
|
|
199
199
|
<span className="hint">{contentZip ? `${contentZip.name} · ${plural(contentZip.zip.names.length, 'file')}` : 'Optional. Used first; the address is the fallback.'}</span>
|
|
200
200
|
</label>
|
|
201
|
-
{needsFiles && <p className="imp-mark warn">
|
|
201
|
+
{needsFiles && <p className="imp-mark warn">Enter the old site’s address or add its content folder ZIP before importing. Missing files without a source URL are left empty and listed in the report.</p>}
|
|
202
202
|
</div>
|
|
203
203
|
)}
|
|
204
204
|
|
|
@@ -252,7 +252,7 @@ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHi
|
|
|
252
252
|
<div className="imp-actions">
|
|
253
253
|
<button type="button" className="btn" disabled={!!busy} onClick={check}>{busy === 'plan' ? 'Checking…' : plan ? 'Check again' : 'Check import'}</button>
|
|
254
254
|
{plan && (
|
|
255
|
-
<button type="button" className="btn primary" disabled={!!busy || stale || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
|
|
255
|
+
<button type="button" className="btn primary" disabled={!!busy || stale || needsFiles || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
|
|
256
256
|
{busy === 'run' ? 'Importing…' : resumable?.runId ? `Resume import` : `Import ${plural(toImport, 'item')}`}
|
|
257
257
|
</button>
|
|
258
258
|
)}
|
|
@@ -341,7 +341,7 @@ function Done({ report, reset, openHistory }: { report: Report; reset: () => voi
|
|
|
341
341
|
)}
|
|
342
342
|
{!!report.failedAssets?.length && (
|
|
343
343
|
<>
|
|
344
|
-
<p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied
|
|
344
|
+
<p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied. Source URLs are kept when available; otherwise the references are left empty.</p>
|
|
345
345
|
<ul className="imp-list">{report.failedAssets.map((f, i) => <li key={i}>{f.url}<small>{f.error}</small></li>)}</ul>
|
|
346
346
|
</>
|
|
347
347
|
)}
|
|
@@ -101,8 +101,14 @@ export function findInZip(zip: ZipIndex, path: string): string | null {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
export function sourceAssetUrl(bundle: ImportBundle, asset: ImportAsset): string {
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// A ZIP path is not a URL on the destination site. Only an absolute
|
|
105
|
+
// source URL is safe to retain when copying fails.
|
|
106
|
+
try {
|
|
107
|
+
const url = /^[a-z][a-z\d+.-]*:/i.test(asset.url)
|
|
108
|
+
? new URL(asset.url)
|
|
109
|
+
: new URL(asset.url.replace(/^\/+/, ''), `${bundle.source.siteUrl.replace(/\/+$/, '')}/`);
|
|
110
|
+
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
|
111
|
+
} catch { return ''; }
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
async function fetchAsset(bundle: ImportBundle, asset: ImportAsset, contentZip: ZipIndex | null): Promise<File> {
|
|
@@ -170,6 +176,10 @@ export async function runImport(
|
|
|
170
176
|
const journal: Journal = readJournal(hash) ?? { runId: '', assets: {}, failed: {}, done: [], authorIds: {} };
|
|
171
177
|
const save = () => writeJournal(hash, journal);
|
|
172
178
|
|
|
179
|
+
if (source.kind === 'ghost' && !files && bundle.assets.some((a) => !journal.assets[a.ref] && !sourceAssetUrl(bundle, a))) {
|
|
180
|
+
throw new Error('This Ghost export references images or files. Enter the old site’s address or add its content folder ZIP before importing.');
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
// 1. files → media library
|
|
174
184
|
const assets = bundle.assets.filter((a) => a.kind !== 'media');
|
|
175
185
|
const total = assets.length + bundle.media.length;
|
|
@@ -214,6 +224,13 @@ export async function runImport(
|
|
|
214
224
|
|
|
215
225
|
const resolved = new Map(Object.entries(journal.assets).map(([ref, r]) => [ref, r]));
|
|
216
226
|
const fallback = new Map(bundle.assets.map((a) => [a.ref, sourceAssetUrl(bundle, a)]));
|
|
227
|
+
// Video/audio are not copied yet, but missing source URLs still need to
|
|
228
|
+
// be reported instead of quietly turning into destination-relative URLs.
|
|
229
|
+
for (const a of bundle.assets.filter((a) => a.kind === 'media')) {
|
|
230
|
+
if (!resolved.has(a.ref) && !fallback.get(a.ref)) {
|
|
231
|
+
journal.failed[a.ref] = 'Video/audio cannot be copied yet. Enter the old site’s address to keep its source URL.';
|
|
232
|
+
} else delete journal.failed[a.ref];
|
|
233
|
+
}
|
|
217
234
|
const fix = (v: string) => resolveAssets(v, resolved, fallback);
|
|
218
235
|
|
|
219
236
|
// 2. run + authors + tags; only people with a byline become authors
|
|
@@ -261,7 +278,7 @@ export async function runImport(
|
|
|
261
278
|
const identity = Object.fromEntries(Object.entries(bundle.identity).map(([k, v]) => [k, typeof v === 'string' ? fix(v) : v]));
|
|
262
279
|
const failedAssets = Object.entries(journal.failed).map(([ref, error]) => {
|
|
263
280
|
const a = bundle.assets.find((x) => x.ref === ref);
|
|
264
|
-
return { url: a ? sourceAssetUrl(bundle, a) : ref.replace(/^m-/, 'media '), error };
|
|
281
|
+
return { url: a ? sourceAssetUrl(bundle, a) || a.url : ref.replace(/^m-/, 'media '), error };
|
|
265
282
|
});
|
|
266
283
|
const { report } = await api<{ report: Record<string, unknown> }>({
|
|
267
284
|
op: 'finish', runId: journal.runId, options, identity,
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -109,15 +109,14 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
109
109
|
? [{
|
|
110
110
|
id: 'calendar',
|
|
111
111
|
label: 'Calendar',
|
|
112
|
+
// two homes: Schedule (anything on a date — grid, list, calendar
|
|
113
|
+
// settings) and Bookings (anything people reserve — requests, rooms &
|
|
114
|
+
// people, booking links). Sub-pages highlight their home.
|
|
112
115
|
items: [
|
|
113
|
-
{ key: 'calendar:
|
|
116
|
+
{ key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
|
|
114
117
|
...(bookingsEnabled()
|
|
115
|
-
? [
|
|
116
|
-
{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 },
|
|
117
|
-
{ key: 'calendar:resources', label: 'Resources', href: '/admin/calendar/resources', icon: icons.resources, count: null },
|
|
118
|
-
]
|
|
118
|
+
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
119
119
|
: []),
|
|
120
|
-
{ key: 'calendar:calendars', label: 'Calendars', href: '/admin/calendar/calendars', icon: icons.calendars, count: null },
|
|
121
120
|
],
|
|
122
121
|
}]
|
|
123
122
|
: []),
|
package/src/layouts/Site.astro
CHANGED
|
@@ -14,6 +14,7 @@ import { diffDesign } from '../lib/designDiff';
|
|
|
14
14
|
import { getTheme } from '../lib/theme';
|
|
15
15
|
import { analyticsHead, analyticsBody } from '../lib/analytics';
|
|
16
16
|
import { socialIconPath, socialIconSvg } from '../lib/socialIcons';
|
|
17
|
+
import type { SiteChrome } from '../lib/site-chrome';
|
|
17
18
|
import '../styles/site.css';
|
|
18
19
|
import '../styles/cards.css';
|
|
19
20
|
import '../styles/live.css';
|
|
@@ -136,6 +137,30 @@ const readLinks = [
|
|
|
136
137
|
...(SEARCH.enabled ? [{ label: 'Search', url: searchAction }] : []),
|
|
137
138
|
{ label: 'RSS', url: '/rss.xml' },
|
|
138
139
|
];
|
|
140
|
+
// a theme's templates/header.astro / footer.astro get this instead of core markup
|
|
141
|
+
const langName = (l: string) => {
|
|
142
|
+
try { const n = new Intl.DisplayNames([l], { type: 'language' }).of(l); return n ? n[0].toLocaleUpperCase(l) + n.slice(1) : l.toUpperCase(); }
|
|
143
|
+
catch { return l.toUpperCase(); }
|
|
144
|
+
};
|
|
145
|
+
const chromeLink = (n: { label: string; url: string }) => ({ label: n.label, url: lnk(n.url), current: isCurrent(lnk(n.url)), external: isExternal(n.url) });
|
|
146
|
+
const chrome: SiteChrome = {
|
|
147
|
+
title: settings.siteTitle, tagline: settings.tagline, logo: settings.siteLogo,
|
|
148
|
+
homeUrl: reqLocale ? localeHomeUrl(reqLocale) : '/',
|
|
149
|
+
links: headerLinks.map(chromeLink),
|
|
150
|
+
footerLinks: footerNav.map(chromeLink),
|
|
151
|
+
social: socialNav.map((n) => ({ ...chromeLink(n), icon: n.icon })),
|
|
152
|
+
readLinks: readLinks.map(chromeLink),
|
|
153
|
+
footerHtml,
|
|
154
|
+
languages: I18N.enabled ? I18N.locales.map((l) => ({ code: l, label: langName(l), url: localePath(barePath, l), current: l === (reqLocale || I18N.defaultLocale) })) : [],
|
|
155
|
+
search: showSearch ? { url: searchAction, label: searchT.label } : null,
|
|
156
|
+
schemeToggle,
|
|
157
|
+
liveHeader: Boolean(liveSignals?.header),
|
|
158
|
+
headerStyle: String(tokens.header), footerStyle: String(tokens.footer),
|
|
159
|
+
};
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
161
|
+
const HeaderTpl = design.theme.HeaderTemplate as any;
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
const FooterTpl = design.theme.FooterTemplate as any;
|
|
139
164
|
// hreflang alternates: every public page exists in each language (translated
|
|
140
165
|
// words where available, the main language otherwise)
|
|
141
166
|
const isHomePath = I18N.enabled && !noindex && !bare;
|
|
@@ -246,7 +271,8 @@ const homeJsonLd =
|
|
|
246
271
|
</script>
|
|
247
272
|
)}
|
|
248
273
|
{!bare && <a class="skip-link" href="#main">Skip to content</a>}
|
|
249
|
-
{!bare &&
|
|
274
|
+
{!bare && HeaderTpl && <HeaderTpl site={chrome} />}
|
|
275
|
+
{!bare && !HeaderTpl && (
|
|
250
276
|
<header class="site-header" data-style={tokens.header}>
|
|
251
277
|
<div class="site-header-in">
|
|
252
278
|
<a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
|
|
@@ -644,7 +670,8 @@ const homeJsonLd =
|
|
|
644
670
|
<main class="site-main" id="main">
|
|
645
671
|
<slot />
|
|
646
672
|
</main>
|
|
647
|
-
{!bare &&
|
|
673
|
+
{!bare && FooterTpl && <FooterTpl site={chrome} />}
|
|
674
|
+
{!bare && !FooterTpl && (
|
|
648
675
|
<footer class="site-footer" data-style={tokens.footer}>
|
|
649
676
|
<div class="site-footer-in">
|
|
650
677
|
{tokens.footer === 'columns' ? (
|
|
@@ -2,6 +2,11 @@ import { z } from 'zod';
|
|
|
2
2
|
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
|
|
3
3
|
import type { ContentRepository } from '../repo/types';
|
|
4
4
|
|
|
5
|
+
// backups from before booking approval modes: a link that didn't confirm on
|
|
6
|
+
// its own must stay manual (a missing `approval` would follow the site default)
|
|
7
|
+
const legacyApproval = (l: unknown) =>
|
|
8
|
+
l && typeof l === 'object' && !('approval' in l) && (l as { autoConfirm?: boolean }).autoConfirm === false ? { ...l, approval: 'manual' } : l;
|
|
9
|
+
|
|
5
10
|
export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
|
|
6
11
|
export type BookingRequest = z.infer<typeof BookingRequest>;
|
|
7
12
|
export const BackupContent = z.object({
|
|
@@ -9,7 +14,7 @@ export const BackupContent = z.object({
|
|
|
9
14
|
settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
|
|
10
15
|
tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
|
|
11
16
|
calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
|
|
12
|
-
bookingLinks: z.array(BookingLink).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
|
|
17
|
+
bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
|
|
13
18
|
}).superRefine((data, ctx) => {
|
|
14
19
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
15
20
|
const keys = new Set<string>();
|
package/src/lib/booking/agent.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { ApiError } from '../api';
|
|
|
8
8
|
import { bookingsEnabled } from '../calendar/config';
|
|
9
9
|
import { availabilityContext, bookingJson, BookingError, createResourceBooking, rescheduleBooking, setBookingStatus } from './service';
|
|
10
10
|
import { addDaysYmd, describeLink, slotsForDay, ymd } from './availability';
|
|
11
|
+
import { confirmsInstantly, linkApproval } from './approval';
|
|
11
12
|
|
|
12
13
|
export const BookingToolInputs = [
|
|
13
14
|
z.object({ tool: z.literal('list_resources'), arguments: z.object({}).strict() }).strict(),
|
|
@@ -65,7 +66,7 @@ export async function runBookingTool(repo: ContentRepository, input: Input, allo
|
|
|
65
66
|
try {
|
|
66
67
|
switch (input.tool) {
|
|
67
68
|
case 'list_resources':
|
|
68
|
-
return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: l
|
|
69
|
+
return { resources: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, capacity: r.capacity, visibility: r.visibility, links: links.filter((l) => l.resourceId === r.id).map((l) => ({ slug: l.slug, name: l.name, rules: describeLink(l), mode: l.mode, lengths: l.mode === 'range' ? l.lengths : [l.duration], autoConfirm: confirmsInstantly(linkApproval(l, settings)), active: l.active, visibility: l.visibility })) })) };
|
|
69
70
|
case 'check_availability': {
|
|
70
71
|
const { r, l } = resolve(input.arguments.resource, input.arguments.link);
|
|
71
72
|
const c = await availabilityContext(repo, r, l, settings);
|
|
@@ -74,7 +75,7 @@ export async function runBookingTool(repo: ContentRepository, input: Input, allo
|
|
|
74
75
|
const days = Array.from({ length: input.arguments.days }, (_, i) => addDaysYmd(from, i)).map((date) => ({
|
|
75
76
|
date, slots: slotsForDay(c, date, input.arguments.minutes).slice(0, 60).map((s) => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
|
|
76
77
|
}));
|
|
77
|
-
return { timezone: tz, autoConfirm: l
|
|
78
|
+
return { timezone: tz, autoConfirm: confirmsInstantly(linkApproval(l, settings)), days };
|
|
78
79
|
}
|
|
79
80
|
case 'list_bookings': {
|
|
80
81
|
const a = input.arguments;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Booking approval: does a new booking confirm at once, or wait for a
|
|
2
|
+
// decision? Each link either follows the site default (Calendar settings →
|
|
3
|
+
// Bookings, seeded from acms({ calendar: { bookings: { approval } } })) or
|
|
4
|
+
// picks its own mode. "rules" confirms unless one of the rules flags the
|
|
5
|
+
// booking. Pure apart from the first-time lookup, which the caller supplies.
|
|
6
|
+
import type { ApprovalMode, ApprovalRules, BookingLink, Settings } from '../schema';
|
|
7
|
+
import { CALENDAR } from '../calendar/config';
|
|
8
|
+
|
|
9
|
+
export interface Approval {
|
|
10
|
+
mode: ApprovalMode;
|
|
11
|
+
rules: ApprovalRules;
|
|
12
|
+
// true when the link follows the site default
|
|
13
|
+
inherited: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function siteApproval(settings: Pick<Settings, 'calendar'>): { mode: ApprovalMode; rules: ApprovalRules } {
|
|
17
|
+
return { mode: settings.calendar.approval || CALENDAR.bookingApproval, rules: settings.calendar.approvalRules };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function linkApproval(link: Pick<BookingLink, 'approval' | 'approvalRules'>, settings: Pick<Settings, 'calendar'>): Approval {
|
|
21
|
+
if (link.approval === 'default') return { ...siteApproval(settings), inherited: true };
|
|
22
|
+
return { mode: link.approval, rules: link.approvalRules, inherited: false };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// what the public page promises before someone books
|
|
26
|
+
export function confirmsInstantly(a: Approval): boolean {
|
|
27
|
+
return a.mode === 'auto';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ApprovalInput {
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
seats: number;
|
|
34
|
+
// has this email booked (any status but declined/cancelled) before?
|
|
35
|
+
returning: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// why a "rules" booking needs a person to look at it; [] = confirm now
|
|
39
|
+
export function approvalReasons(a: Approval, b: ApprovalInput): string[] {
|
|
40
|
+
if (a.mode === 'auto') return [];
|
|
41
|
+
if (a.mode === 'manual') return ['Every booking on this link needs approval'];
|
|
42
|
+
const r = a.rules;
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
if (r.longerThan > 0 && (b.end - b.start) / 60_000 > r.longerThan) out.push(`Longer than ${durationLabel(r.longerThan)}`);
|
|
45
|
+
if (r.firstTime && !b.returning) out.push('First booking from this email');
|
|
46
|
+
if (r.moreSeats > 0 && b.seats > r.moreSeats) out.push(`More than ${r.moreSeats} seats`);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decideStatus(a: Approval, b: ApprovalInput): 'confirmed' | 'pending' {
|
|
51
|
+
return approvalReasons(a, b).length ? 'pending' : 'confirmed';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function durationLabel(min: number): string {
|
|
55
|
+
if (min % 1440 === 0) return `${min / 1440} day${min === 1440 ? '' : 's'}`;
|
|
56
|
+
if (min % 60 === 0) return `${min / 60} hour${min === 60 ? '' : 's'}`;
|
|
57
|
+
if (min > 60) return `${Math.floor(min / 60)} h ${min % 60} min`;
|
|
58
|
+
return `${min} min`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const APPROVAL_LABEL: Record<ApprovalMode, string> = {
|
|
62
|
+
auto: 'Automatically confirm',
|
|
63
|
+
rules: 'Automatically, unless…',
|
|
64
|
+
manual: 'I approve each booking',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// one short phrase for rows and badges: "Instant" / "Needs approval" / "Instant, with rules"
|
|
68
|
+
export function approvalShort(a: Approval): string {
|
|
69
|
+
return a.mode === 'auto' ? 'Instant' : a.mode === 'manual' ? 'Needs approval' : 'Instant, with rules';
|
|
70
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Rooms held by events. An event that lists rooms/people in roomHold makes
|
|
2
|
+
// them unbookable while it happens (± setup/cleanup) or for the whole day.
|
|
3
|
+
// Holds are computed from events every time — never stored as bookings — so
|
|
4
|
+
// moving, repeating, skipping a date, cancelling or deleting the event frees
|
|
5
|
+
// or moves the room automatically. availabilityContext() adds these to its
|
|
6
|
+
// busy list, so every booking path (pages, embeds, app, API, agent) obeys.
|
|
7
|
+
import type { ContentRepository } from '../repo/types';
|
|
8
|
+
import type { Booking, CalEvent, Resource, Settings } from '../schema';
|
|
9
|
+
import { expandEvents } from '../calendar/events';
|
|
10
|
+
import { tzParts, zonedToUtc } from '../calendar/ics';
|
|
11
|
+
import { resourceZone } from './availability';
|
|
12
|
+
|
|
13
|
+
export interface Hold {
|
|
14
|
+
resourceId: string;
|
|
15
|
+
eventId: string;
|
|
16
|
+
title: string;
|
|
17
|
+
start: number; // incl. setup
|
|
18
|
+
end: number; // incl. cleanup
|
|
19
|
+
eventStart: number; // the occurrence itself
|
|
20
|
+
eventEnd: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DAY = 86_400_000;
|
|
24
|
+
|
|
25
|
+
/** Does this event hold its rooms right now? */
|
|
26
|
+
export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold'>): boolean {
|
|
27
|
+
if (e.cancelled || !e.roomHold.resourceIds.length) return false;
|
|
28
|
+
return e.status === 'published' || e.status === 'scheduled' || (e.status === 'draft' && e.roomHold.whileDraft);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// midnight (in tz) of the day containing ms
|
|
32
|
+
function dayStart(ms: number, tz: string): number {
|
|
33
|
+
const p = tzParts(ms, tz);
|
|
34
|
+
return zonedToUtc(p.y, p.m, p.d, 0, 0, 0, tz);
|
|
35
|
+
}
|
|
36
|
+
function nextDayStart(ms: number, tz: string): number {
|
|
37
|
+
// +26h then back to that day's midnight survives DST changes
|
|
38
|
+
return dayStart(dayStart(ms, tz) + DAY + 2 * 3_600_000, tz);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Holds for the given events in [from, to). `force` counts drafts too (editor previews). */
|
|
42
|
+
export function holdsFromEvents(
|
|
43
|
+
events: CalEvent[],
|
|
44
|
+
resources: Map<string, Pick<Resource, 'id' | 'timezone'>>,
|
|
45
|
+
settings: Pick<Settings, 'timezone'>,
|
|
46
|
+
from: number,
|
|
47
|
+
to: number,
|
|
48
|
+
opts: { force?: boolean; resourceId?: string } = {}
|
|
49
|
+
): Hold[] {
|
|
50
|
+
const out: Hold[] = [];
|
|
51
|
+
const holding = events.filter((e) => (opts.force ? !e.cancelled && e.roomHold.resourceIds.length > 0 : holdsRooms(e))
|
|
52
|
+
&& (!opts.resourceId || e.roomHold.resourceIds.includes(opts.resourceId)));
|
|
53
|
+
if (!holding.length) return out;
|
|
54
|
+
// widen the window so setup/cleanup and whole-day holds at the edges count
|
|
55
|
+
const pad = 2 * DAY;
|
|
56
|
+
const occurrences = expandEvents(holding, settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from - pad, windowEndMs: to + pad });
|
|
57
|
+
for (const o of occurrences) {
|
|
58
|
+
const h = o.event.roomHold;
|
|
59
|
+
for (const resourceId of h.resourceIds) {
|
|
60
|
+
if (opts.resourceId && resourceId !== opts.resourceId) continue;
|
|
61
|
+
const r = resources.get(resourceId);
|
|
62
|
+
if (!r) continue;
|
|
63
|
+
let start: number;
|
|
64
|
+
let end: number;
|
|
65
|
+
if (h.span === 'day') {
|
|
66
|
+
const tz = resourceZone(r, settings.timezone || 'UTC');
|
|
67
|
+
start = dayStart(o.start, tz);
|
|
68
|
+
end = nextDayStart(Math.max(o.start, o.end - 1), tz);
|
|
69
|
+
} else {
|
|
70
|
+
start = o.start - h.before * 60_000;
|
|
71
|
+
end = o.end + h.after * 60_000;
|
|
72
|
+
}
|
|
73
|
+
if (end <= from || start >= to) continue;
|
|
74
|
+
out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out.sort((a, b) => a.start - b.start);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Holds on the site's live events (what booking pages must respect). */
|
|
81
|
+
export async function eventHolds(repo: ContentRepository, settings: Pick<Settings, 'timezone'>, from: number, to: number, resourceId?: string): Promise<Hold[]> {
|
|
82
|
+
const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
|
|
83
|
+
return holdsFromEvents(events, new Map(resources.map((r) => [r.id, r])), settings, from, to, { resourceId });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface HoldConflict {
|
|
87
|
+
booking: Pick<Booking, 'id' | 'name' | 'email' | 'start' | 'end' | 'status' | 'resourceId' | 'kind' | 'notes'>;
|
|
88
|
+
hold: Hold;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Bookings that overlap an event's holds. `event` may be unsaved (the
|
|
93
|
+
* editor's current values): holds are computed as if it were published.
|
|
94
|
+
* Repeating events are checked over the next `horizonDays` (default a year).
|
|
95
|
+
*/
|
|
96
|
+
export async function holdConflicts(
|
|
97
|
+
repo: ContentRepository,
|
|
98
|
+
settings: Pick<Settings, 'timezone'>,
|
|
99
|
+
event: CalEvent,
|
|
100
|
+
opts: { now?: number; horizonDays?: number } = {}
|
|
101
|
+
): Promise<{ conflicts: HoldConflict[]; holds: Hold[] }> {
|
|
102
|
+
const now = opts.now ?? Date.now();
|
|
103
|
+
const to = now + (opts.horizonDays ?? 366) * DAY;
|
|
104
|
+
const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
105
|
+
const holds = holdsFromEvents([event], resources, settings, now, to, { force: true });
|
|
106
|
+
if (!holds.length) return { conflicts: [], holds };
|
|
107
|
+
const keep = new Set(event.roomHold.keep);
|
|
108
|
+
const conflicts: HoldConflict[] = [];
|
|
109
|
+
for (const resourceId of new Set(holds.map((h) => h.resourceId))) {
|
|
110
|
+
const mine = holds.filter((h) => h.resourceId === resourceId);
|
|
111
|
+
const bookings = await repo.listBookings({ resourceId, from: mine[0].start, to: mine[mine.length - 1].end, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
|
|
112
|
+
for (const b of bookings) {
|
|
113
|
+
if (keep.has(b.id)) continue;
|
|
114
|
+
// a booking's own buffers (hold_start/end) don't count, only its real time
|
|
115
|
+
const hold = mine.find((h) => b.start < h.end && b.end > h.start);
|
|
116
|
+
if (hold) conflicts.push({ booking: { id: b.id, name: b.name, email: b.email, start: b.start, end: b.end, status: b.status, resourceId: b.resourceId, kind: b.kind, notes: b.notes }, hold });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
|
|
120
|
+
}
|