@acms-subir/core 0.3.44 → 0.3.46

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/cli/main.mjs +3 -2
  3. package/cli/migrations.generated.mjs +128 -16
  4. package/docs/calendar.md +112 -0
  5. package/docs/environment-sync.md +6 -0
  6. package/docs/mobile.md +1 -0
  7. package/docs/server.md +47 -2
  8. package/integration.mjs +2 -0
  9. package/package.json +1 -1
  10. package/src/actions/index.ts +16 -8
  11. package/src/components/calendar/QuickAdd.tsx +1 -1
  12. package/src/components/calendar/RoomHolds.tsx +23 -5
  13. package/src/lib/booking/holds.ts +98 -2
  14. package/src/lib/booking/service.ts +2 -1
  15. package/src/lib/calendar/agent.ts +5 -5
  16. package/src/lib/calendar/api.ts +16 -3
  17. package/src/lib/calendar/change-migration.ts +65 -0
  18. package/src/lib/calendar/data.ts +12 -1
  19. package/src/lib/calendar/events.ts +4 -2
  20. package/src/lib/calendar/hooks.ts +76 -31
  21. package/src/lib/calendar/requests.ts +2 -3
  22. package/src/lib/calendar/schedule.ts +4 -4
  23. package/src/lib/content-service.ts +15 -12
  24. package/src/lib/editor/schedule.ts +3 -0
  25. package/src/lib/mobile/calendar.ts +49 -11
  26. package/src/lib/mobile/openapi.ts +3 -1
  27. package/src/lib/mobile/schemas.ts +8 -3
  28. package/src/lib/mobile/search.ts +5 -6
  29. package/src/lib/push/service.ts +1 -1
  30. package/src/lib/repo/migrations.ts +15 -0
  31. package/src/lib/repo/sql.ts +36 -5
  32. package/src/lib/repo/types.ts +21 -0
  33. package/src/lib/scheduler.ts +3 -0
  34. package/src/lib/schema.ts +10 -0
  35. package/src/lib/sync/catalog.ts +2 -0
  36. package/src/lib/sync/store.ts +4 -0
  37. package/src/middleware.ts +13 -2
  38. package/src/pages/admin/calendar/events/index.astro +4 -9
  39. package/src/pages/admin/calendar/resources/[id].astro +13 -1
  40. package/src/pages/api/calendar/docs.ts +2 -0
  41. package/src/pages/api/calendar/events/[id].ts +5 -5
  42. package/src/pages/api/calendar/events/index.ts +7 -4
  43. package/src/pages/api/v1/rooms/free.ts +7 -0
  44. package/src/pages/api/webhooks/docs.ts +5 -0
  45. package/src/server/index.ts +37 -0
  46. package/src/styles/admin.css +3 -0
  47. package/src/styles/editor.css +2 -0
@@ -59,6 +59,8 @@ Add endpoints in Team → API & webhooks. Events: event.published, event.updated
59
59
  Each POST has headers X-Acms-Event, X-Acms-Delivery, X-Acms-Timestamp and
60
60
  X-Acms-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>.
61
61
  Body: { "id", "type", "createdAt", "data": { ... } }. Failed deliveries retry after 1, 10 and 60 minutes.
62
+ event.* fire however an event changes: the editor, Schedule, this API, agent tools, scheduled
63
+ publishing, a saved place's new address and environment sync. Restores send site.restored instead.
62
64
 
63
65
  ## MCP / agent tools
64
66
 
@@ -7,7 +7,7 @@ import { CALENDAR } from '../../../../lib/calendar/config';
7
7
  import { eventIsPublic } from '../../../../lib/calendar/events';
8
8
  import { eventToJson } from '../../../../lib/calendar/api';
9
9
  import { runAgentTool } from '../../../../lib/agent';
10
- import { eventDeleted } from '../../../../lib/calendar/hooks';
10
+ import { flushEventChanges } from '../../../../lib/calendar/hooks';
11
11
  import { mergeEventDraft } from '../../../../lib/drafts';
12
12
  import { waitUntilFrom } from '../../../../lib/webhooks';
13
13
  import { fail, preflight, withCors } from '../../../../lib/calendar/http';
@@ -27,12 +27,12 @@ export const GET: APIRoute = async ({ request, locals, url, params }) => {
27
27
  try {
28
28
  if (!CALENDAR.enabled) throw new ApiError(404, 'The calendar module is disabled.');
29
29
  const repo = await getRepo(locals);
30
- const [e, settings, calendars, who] = await Promise.all([find(repo, params.id!), repo.getSettings(), repo.listCalendars(), authenticate(request, repo)]);
30
+ const [e, settings, calendars, who, rooms] = await Promise.all([find(repo, params.id!), repo.getSettings(), repo.listCalendars(), authenticate(request, repo), repo.listResources()]);
31
31
  const map = new Map(calendars.map((c) => [c.id, c]));
32
32
  const full = who ? hasScope(who, 'events:read') : false;
33
33
  if (!e || (!full && !eventIsPublic(e, map))) throw new ApiError(404, 'Event not found.');
34
34
  const view = full ? mergeEventDraft(e) : e;
35
- return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone, { includePrivate: full }), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
35
+ return withCors(json({ event: { ...eventToJson(view, siteOrigin(settings, url), map, settings.timezone, { includePrivate: full, resources: new Map(rooms.map((r) => [r.id, r])) }), ...(full ? { hasPendingDraft: Boolean(e.draft), bodyHtml: view.bodyHtml } : {}) } }));
36
36
  } catch (err) { return fail(err); }
