@acms-subir/core 0.3.44 → 0.3.45
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 +24 -0
- package/cli/main.mjs +3 -2
- package/cli/migrations.generated.mjs +11 -0
- package/docs/calendar.md +112 -0
- package/docs/mobile.md +1 -0
- package/docs/server.md +47 -2
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/actions/index.ts +14 -4
- package/src/components/calendar/QuickAdd.tsx +1 -1
- package/src/components/calendar/RoomHolds.tsx +23 -5
- package/src/lib/booking/holds.ts +98 -2
- package/src/lib/booking/service.ts +2 -1
- package/src/lib/calendar/agent.ts +2 -1
- package/src/lib/calendar/api.ts +16 -3
- package/src/lib/calendar/data.ts +12 -1
- package/src/lib/calendar/events.ts +4 -2
- package/src/lib/calendar/hooks.ts +2 -2
- package/src/lib/editor/schedule.ts +3 -0
- package/src/lib/mobile/calendar.ts +49 -11
- package/src/lib/mobile/openapi.ts +3 -1
- package/src/lib/mobile/schemas.ts +8 -3
- package/src/lib/mobile/search.ts +5 -6
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +7 -5
- package/src/lib/schema.ts +10 -0
- package/src/lib/sync/catalog.ts +2 -0
- package/src/pages/admin/calendar/resources/[id].astro +13 -1
- package/src/pages/api/calendar/events/[id].ts +2 -2
- package/src/pages/api/calendar/events/index.ts +7 -4
- package/src/pages/api/v1/rooms/free.ts +7 -0
- package/src/server/index.ts +37 -0
- package/src/styles/admin.css +3 -0
- package/src/styles/editor.css +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.45
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 318e6a1: Rooms: clashes between events, a public free/busy check, and room colours and names in public event data.
|
|
8
|
+
|
|
9
|
+
**Clashes.** Room bookings already couldn't take a room an event holds, but two events could hold the same room at once with no warning. The event editor's Rooms box now warns when another event holds a ticked room at an overlapping time, setup and cleanup included. It shows that event's title (linking to it) and date, for example "Hall is also held by another event · Practice · Thu, Jan 10 · 7:00 – 8:00 PM". Both events keep the room, so the warning says to change a time or pick another room if they can't share it. The room list says "Another event" or "Busy on 3 of 12 dates", and Quick add counts these as busy too. Other events count while they hold rooms: published or scheduled, drafts with Hold now, and not on a cancelled date. New in `@acms-subir/core/server`:
|
|
10
|
+
|
|
11
|
+
- `eventRoomClashes(repo, eventOrId, { horizonDays?, now? })`: the other events holding the same rooms at overlapping times, as `{ hold, other }` pairs.
|
|
12
|
+
- `roomBusy(repo, { roomIds, from, to, ignore? })`: everything that takes the rooms in a span (event holds, bookings, staff blocks and busy calendars), with names for staff. `ignore: { eventId, key }` leaves out the date being moved.
|
|
13
|
+
|
|
14
|
+
Neither function checks permissions. Holds now carry the date's occurrence `key`, and occurrences carry `roomIds`, the rooms that date holds.
|
|
15
|
+
|
|
16
|
+
**Public free/busy.** `GET /api/v1/rooms/free?room=<slug>&start=&end=` answers `{ room, start, end, free, busy: [{ start, end }] }`. The busy times are merged and clipped to the span and never say what or who. Only rooms with the new **Anyone can check if it’s free** switch answer; any other slug is a 404. Spans are at most 31 days, answers are never cached, and each address gets 30 checks a minute.
|
|
17
|
+
|
|
18
|
+
**Room colour and names.** Each room (Admin → Calendar → Rooms) gets:
|
|
19
|
+
|
|
20
|
+
- a **Colour**, for events in it on calendars and screens;
|
|
21
|
+
- **Name it on public events**, off by default, so existing rooms stay unnamed until staff turn it on.
|
|
22
|
+
|
|
23
|
+
Public event data now has `rooms: [{ slug, name, color }]`, listing only rooms with that switch on, with `color` `null` when a room has none. It appears in `/api/v1/events` (on each event and on each occurrence, the rooms that date holds), `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events` and webhook payloads. An API key with `events:read` and the agent tools see every room. Calendar views (`collectViewEvents`: `/calendar`, cards, embeds) give each native event `rooms: [{ id, slug, name, color }]` under the same rule. The editor API's resources take `color`, `showOnEvents` and `requestable`, and environment sync carries them.
|
|
24
|
+
|
|
25
|
+
Migration 64 adds `resources.color`, `show_on_events` and `requestable`; changing the colour or the naming switch refreshes cached pages and API answers. New docs: `npx acms docs calendar` covers repeats, cancelled dates, rooms and clashes, and `npx acms docs server` has the two new functions.
|
|
26
|
+
|
|
3
27
|
## 0.3.44
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/cli/main.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { addMobile, mobileCommand, listApps } from './mobile.mjs';
|
|
|
10
10
|
import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
|
|
11
11
|
import { monitorCommand, renderMonitor } from './monitor.mjs';
|
|
12
12
|
const HELP=`acms — ACMS project, theme and agent tools
|
|
13
|
-
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server
|
|
13
|
+
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar
|
|
14
14
|
agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
|
|
15
15
|
Check: check [--build] validate every project theme, refresh section types,
|
|
16
16
|
report sections without a native app version
|
|
@@ -140,6 +140,7 @@ export async function main() {
|
|
|
140
140
|
else if(cmd==='docs'&&sub==='mobile')data={markdown:await readFile(resolve(CORE,'docs/mobile.md'),'utf8')};
|
|
141
141
|
else if(cmd==='docs'&&sub==='sync')data={markdown:await readFile(resolve(CORE,'docs/environment-sync.md'),'utf8')};
|
|
142
142
|
else if(cmd==='docs'&&sub==='server')data={markdown:await readFile(resolve(CORE,'docs/server.md'),'utf8')};
|
|
143
|
+
else if(cmd==='docs'&&sub==='calendar')data={markdown:await readFile(resolve(CORE,'docs/calendar.md'),'utf8')};
|
|
143
144
|
else if(cmd==='section')data=await sectionCommand(sub,rest,flags);
|
|
144
145
|
else if(cmd==='check') {
|
|
145
146
|
const p=await project(),themes=(await listLocalThemes(p)).filter(t=>t.origin==='project');
|
|
@@ -148,7 +149,7 @@ export async function main() {
|
|
|
148
149
|
}
|
|
149
150
|
else if(cmd==='agent'&&sub==='init')data=await agentInit(flags);
|
|
150
151
|
else if(cmd==='inspect') {
|
|
151
|
-
const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
|
|
152
|
+
const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api','calendar-docs'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
|
|
152
153
|
} else if(cmd==='theme')data=await themeCommand(sub,rest,flags);
|
|
153
154
|
else if(['site','design','content','media'].includes(cmd))data=await remoteCommand(cmd,sub,rest,flags);
|
|
154
155
|
else fail('USAGE','Unknown command.',[],['Run acms --help']);
|
|
@@ -1136,12 +1136,23 @@ var MIGRATIONS = [
|
|
|
1136
1136
|
// with or without the answers); '{}' = off. Not public, so no cache trigger.
|
|
1137
1137
|
{ version: 63, statements: [
|
|
1138
1138
|
`ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`
|
|
1139
|
+
] },
|
|
1140
|
+
// rooms: a colour and whether public event data names them
|
|
1141
|
+
// (Resource.color / .showOnEvents, both visible), and whether anyone may ask
|
|
1142
|
+
// if they're free (Resource.requestable, never shown, so no cache bump)
|
|
1143
|
+
{ version: 64, statements: [
|
|
1144
|
+
`ALTER TABLE resources ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
|
|
1145
|
+
`ALTER TABLE resources ADD COLUMN show_on_events INTEGER NOT NULL DEFAULT 0`,
|
|
1146
|
+
`ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
|
|
1147
|
+
`DROP TRIGGER content_version_resources_update`,
|
|
1148
|
+
contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"])[2]
|
|
1139
1149
|
] }
|
|
1140
1150
|
];
|
|
1141
1151
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1142
1152
|
...CONTENT_VERSION_COLUMNS,
|
|
1143
1153
|
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1144
1154
|
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"],
|
|
1155
|
+
resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
|
|
1145
1156
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
|
|
1146
1157
|
};
|
|
1147
1158
|
var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/docs/calendar.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Calendar: repeats, single dates, rooms and clashes
|
|
2
|
+
|
|
3
|
+
How events repeat, how one date of a series is changed, and how events hold rooms.
|
|
4
|
+
Read it with `npx acms docs calendar`. The functions named here come from
|
|
5
|
+
`@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
|
|
6
|
+
API is in `docs/mobile-api-v1.md` in the framework repository.
|
|
7
|
+
|
|
8
|
+
## Times and repeats
|
|
9
|
+
|
|
10
|
+
An event stores **wall-clock** times in its own timezone (the site's when it has
|
|
11
|
+
none): `start` and `end` are `YYYY-MM-DDTHH:mm`, or `YYYY-MM-DD` for all-day events,
|
|
12
|
+
whose `end` is the last day, inclusive. A 6 PM class stays at 6 PM across daylight
|
|
13
|
+
saving changes.
|
|
14
|
+
|
|
15
|
+
A repeating event has an `rrule`, the RFC 5545 rule without the `RRULE:` prefix, such
|
|
16
|
+
as `FREQ=WEEKLY;BYDAY=TH`. The editor offers daily, weekly on chosen days, monthly by
|
|
17
|
+
date or by weekday ("second Tuesday"), and yearly, each ending never, on a date or
|
|
18
|
+
after a number of times. Other rules round-trip as custom rules. Every repeat is the
|
|
19
|
+
same event: one page, one slug, the same hosts and rooms. The calendar, the app API,
|
|
20
|
+
the ICS feeds and room holds expand it into **occurrences** (dates) as needed.
|
|
21
|
+
|
|
22
|
+
`exdates` skips dates entirely: they vanish from the calendar and feeds. Dragging a
|
|
23
|
+
whole series on the admin Schedule moves the rule with it ("weekly on Thursday" moved a
|
|
24
|
+
day becomes "weekly on Friday").
|
|
25
|
+
|
|
26
|
+
## Occurrence keys
|
|
27
|
+
|
|
28
|
+
Each date of a series has a **key**: its start as wall-clock time in the event's
|
|
29
|
+
timezone, in the same format as `start` (`2030-01-10T18:00`, or `2030-01-10` for
|
|
30
|
+
all-day events). Changes to single dates are stored on the event under that key. Take
|
|
31
|
+
keys from `listOccurrences` (`o.key`) rather than building them.
|
|
32
|
+
|
|
33
|
+
When the series moves to another day, stored keys move with it. When its time or rule
|
|
34
|
+
is edited instead, keys that no longer match a date are ignored, not an error.
|
|
35
|
+
|
|
36
|
+
## Cancelled dates
|
|
37
|
+
|
|
38
|
+
Cancelling one date (**Cancel this date** in the editor, the Schedule, a host's My
|
|
39
|
+
events, or `cancelOccurrence`) applies at once, even on a published event. The date
|
|
40
|
+
stays listed, struck through as "Cancelled", so nobody wonders where it went:
|
|
41
|
+
|
|
42
|
+
- the event page, `/calendar`, `/api/v1/events` and `/api/calendar/events` mark it
|
|
43
|
+
`cancelled`;
|
|
44
|
+
- the ICS feeds add an override for that date with `STATUS:CANCELLED`;
|
|
45
|
+
- its room holds are released for that slot;
|
|
46
|
+
- its RSVPs are cancelled, and each person gets the "cancelled" email. Restoring the
|
|
47
|
+
date doesn't bring them back, so people sign up again;
|
|
48
|
+
- the "When an event changes" push rule can announce it.
|
|
49
|
+
|
|
50
|
+
The keys are stored in `cancelledDates`. `restoreOccurrence` undoes a cancellation.
|
|
51
|
+
|
|
52
|
+
## Rooms
|
|
53
|
+
|
|
54
|
+
Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
|
|
55
|
+
`calendar.bookings`, which is on by default). An event **holds** rooms
|
|
56
|
+
(`roomHold.resourceIds`, the Rooms box in the editor) while it happens, plus any setup
|
|
57
|
+
and cleanup minutes, or all day:
|
|
58
|
+
|
|
59
|
+
- Published and scheduled events hold their rooms. A draft holds them only with **Hold
|
|
60
|
+
now** on. A cancelled event, or a cancelled date, holds nothing.
|
|
61
|
+
- Holds are worked out from the events every time and never stored, so moving,
|
|
62
|
+
repeating, skipping or cancelling a date frees or moves the room automatically.
|
|
63
|
+
- Room bookings (`/book`, embeds, the app, the API) can't take a held room.
|
|
64
|
+
|
|
65
|
+
Each room also has:
|
|
66
|
+
|
|
67
|
+
- **Colour** (`color`, `#rrggbb` or `''`): for events in that room on calendars and
|
|
68
|
+
screens.
|
|
69
|
+
- **Name it on public events** (`showOnEvents`, off by default): public event data
|
|
70
|
+
names the room. When it's off, only staff see that an event uses the room.
|
|
71
|
+
- **Anyone can check if it’s free** (`requestable`, off by default): the room answers
|
|
72
|
+
the public free/busy check below.
|
|
73
|
+
|
|
74
|
+
### Rooms in event data
|
|
75
|
+
|
|
76
|
+
- `/api/v1/events`, `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events`
|
|
77
|
+
and webhook payloads carry `rooms: [{ slug, name, color }]` on each event (`color`
|
|
78
|
+
is `null` when the room has none). Each occurrence also has `rooms`, the rooms that
|
|
79
|
+
date holds. Only rooms with **Name it on public events** appear. Staff callers (an
|
|
80
|
+
API key with `events:read`, the agent tools) see every room.
|
|
81
|
+
- Calendar views built on `collectViewEvents` (the `/calendar` page, calendar cards,
|
|
82
|
+
embeds) get `rooms: [{ id, slug, name, color }]` on each native event's
|
|
83
|
+
occurrence, with the same rule for public views. Admin views get every room.
|
|
84
|
+
- Site code reads `event.roomHold.resourceIds`, or `o.roomIds` on an occurrence.
|
|
85
|
+
|
|
86
|
+
### Clashes
|
|
87
|
+
|
|
88
|
+
Bookings can't take a held room, but two **events** may hold the same room at once.
|
|
89
|
+
The event editor's Rooms box warns when another event holds a ticked room at an
|
|
90
|
+
overlapping time, with that event's title and date. Both events keep the room, so
|
|
91
|
+
change a time or pick another room if they can't share it. The same box still lists
|
|
92
|
+
bookings the event would overlap, with Keep or Cancel and email.
|
|
93
|
+
|
|
94
|
+
Site code and other framework features use the same checks:
|
|
95
|
+
|
|
96
|
+
- `eventRoomClashes(repo, eventOrId)`: the other events holding this event's rooms at
|
|
97
|
+
overlapping times over the next year, as pairs of `{ hold, other }` (one date of each).
|
|
98
|
+
- `roomBusy(repo, { roomIds, from, to, ignore? })`: everything that takes the rooms in
|
|
99
|
+
a span (event holds, bookings, staff blocks and the rooms' busy calendars), with
|
|
100
|
+
names. `ignore: { eventId, key }` leaves out the date you're about to move.
|
|
101
|
+
|
|
102
|
+
Both include names, so show them to staff only. Neither checks permissions.
|
|
103
|
+
|
|
104
|
+
### Public free/busy
|
|
105
|
+
|
|
106
|
+
`GET /api/v1/rooms/free?room=<slug>&start=<day or date-time>&end=<…>` answers
|
|
107
|
+
whether a room is free, plus the busy times inside the span (merged), never what or
|
|
108
|
+
who. Only rooms with **Anyone can check if it’s free** answer, and every other slug is
|
|
109
|
+
a 404. Spans are at most 31 days, and a plain day means midnight in the site's
|
|
110
|
+
timezone. Answers are never cached, and each address gets 30 checks a minute. The
|
|
111
|
+
client has `roomFree({ room, start, end })`, and the native package has
|
|
112
|
+
`useRoomFree(query)`.
|
package/docs/mobile.md
CHANGED
|
@@ -127,6 +127,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
127
127
|
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
|
|
128
128
|
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
129
129
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
130
|
+
| `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
|
|
130
131
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
131
132
|
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
|
|
132
133
|
| `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
|
package/docs/server.md
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getRepo, currentUser,
|
|
13
13
|
listEvents, getEvent, listOccurrences,
|
|
14
14
|
cancelOccurrence, restoreOccurrence,
|
|
15
|
+
eventRoomClashes, roomBusy,
|
|
15
16
|
listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
|
|
16
17
|
withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
|
|
17
18
|
} from '@acms-subir/core/server';
|
|
@@ -32,6 +33,8 @@ getEvent(repo: Repo, id: string): Promise<CalEvent | null>
|
|
|
32
33
|
listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
|
|
33
34
|
cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
34
35
|
restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
36
|
+
eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
|
|
37
|
+
roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
|
|
35
38
|
|
|
36
39
|
// event requests: `who` is currentUser()'s result (anything with `id` and `can()` works)
|
|
37
40
|
listMyEvents(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
|
|
@@ -45,12 +48,16 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
|
|
|
45
48
|
|
|
46
49
|
type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
|
|
47
50
|
interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
|
|
48
|
-
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean }
|
|
51
|
+
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[] }
|
|
52
|
+
interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
|
|
53
|
+
interface EventClash { hold: Hold; other: Hold }
|
|
54
|
+
interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
|
|
49
55
|
interface WriteContext { url: URL; locals: unknown }
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
|
|
53
|
-
`OccurrenceWindow`, `WriteContext`
|
|
59
|
+
`OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash` and
|
|
60
|
+
`BusySlot` are exported too.
|
|
54
61
|
|
|
55
62
|
- **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
|
|
56
63
|
(or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
|
|
@@ -121,6 +128,44 @@ another day (the admin's drag-to-move), its cancelled dates move with it. If its
|
|
|
121
128
|
or rule is edited instead, keys that no longer match an occurrence are ignored, not an
|
|
122
129
|
error.
|
|
123
130
|
|
|
131
|
+
## Rooms
|
|
132
|
+
|
|
133
|
+
Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
|
|
134
|
+
while they happen, plus any setup and cleanup minutes, or all day. Room bookings
|
|
135
|
+
already can't take a held room. Two events, though, may hold the same room at once.
|
|
136
|
+
These two functions find where things want the same room. Both include names, so
|
|
137
|
+
show their results to staff only, and neither checks permissions.
|
|
138
|
+
|
|
139
|
+
- **`eventRoomClashes(repo, event, opts?)`**: other events holding the same rooms as
|
|
140
|
+
this one at overlapping times, soonest first. Pass an event id, or a `CalEvent` with
|
|
141
|
+
unsaved values, which is checked as if it were published. The other events count
|
|
142
|
+
while they hold rooms: when they're published or scheduled, or drafts with **Hold
|
|
143
|
+
now** on, and not on a cancelled date. Each clash pairs one of this event's dates
|
|
144
|
+
(`hold`) with the other event's date (`other`). `hold.start`/`hold.end` include
|
|
145
|
+
setup and cleanup, and `eventStart`/`eventEnd` are the date's own times. It looks a
|
|
146
|
+
year ahead (`horizonDays`). The event editor shows the same check as a warning; both
|
|
147
|
+
events keep the room.
|
|
148
|
+
- **`roomBusy(repo, { roomIds, from, to, ignore? })`**: everything that takes these
|
|
149
|
+
rooms in `[from, to)`, soonest first. That covers events holding them, pending and
|
|
150
|
+
confirmed bookings, staff blocks, and the rooms' busy calendars. `kind` says which,
|
|
151
|
+
and `title` is the event's title, the booker's name or the block's note (`''` for a
|
|
152
|
+
busy calendar). `ignore: { eventId, key }` leaves out one date of an event, such as
|
|
153
|
+
the date you're about to move, and `ignore: { eventId }` leaves out the whole event.
|
|
154
|
+
An empty list means every room is free. Windows are at most 400 days.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// is the hall free next Wednesday 5–6 PM for this one class date?
|
|
158
|
+
const busy = await roomBusy(repo, {
|
|
159
|
+
roomIds: [hall.id], from: new Date('2030-01-09T22:00:00Z'), to: new Date('2030-01-09T23:00:00Z'),
|
|
160
|
+
ignore: { eventId: classEvent.id, key: '2030-01-10T18:00' },
|
|
161
|
+
});
|
|
162
|
+
if (busy.length) return `Taken by ${busy[0].title || 'something else'}`;
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
For people outside the staff, `GET /api/v1/rooms/free?room=<slug>&start=&end=` answers
|
|
166
|
+
only free or busy (with the busy times), never what or who. It works only for rooms
|
|
167
|
+
marked **Anyone can check if it’s free**.
|
|
168
|
+
|
|
124
169
|
## Private event fields
|
|
125
170
|
|
|
126
171
|
A custom field can be private:
|
package/integration.mjs
CHANGED
|
@@ -874,6 +874,8 @@ export default function acms(options = {}) {
|
|
|
874
874
|
['/embed/book/[resource]/[link]', 'embed/book/[resource]/[link].astro'],
|
|
875
875
|
['/api/v1/resources', 'api/v1/resources.ts'],
|
|
876
876
|
['/api/v1/availability', 'api/v1/availability.ts'],
|
|
877
|
+
// free or busy for a requestable room (no names)
|
|
878
|
+
['/api/v1/rooms/free', 'api/v1/rooms/free.ts'],
|
|
877
879
|
['/api/calendar/availability', 'api/calendar/availability.ts'],
|
|
878
880
|
['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
|
|
879
881
|
['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { getCalendarEvents } from '../lib/calendar/ics';
|
|
|
22
22
|
import { siteLocale } from '../lib/dates';
|
|
23
23
|
import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
|
|
24
24
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
25
|
-
import { holdConflicts } from '../lib/booking/holds';
|
|
25
|
+
import { eventClashes, holdConflicts } from '../lib/booking/holds';
|
|
26
26
|
import { setBookingStatus } from '../lib/booking/service';
|
|
27
27
|
import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from '../lib/calendar/events';
|
|
28
28
|
import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
|
|
@@ -1034,8 +1034,9 @@ export const server = {
|
|
|
1034
1034
|
},
|
|
1035
1035
|
}),
|
|
1036
1036
|
// Event editor → Rooms: which rooms/people are free at this event's time
|
|
1037
|
-
// (for every room, so the picker can label them),
|
|
1038
|
-
//
|
|
1037
|
+
// (for every room, so the picker can label them), which existing bookings
|
|
1038
|
+
// the selected rooms would overlap, and which other events hold them then
|
|
1039
|
+
// (a warning: both keep the room). Works on unsaved values.
|
|
1039
1040
|
roomStatus: defineAction({
|
|
1040
1041
|
input: z.object({
|
|
1041
1042
|
eventId: z.string().default(''),
|
|
@@ -1064,7 +1065,7 @@ export const server = {
|
|
|
1064
1065
|
timezone: input.timezone, rrule: input.rrule, exdates: input.exdates, status: 'published', startAt: 0, endAt: 0, createdAt: 0, updatedAt: 0,
|
|
1065
1066
|
roomHold: { ...input.roomHold, resourceIds: resources.map((r) => r.id), keep: input.roomHold.keep },
|
|
1066
1067
|
});
|
|
1067
|
-
const { conflicts, holds } = await holdConflicts(repo, settings, probe, { now });
|
|
1068
|
+
const [{ conflicts, holds }, { clashes }] = await Promise.all([holdConflicts(repo, settings, probe, { now }), eventClashes(repo, settings, probe, { now })]);
|
|
1068
1069
|
const tz = settings.timezone || 'UTC';
|
|
1069
1070
|
const locale = siteLocale(settings);
|
|
1070
1071
|
const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
@@ -1077,9 +1078,18 @@ export const server = {
|
|
|
1077
1078
|
holds: holdsLabel,
|
|
1078
1079
|
rooms: resources.map((r) => {
|
|
1079
1080
|
const mine = conflicts.filter((c) => c.booking.resourceId === r.id);
|
|
1081
|
+
// one row per other event's date, however many of ours it overlaps
|
|
1082
|
+
const others = [...new Map(clashes.filter((c) => c.other.resourceId === r.id).map((c) => [`${c.other.eventId}:${c.other.eventStart}`, c])).values()];
|
|
1080
1083
|
return {
|
|
1081
1084
|
id: r.id, name: r.name, kind: r.kind, capacity: r.capacity,
|
|
1082
1085
|
datesWithBookings: new Set(mine.map((c) => c.hold.eventStart)).size, dates: occurrences,
|
|
1086
|
+
datesWithEvents: new Set(others.map((c) => c.hold.eventStart)).size,
|
|
1087
|
+
datesBusy: new Set([...mine.map((c) => c.hold.eventStart), ...others.map((c) => c.hold.eventStart)]).size,
|
|
1088
|
+
events: others.slice(0, 20).map((c) => ({
|
|
1089
|
+
id: `${c.other.eventId}:${c.other.eventStart}`, eventId: c.other.eventId, title: c.other.title,
|
|
1090
|
+
when: `${day.format(c.other.eventStart)} · ${range(c.other.eventStart, c.other.eventEnd)}`,
|
|
1091
|
+
})),
|
|
1092
|
+
eventCount: others.length,
|
|
1083
1093
|
conflicts: mine.slice(0, 20).map((c) => ({
|
|
1084
1094
|
id: c.booking.id, name: c.booking.kind === 'block' ? `Blocked${c.booking.notes ? ` · ${c.booking.notes}` : ''}` : c.booking.name,
|
|
1085
1095
|
block: c.booking.kind === 'block', status: c.booking.status,
|
|
@@ -98,7 +98,7 @@ export default function QuickAdd(props: Props) {
|
|
|
98
98
|
const s = allDay ? day : `${day}T${from}`;
|
|
99
99
|
const e = allDay ? (endDay < day ? day : endDay) : `${endDay < day ? day : endDay}T${to}`;
|
|
100
100
|
fetchRoomStatus({ eventId: '', title, start: s, end: e, allDay, timezone: '', rrule: repeatRule(repeat, day), exdates: [] }, NO_HOLD)
|
|
101
|
-
.then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length }))))
|
|
101
|
+
.then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length + r.eventCount }))))
|
|
102
102
|
.catch(() => {});
|
|
103
103
|
}, 350);
|
|
104
104
|
return () => clearTimeout(t);
|
|
@@ -5,7 +5,8 @@ import { actions } from 'astro:actions';
|
|
|
5
5
|
// they can't be booked while it happens (± setup/cleanup) or all day. Each
|
|
6
6
|
// room shows whether it's free at the event's time; overlapping bookings show
|
|
7
7
|
// right here, with Keep (share the room) or Cancel and email. The same check
|
|
8
|
-
// runs again when you publish (RoomConflictDialog).
|
|
8
|
+
// runs again when you publish (RoomConflictDialog). Other events holding the
|
|
9
|
+
// room at the same time show as a warning: both keep it.
|
|
9
10
|
|
|
10
11
|
export interface RoomHoldValue {
|
|
11
12
|
resourceIds: string[];
|
|
@@ -28,7 +29,12 @@ export interface RoomTimes {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
interface Conflict { id: string; name: string; when: string; status: string; block: boolean }
|
|
31
|
-
interface
|
|
32
|
+
interface EventClash { id: string; eventId: string; title: string; when: string }
|
|
33
|
+
interface RoomRow {
|
|
34
|
+
id: string; name: string; kind: string; capacity: number | null; conflicts: Conflict[]; datesWithBookings: number; dates: number;
|
|
35
|
+
// other events holding it at the same time (eventCount: all of them, events: the first 20)
|
|
36
|
+
events: EventClash[]; eventCount: number; datesWithEvents: number; datesBusy: number;
|
|
37
|
+
}
|
|
32
38
|
interface Status { rooms: RoomRow[]; holds: string }
|
|
33
39
|
|
|
34
40
|
export const NO_HOLD: RoomHoldValue = { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] };
|
|
@@ -77,11 +83,13 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
|
|
|
77
83
|
if (auto) onLocation(names(ids));
|
|
78
84
|
};
|
|
79
85
|
const label = (r: RoomRow) => {
|
|
80
|
-
if (!r.conflicts.length) return { text: 'Free', cls: 'free' };
|
|
81
|
-
if (r.dates > 1) return { text: `Busy on ${r.
|
|
82
|
-
return { text: r.
|
|
86
|
+
if (!r.conflicts.length && !r.eventCount) return { text: 'Free', cls: 'free' };
|
|
87
|
+
if (r.dates > 1) return { text: `Busy on ${r.datesBusy} of ${r.dates} dates`, cls: 'busy' };
|
|
88
|
+
if (!r.conflicts.length) return { text: r.eventCount === 1 ? 'Another event' : `${r.eventCount} other events`, cls: 'busy' };
|
|
89
|
+
return { text: r.conflicts.length === 1 && !r.eventCount ? 'Busy' : `Busy · ${r.conflicts.length + r.eventCount} bookings and events`, cls: 'busy' };
|
|
83
90
|
};
|
|
84
91
|
const conflicted = data.rooms.filter((r) => selected.has(r.id) && r.conflicts.length);
|
|
92
|
+
const shared = data.rooms.filter((r) => selected.has(r.id) && r.eventCount);
|
|
85
93
|
const draft = status === 'draft';
|
|
86
94
|
|
|
87
95
|
async function cancelBookings(ids: string[]) {
|
|
@@ -157,6 +165,16 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
|
|
|
157
165
|
)}
|
|
158
166
|
</div>
|
|
159
167
|
))}
|
|
168
|
+
{shared.map((r) => (
|
|
169
|
+
<div key={r.id} className="rh-conflict rh-clash" role="status">
|
|
170
|
+
<b>{r.name} is also held by {r.eventCount === 1 ? 'another event' : `other events on ${r.eventCount} dates`}</b>
|
|
171
|
+
{r.events.slice(0, 5).map((c) => (
|
|
172
|
+
<div key={c.id} className="rh-bk"><a href={`/admin/calendar/events/${c.eventId}`} target="_blank" rel="noopener">{c.title}</a><small>{c.when}</small></div>
|
|
173
|
+
))}
|
|
174
|
+
{r.eventCount > 5 && <small className="rh-more">+{r.eventCount - 5} more</small>}
|
|
175
|
+
<small className="rh-more">Both events keep the room. Change a time or pick another room if they can’t share it.</small>
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
160
178
|
{note && <p className="pe-ev-note rh-done">{note}</p>}
|
|
161
179
|
{data.holds && <p className="pe-ev-note">{data.holds}{draft && !hold.whileDraft ? ' once published.' : '.'}</p>}
|
|
162
180
|
</>
|
package/src/lib/booking/holds.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// moving, repeating, skipping a date, cancelling the event or one of its
|
|
5
5
|
// dates, or deleting it frees or moves the room automatically. availabilityContext() adds these to its
|
|
6
6
|
// busy list, so every booking path (pages, embeds, app, API, agent) obeys.
|
|
7
|
+
// Two events may hold the same room at once: eventClashes() finds those for
|
|
8
|
+
// staff, and roomBusy() answers "is this room taken then?" for one span.
|
|
7
9
|
import type { ContentRepository } from '../repo/types';
|
|
8
10
|
import type { Booking, CalEvent, Resource, Settings } from '../schema';
|
|
9
11
|
import { expandEvents } from '../calendar/events';
|
|
@@ -13,6 +15,8 @@ import { resourceZone } from './availability';
|
|
|
13
15
|
export interface Hold {
|
|
14
16
|
resourceId: string;
|
|
15
17
|
eventId: string;
|
|
18
|
+
// the occurrence key of the date that holds it (lib/calendar/events.ts)
|
|
19
|
+
key: string;
|
|
16
20
|
title: string;
|
|
17
21
|
start: number; // incl. setup
|
|
18
22
|
end: number; // incl. cleanup
|
|
@@ -58,7 +62,8 @@ export function holdsFromEvents(
|
|
|
58
62
|
// a cancelled date frees the room for that slot only
|
|
59
63
|
if (o.cancelled) continue;
|
|
60
64
|
const h = o.event.roomHold;
|
|
61
|
-
|
|
65
|
+
// the rooms this date holds
|
|
66
|
+
for (const resourceId of o.roomIds) {
|
|
62
67
|
if (opts.resourceId && resourceId !== opts.resourceId) continue;
|
|
63
68
|
const r = resources.get(resourceId);
|
|
64
69
|
if (!r) continue;
|
|
@@ -73,7 +78,7 @@ export function holdsFromEvents(
|
|
|
73
78
|
end = o.end + h.after * 60_000;
|
|
74
79
|
}
|
|
75
80
|
if (end <= from || start >= to) continue;
|
|
76
|
-
out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
|
|
81
|
+
out.push({ resourceId, eventId: o.event.id, key: o.key, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
return out.sort((a, b) => a.start - b.start);
|
|
@@ -120,3 +125,94 @@ export async function holdConflicts(
|
|
|
120
125
|
}
|
|
121
126
|
return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
|
|
122
127
|
}
|
|
128
|
+
|
|
129
|
+
// ---- events against events ------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface EventClash {
|
|
132
|
+
/** one date of the event being checked, in one room */
|
|
133
|
+
hold: Hold;
|
|
134
|
+
/** another event's date holding that room at an overlapping time */
|
|
135
|
+
other: Hold;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Other events holding the same rooms at overlapping times (setup and cleanup
|
|
140
|
+
* included), soonest first. Like holdConflicts, `event` may be unsaved and is
|
|
141
|
+
* checked as if published; the others count only while they hold rooms
|
|
142
|
+
* (published or scheduled, drafts with "Hold now", not cancelled). These are
|
|
143
|
+
* warnings for staff: both events keep the room.
|
|
144
|
+
*/
|
|
145
|
+
export async function eventClashes(
|
|
146
|
+
repo: ContentRepository,
|
|
147
|
+
settings: Pick<Settings, 'timezone'>,
|
|
148
|
+
event: CalEvent,
|
|
149
|
+
opts: { now?: number; horizonDays?: number } = {}
|
|
150
|
+
): Promise<{ clashes: EventClash[]; holds: Hold[] }> {
|
|
151
|
+
const now = opts.now ?? Date.now();
|
|
152
|
+
const to = now + (opts.horizonDays ?? 366) * DAY;
|
|
153
|
+
const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
|
|
154
|
+
const byId = new Map(resources.map((r) => [r.id, r]));
|
|
155
|
+
const holds = holdsFromEvents([event], byId, settings, now, to, { force: true });
|
|
156
|
+
if (!holds.length) return { clashes: [], holds };
|
|
157
|
+
const rooms = new Set(holds.map((h) => h.resourceId));
|
|
158
|
+
const others = events.filter((e) => e.id !== event.id && e.roomHold.resourceIds.some((id) => rooms.has(id)));
|
|
159
|
+
const from = Math.min(...holds.map((h) => h.start)), until = Math.max(...holds.map((h) => h.end));
|
|
160
|
+
const theirs = holdsFromEvents(others, byId, settings, from, until);
|
|
161
|
+
const clashes: EventClash[] = [];
|
|
162
|
+
for (const hold of holds) {
|
|
163
|
+
for (const other of theirs) {
|
|
164
|
+
if (other.resourceId === hold.resourceId && other.start < hold.end && other.end > hold.start) clashes.push({ hold, other });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { clashes, holds };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface BusySlot {
|
|
171
|
+
resourceId: string;
|
|
172
|
+
start: number;
|
|
173
|
+
end: number;
|
|
174
|
+
/** an event holding it, a booking, a staff block, or one of its busy calendars */
|
|
175
|
+
kind: 'event' | 'booking' | 'block' | 'calendar';
|
|
176
|
+
/** the event's title, the booker's name or the block's note; '' for busy calendars. Staff only. */
|
|
177
|
+
title: string;
|
|
178
|
+
eventId?: string;
|
|
179
|
+
/** the event date's occurrence key */
|
|
180
|
+
key?: string;
|
|
181
|
+
bookingId?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Everything that takes these rooms in [from, to): event holds (setup and
|
|
186
|
+
* cleanup included), pending and confirmed bookings and blocks, and the rooms'
|
|
187
|
+
* busy calendars. `ignore` leaves out one event, or one date of it (its key):
|
|
188
|
+
* the date being moved doesn't block its own new time. Names are for staff;
|
|
189
|
+
* the public free/busy check (GET /api/v1/rooms/free) only reports the times.
|
|
190
|
+
*/
|
|
191
|
+
export async function roomBusy(
|
|
192
|
+
repo: ContentRepository,
|
|
193
|
+
settings: Pick<Settings, 'timezone'>,
|
|
194
|
+
q: { resourceIds: string[]; from: number; to: number; ignore?: { eventId: string; key?: string }; now?: number }
|
|
195
|
+
): Promise<BusySlot[]> {
|
|
196
|
+
const ids = new Set(q.resourceIds);
|
|
197
|
+
if (!ids.size || q.to <= q.from) return [];
|
|
198
|
+
const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
|
|
199
|
+
const rooms = resources.filter((r) => ids.has(r.id));
|
|
200
|
+
const byId = new Map(rooms.map((r) => [r.id, r]));
|
|
201
|
+
const out: BusySlot[] = [];
|
|
202
|
+
const holding = events.filter((e) => e.roomHold.resourceIds.some((id) => ids.has(id)) && !(q.ignore && e.id === q.ignore.eventId && !q.ignore.key));
|
|
203
|
+
for (const h of holdsFromEvents(holding, byId, settings, q.from, q.to)) {
|
|
204
|
+
if (q.ignore?.key && h.eventId === q.ignore.eventId && h.key === q.ignore.key) continue;
|
|
205
|
+
out.push({ resourceId: h.resourceId, start: h.start, end: h.end, kind: 'event', title: h.title, eventId: h.eventId, key: h.key });
|
|
206
|
+
}
|
|
207
|
+
// dynamic: service.ts imports this module
|
|
208
|
+
const { busyIntervals } = await import('./service');
|
|
209
|
+
await Promise.all(rooms.map(async (r) => {
|
|
210
|
+
const bookings = await repo.listBookings({ resourceId: r.id, from: q.from, to: q.to, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
|
|
211
|
+
// a booking's real time, as holdConflicts compares it
|
|
212
|
+
for (const b of bookings) out.push({ resourceId: r.id, start: b.start, end: b.end, kind: b.kind === 'block' ? 'block' : 'booking', title: b.kind === 'block' ? b.notes : b.name, bookingId: b.id });
|
|
213
|
+
for (const i of await busyIntervals(r, q.from, q.to, q.now ?? Date.now())) {
|
|
214
|
+
if (i.end > q.from && i.start < q.to) out.push({ resourceId: r.id, start: i.start, end: i.end, kind: 'calendar', title: '' });
|
|
215
|
+
}
|
|
216
|
+
}));
|
|
217
|
+
return out.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
218
|
+
}
|
|
@@ -50,7 +50,8 @@ export const WAITLIST_HOLD = 24 * 3_600_000;
|
|
|
50
50
|
|
|
51
51
|
const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
/** a resource's busy calendars (Resource.busyFeeds) in [from, to), cached for 5 minutes */
|
|
54
|
+
export async function busyIntervals(resource: Resource, from: number, to: number, now: number): Promise<Interval[]> {
|
|
54
55
|
if (!resource.busyFeeds.length) return [];
|
|
55
56
|
const key = `${resource.id}:${resource.busyFeeds.join('|')}:${Math.floor(from / 86_400_000)}`;
|
|
56
57
|
const hit = busyCache.get(key);
|
|
@@ -110,7 +110,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
|
|
|
110
110
|
case 'get_event': {
|
|
111
111
|
const e = await repo.getEvent(input.arguments.id);
|
|
112
112
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
113
|
-
|
|
113
|
+
const rooms = new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
114
|
+
return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone, { includePrivate: true, resources: rooms }), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
|
|
114
115
|
}
|
|
115
116
|
case 'create_event': {
|
|
116
117
|
const a = input.arguments;
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// webhook payloads and agent tools, so integrations see the same fields
|
|
3
3
|
// everywhere. Drafts, internal ids of private calendars, staging data and
|
|
4
4
|
// private custom fields (unless a staff caller asks for them) never appear
|
|
5
|
-
// here.
|
|
6
|
-
import type
|
|
5
|
+
// here. Rooms are named only when set to show on public events (or for staff).
|
|
6
|
+
import { roomColor, type CalEvent, type Calendar, type Resource } from '../schema';
|
|
7
7
|
import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
|
|
8
8
|
import { describeRepeat } from './rrule';
|
|
9
9
|
|
|
@@ -33,13 +33,25 @@ export interface EventJson {
|
|
|
33
33
|
imageAlt: string;
|
|
34
34
|
tags: string[];
|
|
35
35
|
calendars: { id: string; slug: string; name: string; color: string }[];
|
|
36
|
+
// the rooms it holds (roomHold.resourceIds); color null = the room has none
|
|
37
|
+
rooms: RoomJson[];
|
|
36
38
|
fields: Record<string, string>;
|
|
37
39
|
updatedAt: string;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
export interface RoomJson { slug: string; name: string; color: string | null }
|
|
43
|
+
|
|
44
|
+
/** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
|
|
45
|
+
export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
|
|
46
|
+
if (!resources) return [];
|
|
47
|
+
return ids.map((id) => resources.get(id)).filter((r): r is Resource => Boolean(r) && (includePrivate || r!.showOnEvents))
|
|
48
|
+
.map((r) => ({ slug: r.slug, name: r.name, color: roomColor(r) || null }));
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
// resources: every room by id (rooms stay [] without it)
|
|
54
|
+
export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource> } = {}): EventJson {
|
|
43
55
|
return {
|
|
44
56
|
id: e.id,
|
|
45
57
|
slug: e.slug,
|
|
@@ -68,6 +80,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
68
80
|
.map((id) => calendars.get(id))
|
|
69
81
|
.filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
|
|
70
82
|
.map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
|
|
83
|
+
rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate),
|
|
71
84
|
fields: opts.includePrivate ? e.fields : publicEventFields(e.fields),
|
|
72
85
|
updatedAt: new Date(e.updatedAt).toISOString(),
|
|
73
86
|
};
|