37
37
  };
38
38
 
@@ -50,14 +50,14 @@ export const PATCH: APIRoute = async ({ request, locals, url, params }) => {
50
50
  } catch (err) { return fail(err); }
51
51
  };
52
52
 
53
- export const DELETE: APIRoute = async ({ request, locals, params }) => {
53
+ export const DELETE: APIRoute = async ({ request, locals, url, params }) => {
54
54
  try {
55
55
  const repo = await getRepo(locals);
56
56
  await requireScope(request, repo, 'events:write');
57
57
  const e = await find(repo, params.id!);
58
58
  if (!e) throw new ApiError(404, 'Event not found.');
59
59
  await repo.deleteEvent(e.id);
60
- await eventDeleted(repo, e, { waitUntil: waitUntilFrom(locals) });
60
+ await flushEventChanges(repo, { origin: url.origin, waitUntil: waitUntilFrom(locals) });
61
61
  return withCors(json({ deleted: e.id }));
62
62
  } catch (err) { return fail(err); }
63
63
  };
@@ -5,7 +5,7 @@ import { authenticate, hasScope } from '../../../../lib/api-keys';
5
5
  import { siteOrigin } from '../../../../lib/seo';
6
6
  import { CALENDAR } from '../../../../lib/calendar/config';
7
7
  import { eventIsListed, eventIsPublic, expandEvents, parseWall } from '../../../../lib/calendar/events';
8
- import { eventToJson } from '../../../../lib/calendar/api';
8
+ import { eventToJson, roomsToJson } from '../../../../lib/calendar/api';
9
9
  import { zonedToUtc } from '../../../../lib/calendar/ics';
10
10
  import { isoWithOffset } from '../../../../lib/calendar/seo';
11
11
  import { eventTimeZone } from '../../../../lib/calendar/events';
@@ -43,9 +43,10 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
43
43
  const principal = await authenticate(request, repo);
44
44
  if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
45
45
  const full = principal ? hasScope(principal, 'events:read') : false;
46
- const [settings, calendars, published] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
46
+ const [settings, calendars, published, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), repo.listResources()]);
47
47
  const tz = settings.timezone || 'UTC';
48
48
  const map = new Map(calendars.map((c) => [c.id, c]));
49
+ const resources = new Map(rooms.map((r) => [r.id, r]));
49
50
  const origin = siteOrigin(settings, url);
50
51
 
51
52
  const slug = url.searchParams.get('calendar');
@@ -64,7 +65,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
64
65
  }
65
66
 
66
67
  if (url.searchParams.get('expand') === '0') {
67
- return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
68
+ return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full, resources })) }));
68
69
  }
69
70
  const day = (param: string, fallback: number) => {
70
71
  const w = parseWall(url.searchParams.get(param) ?? '');
@@ -78,7 +79,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
78
79
  const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
79
80
  const cache = new Map<string, ReturnType<typeof eventToJson>>();
80
81
  const toJson = (id: string) => {
81
- if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full }));
82
+ if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full, resources }));
82
83
  return cache.get(id)!;
83
84
  };
84
85
  return withCors(json({
@@ -93,6 +94,8 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
93
94
  end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
94
95
  allDay: o.allDay,
95
96
  cancelled: o.cancelled,
97
+ // that date's rooms
98
+ rooms: roomsToJson(o.roomIds, resources, full),
96
99
  event: toJson(o.event.id),
97
100
  };
98
101
  }),
@@ -0,0 +1,7 @@
1
+ import { roomFree } from '../../../../lib/mobile/calendar';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // never cached: a room taken a minute ago must not read as free
6
+ export const GET = privateRoute(roomFree);
7
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -24,6 +24,11 @@ Restoring design history only stages a draft; publishing it emits this event.
24
24
  media.created/updated/deleted: a media record is added, its alt text changes, or it is removed.
25
25
  site.restored: a content backup is restored; resync instead of expecting individual record events.
26
26
  announcement.published: an announcement goes up (acms({ announcements }); scheduled ones when their time comes).
27
+ event.published/updated/unpublished/deleted: a calendar event goes public, changes, stops being
28
+ public or is deleted, by any path: the editor, Schedule, list actions, agent tools, the REST API,
29
+ scheduled publishing, a saved place's new address and environment sync. event.updated means
30
+ something public changed (times, place, rooms, cancelled dates, text, translations); hosts,
31
+ members' requests and staged edits do not emit it. A restore sends site.restored instead.
27
32
  Existing event.* and booking.* payloads are described at /api/calendar/docs.
28
33
 
29
34
  ## Payloads
@@ -36,6 +36,7 @@ export interface ServerUser {
36
36
 
37
37
  export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
+ export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
39
40
 
40
41
  export interface ListEventsOptions {
41
42
  /** only published events (default: every event except archived ones) */
@@ -171,6 +172,42 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
171
172
  return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
172
173
  }
173
174
 
175
+ // ---- rooms -----------------------------------------------------------------
176
+ // Events hold rooms (CalEvent.roomHold) while they happen. These find where
177
+ // two things want the same room. They don't check permissions, and their
178
+ // results carry names: show them to staff only.
179
+
180
+ /**
181
+ * Other events holding the same rooms as this one at overlapping times, setup
182
+ * and cleanup included, soonest first. Pass an event id, or a CalEvent with
183
+ * unsaved values (it's checked as if published). Looks `horizonDays` ahead
184
+ * (default 366) from `now`. Clashes are warnings: both events keep the room.
185
+ * Throws an Error with `status` 404 for an unknown event id.
186
+ */
187
+ export async function eventRoomClashes(repo: Repo, event: string | CalEvent, opts: { horizonDays?: number; now?: Date | number } = {}): Promise<import('../lib/booking/holds').EventClash[]> {
188
+ const r = internal(repo);
189
+ const e = typeof event === 'string' ? await r.getEvent(event) : event;
190
+ if (!e) throw Object.assign(new Error('Event not found.'), { status: 404 });
191
+ const { eventClashes } = await import('../lib/booking/holds');
192
+ return (await eventClashes(r, await r.getSettings(), e, { horizonDays: opts.horizonDays, now: opts.now === undefined ? undefined : +opts.now })).clashes;
193
+ }
194
+
195
+ /**
196
+ * Everything that takes these rooms (Resource ids) in [from, to), soonest
197
+ * first: events holding them (setup and cleanup included), pending and
198
+ * confirmed bookings, staff blocks and the rooms' busy calendars. `ignore`
199
+ * leaves out one event, or one date of it by occurrence key, such as the date
200
+ * you're about to move. An empty list means they're all free.
201
+ */
202
+ export async function roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<import('../lib/booking/holds').BusySlot[]> {
203
+ const from = +q.from, to = +q.to;
204
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new RangeError('roomBusy needs a "to" after "from".');
205
+ if (to - from > MAX_WINDOW) throw new RangeError('roomBusy windows are at most 400 days.');
206
+ const r = internal(repo);
207
+ const { roomBusy: busy } = await import('../lib/booking/holds');
208
+ return busy(r, await r.getSettings(), { resourceIds: q.roomIds, from, to, ignore: q.ignore });
209
+ }
210
+
174
211
  // ---- members' event requests ------------------------------------------
175
212
  // The rules behind the built-in "My events" pages (acms({ members })), for a
176
213
  // site that builds its own. Each takes the person acting (currentUser()) and
@@ -1392,6 +1392,9 @@ em.manual { color: var(--amber); }
1392
1392
  .res-grid > div > .card + .card, .res-side > .card + .card { margin-top: 16px; }
1393
1393
  .res-side { display: flex; flex-direction: column; gap: 0; }
1394
1394
  .res-side > .btn { margin-top: 14px; align-self: flex-start; }
1395
+ .res-color { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
1396
+ .res-color input[type='color'] { width: 44px; height: 30px; padding: 2px; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--panel); cursor: pointer; }
1397
+ .res-color .check-row { margin: 0; }
1395
1398
  .card-pad { padding: 16px 18px; }
1396
1399
  .card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 12px; }
1397
1400
  .card-head b { font-size: 14.5px; }
@@ -1039,6 +1039,8 @@
1039
1039
  .rh-conflict > b { display: block; margin-bottom: 4px; }
1040
1040
  .rh-bk { display: flex; flex-direction: column; padding: 5px 0; border-top: 1px solid color-mix(in srgb, var(--amber) 25%, transparent); }
1041
1041
  .rh-bk small, .rh-more { color: var(--text-2); font-size: 11.5px; }
1042
+ .rh-clash .rh-bk a { color: inherit; font-weight: 600; }
1043
+ .rh-clash > .rh-more:last-child { display: block; margin-top: 6px; }
1042
1044
  .rh-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
1043
1045
  .rh-acts.end { justify-content: flex-end; }
1044
1046
  .rh-cancel textarea, .rh-msg { width: 100%; margin-top: 8px; padding: 7px 9px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; font-size: 12.5px; resize: vertical; }