@acms-subir/core 0.3.6 → 0.3.7

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 (106) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/integration.mjs +38 -2
  3. package/package.json +1 -1
  4. package/src/actions/index.ts +146 -2
  5. package/src/components/EventPage.astro +25 -5
  6. package/src/components/calendar/BookingsHead.astro +4 -3
  7. package/src/components/calendar/RoomHolds.tsx +13 -8
  8. package/src/components/editor/EventPanel.tsx +9 -383
  9. package/src/components/editor/LivePane.tsx +16 -3
  10. package/src/components/editor/PostEditor.tsx +59 -46
  11. package/src/components/editor/PreviewOverlay.tsx +7 -1
  12. package/src/components/editor/event/EventSheet.tsx +136 -0
  13. package/src/components/editor/event/LocationField.tsx +266 -0
  14. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  15. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  16. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  17. package/src/components/editor/event/Section.tsx +27 -0
  18. package/src/components/editor/event/WhenCard.tsx +217 -0
  19. package/src/components/editor/event/dates.ts +28 -0
  20. package/src/components/editor/event/registration.ts +39 -0
  21. package/src/components/editor/extensions/form.tsx +60 -0
  22. package/src/components/editor/extensions/slash.tsx +5 -0
  23. package/src/components/editor/frameNav.ts +20 -0
  24. package/src/components/forms/Canvas.tsx +149 -0
  25. package/src/components/forms/FormBuildPage.tsx +155 -0
  26. package/src/components/forms/FormBuilder.tsx +283 -0
  27. package/src/components/forms/FormHeader.astro +31 -0
  28. package/src/components/forms/InsertMenu.tsx +77 -0
  29. package/src/components/forms/Inspector.tsx +232 -0
  30. package/src/components/forms/Outline.tsx +93 -0
  31. package/src/components/forms/ResponsesTable.tsx +123 -0
  32. package/src/components/forms/TypeIcon.tsx +24 -0
  33. package/src/components/forms/dnd.ts +3 -0
  34. package/src/components/forms/meta.ts +84 -0
  35. package/src/components/forms/useFormHistory.ts +41 -0
  36. package/src/layouts/Admin.astro +3 -2
  37. package/src/lib/backup/content.ts +10 -5
  38. package/src/lib/booking/service.ts +6 -2
  39. package/src/lib/calendar/agent-schema.ts +7 -1
  40. package/src/lib/calendar/agent.ts +28 -3
  41. package/src/lib/calendar/api.ts +2 -0
  42. package/src/lib/calendar/ics-write.ts +2 -0
  43. package/src/lib/calendar/ics.ts +4 -0
  44. package/src/lib/calendar/import.ts +3 -3
  45. package/src/lib/calendar/schedule.ts +6 -2
  46. package/src/lib/calendar/seo.ts +4 -1
  47. package/src/lib/content-service.ts +30 -4
  48. package/src/lib/drafts.ts +9 -1
  49. package/src/lib/dynamicBlocks.ts +14 -3
  50. package/src/lib/editor/documents.ts +12 -3
  51. package/src/lib/editor/schemas.ts +4 -3
  52. package/src/lib/forms/events.ts +40 -0
  53. package/src/lib/forms/export.ts +62 -0
  54. package/src/lib/forms/fields.ts +50 -0
  55. package/src/lib/forms/http.ts +29 -0
  56. package/src/lib/forms/render.ts +11 -0
  57. package/src/lib/forms/schema.ts +56 -0
  58. package/src/lib/forms/service.ts +38 -0
  59. package/src/lib/forms/store.ts +78 -0
  60. package/src/lib/forms/validate.ts +71 -0
  61. package/src/lib/maps/config.ts +64 -0
  62. package/src/lib/maps/migration.ts +12 -0
  63. package/src/lib/maps/parse.ts +180 -0
  64. package/src/lib/maps/places.ts +109 -0
  65. package/src/lib/maps/providers/geoapify.ts +36 -0
  66. package/src/lib/maps/providers/google.ts +54 -0
  67. package/src/lib/maps/providers/http.ts +96 -0
  68. package/src/lib/maps/providers/index.ts +37 -0
  69. package/src/lib/maps/providers/locationiq.ts +35 -0
  70. package/src/lib/maps/providers/mapbox.ts +38 -0
  71. package/src/lib/maps/providers/osm.ts +59 -0
  72. package/src/lib/maps/providers/pelias.ts +51 -0
  73. package/src/lib/maps/service.ts +281 -0
  74. package/src/lib/maps/types.ts +35 -0
  75. package/src/lib/mobile/bookings.ts +1 -1
  76. package/src/lib/mobile/schemas.ts +2 -2
  77. package/src/lib/repo/migrations.ts +8 -0
  78. package/src/lib/repo/sql.ts +99 -10
  79. package/src/lib/repo/types.ts +21 -1
  80. package/src/lib/schema.ts +38 -0
  81. package/src/lib/umami.ts +8 -1
  82. package/src/pages/admin/calendar/bookings.astro +12 -7
  83. package/src/pages/admin/calendar/events/[id].astro +5 -2
  84. package/src/pages/admin/calendar/events/index.astro +3 -0
  85. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  86. package/src/pages/admin/calendar/settings.astro +140 -3
  87. package/src/pages/admin/forms/[id]/share.astro +121 -0
  88. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  89. package/src/pages/admin/forms/[id].astro +27 -0
  90. package/src/pages/admin/forms/index.astro +198 -0
  91. package/src/pages/admin/integrations.astro +57 -0
  92. package/src/pages/api/agent/index.ts +1 -1
  93. package/src/pages/api/calendar/docs.ts +2 -0
  94. package/src/pages/api/calendar/events/[id].ts +1 -1
  95. package/src/pages/api/calendar/events/index.ts +1 -1
  96. package/src/pages/api/mcp.ts +1 -1
  97. package/src/pages/book/manage/[token].astro +4 -4
  98. package/src/pages/events/[slug].astro +24 -10
  99. package/src/pages/forms/[id].astro +27 -0
  100. package/src/styles/admin.css +2 -2
  101. package/src/styles/editor.css +275 -34
  102. package/src/styles/form-block.css +38 -0
  103. package/src/styles/forms-admin.css +184 -0
  104. package/src/styles/forms.css +281 -0
  105. package/src/styles/site.css +43 -0
  106. package/src/virtual.d.ts +2 -0
@@ -0,0 +1,281 @@
1
+ // Server-side maps work behind the actions: address suggestions (with a small
2
+ // bounded cache), expanding short map links safely, and the static map images
3
+ // stored in the media library. Nothing here runs without a configured
4
+ // provider except link parsing.
5
+ import type { ContentRepository } from '../repo/types';
6
+ import { EventLocation, slugify } from '../schema';
7
+ import { getMediaStore } from '../media/store';
8
+ import type { GeoResult, MapsProviderId, ParsedLocation } from './types';
9
+ import { activeProvider } from './config';
10
+ import { isShortMapLink, parseMapLink, validLat, validLng } from './parse';
11
+ import { ATTRIBUTION, PROVIDERS } from './providers';
12
+
13
+ export interface MapsContext {
14
+ locals?: unknown;
15
+ // the site origin, for the provider User-Agent
16
+ origin?: string;
17
+ }
18
+
19
+ /* ---------------- suggestions ---------------- */
20
+
21
+ const SEARCH_TTL = 10 * 60_000;
22
+ export const SEARCH_CACHE_MAX = 100;
23
+ const searchCache = new Map<string, { at: number; results: GeoResult[] }>();
24
+
25
+ /**
26
+ * Address suggestions for the event editor. [] when maps aren't set up or the
27
+ * query is too short; provider failures throw (the action reports them).
28
+ * Results are cached briefly per provider/query/area — typing the same
29
+ * prefix twice costs one request — and the cache is size-bounded so a
30
+ * long-lived Node server can't grow it forever.
31
+ */
32
+ export async function searchPlaces(q: string, near: { lat: number; lng: number } | undefined, ctx: MapsContext = {}): Promise<GeoResult[]> {
33
+ const query = q.replace(/\s+/g, ' ').trim().slice(0, 200);
34
+ if (query.length < 3) return [];
35
+ const provider = activeProvider(ctx.locals, ctx.origin);
36
+ if (!provider) return [];
37
+ const area = near && validLat(near.lat) && validLng(near.lng) ? near : undefined;
38
+ // ~10 km cells: nearby pins share cache entries
39
+ const key = `${provider.id}|${query.toLowerCase()}|${area ? `${area.lat.toFixed(1)},${area.lng.toFixed(1)}` : ''}`;
40
+ const now = Date.now();
41
+ const hit = searchCache.get(key);
42
+ if (hit && now - hit.at < SEARCH_TTL) {
43
+ // refresh recency so hot queries survive eviction
44
+ searchCache.delete(key);
45
+ searchCache.set(key, hit);
46
+ return hit.results;
47
+ }
48
+ const results = await provider.search(query, { near: area, limit: 5 });
49
+ for (const [k, entry] of searchCache) if (now - entry.at >= SEARCH_TTL) searchCache.delete(k);
50
+ searchCache.delete(key);
51
+ searchCache.set(key, { at: now, results });
52
+ while (searchCache.size > SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value!);
53
+ return results;
54
+ }
55
+
56
+ /* ---------------- short links ---------------- */
57
+
58
+ // hosts a short link may be expanded through. consent.google.com is
59
+ // deliberately absent: its continue= parameter is read, never fetched.
60
+ const RESOLVE_HOSTS = new Set(['maps.app.goo.gl', 'goo.gl', 'google.com', 'www.google.com', 'maps.google.com']);
61
+ const RESOLVE_HOPS = 5;
62
+ const RESOLVE_TIMEOUT_MS = 5000;
63
+
64
+ const pinned = (p: ParsedLocation | null) => p !== null && (p.lat !== null || p.name !== '' || p.address !== '' || p.kind === 'meeting');
65
+
66
+ /**
67
+ * Expand (if short) and parse a pasted map link. Redirects are walked by hand
68
+ * — at most 5 hops, https only, Google map hosts only, 5 s in total — so a
69
+ * pasted link can't make the server fetch anything else (no SSRF).
70
+ */
71
+ export async function expandMapLink(input: string): Promise<ParsedLocation | null> {
72
+ const raw = input.trim();
73
+ if (!isShortMapLink(raw)) return parseMapLink(raw);
74
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
75
+ let current = raw;
76
+ for (let hop = 0; hop < RESOLVE_HOPS; hop++) {
77
+ let u: URL;
78
+ try { u = new URL(current); } catch { return null; }
79
+ if (u.hostname === 'consent.google.com') {
80
+ const next = u.searchParams.get('continue');
81
+ if (!next) return null;
82
+ current = next;
83
+ continue;
84
+ }
85
+ if (!isShortMapLink(current)) {
86
+ const parsed = parseMapLink(current);
87
+ if (pinned(parsed)) return parsed;
88
+ }
89
+ if (u.protocol !== 'https:' || !RESOLVE_HOSTS.has(u.hostname.toLowerCase()) || u.username || u.password || (u.port && u.port !== '443')) return null;
90
+ let res: Response;
91
+ try {
92
+ res = await fetch(u.href, { redirect: 'manual', signal, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; acms-maps/1.0)', Accept: 'text/html' } });
93
+ } catch {
94
+ return null;
95
+ }
96
+ const loc = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
97
+ // don't keep the body around; only the redirect matters
98
+ try { await res.body?.cancel(); } catch { /* already consumed */ }
99
+ if (!loc) return null;
100
+ try { current = new URL(loc, u.href).href; } catch { return null; }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /** expand + parse; a pin with no address gets one from the provider when set up */
106
+ export async function resolveMapLink(input: string, ctx: MapsContext = {}): Promise<ParsedLocation | null> {
107
+ const parsed = await expandMapLink(input);
108
+ if (!parsed) return null;
109
+ // keep the short link the person pasted: it's tidier and opens the app
110
+ if (isShortMapLink(input.trim()) && parsed.kind === 'map') parsed.url = input.trim();
111
+ if (parsed.kind === 'map' && parsed.lat !== null && parsed.lng !== null && !parsed.address) {
112
+ const provider = activeProvider(ctx.locals, ctx.origin);
113
+ if (provider) {
114
+ try {
115
+ const hit = await provider.reverse(parsed.lat, parsed.lng);
116
+ if (hit) {
117
+ parsed.address = hit.address || (hit.name && hit.name !== parsed.name ? hit.name : '');
118
+ if (!parsed.name && hit.name && hit.address) parsed.name = hit.name;
119
+ }
120
+ } catch (err) {
121
+ console.warn('[acms maps] reverse lookup failed:', err instanceof Error ? err.message : err);
122
+ }
123
+ }
124
+ }
125
+ return parsed;
126
+ }
127
+
128
+ /* ---------------- static map images ---------------- */
129
+
130
+ export const MAP_WIDTH = 1200;
131
+ export const MAP_HEIGHT = 500;
132
+
133
+ const fix = (n: number) => n.toFixed(5);
134
+ export const samePin = (a: { lat: number | null; lng: number | null }, b: { lat: number | null; lng: number | null }) =>
135
+ a.lat !== null && a.lng !== null && b.lat !== null && b.lng !== null && fix(a.lat) === fix(b.lat) && fix(a.lng) === fix(b.lng);
136
+
137
+ // Map images live in the media library under keys that record what they show:
138
+ // <uuid>/map-<provider>-<lat>_<lng>-<light|dark>.<ext>
139
+ // so a later save can tell whether they still match the pin (and which
140
+ // provider to credit) without any extra columns.
141
+ const IMAGE_RE = /^\/media\/[0-9a-f-]+\/map-([a-z]+)-(-?\d+\.\d+)_(-?\d+\.\d+)-(light|dark)\.(png|jpe?g|webp)$/;
142
+
143
+ export function mapImageInfo(url: string): { provider: MapsProviderId; lat: number; lng: number; scheme: 'light' | 'dark' } | null {
144
+ const m = url.match(IMAGE_RE);
145
+ if (!m || !(m[1] in PROVIDERS)) return null;
146
+ return { provider: m[1] as MapsProviderId, lat: Number(m[2]), lng: Number(m[3]), scheme: m[4] as 'light' | 'dark' };
147
+ }
148
+
149
+ /** the credit line for a stored map image ('' when it isn't one of ours) */
150
+ export function mapAttribution(url: string): string {
151
+ const info = mapImageInfo(url);
152
+ return info ? ATTRIBUTION[info.provider] : '';
153
+ }
154
+
155
+ const EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
156
+
157
+ async function storeMapImage(repo: ContentRepository, ctx: MapsContext, provider: MapsProviderId, lat: number, lng: number, scheme: 'light' | 'dark',
158
+ img: { body: ArrayBuffer; contentType: string }, label: string): Promise<string> {
159
+ const id = crypto.randomUUID();
160
+ const ext = EXT[img.contentType] ?? 'png';
161
+ const key = `${id}/map-${provider}-${fix(lat)}_${fix(lng)}-${scheme}.${ext}`;
162
+ await getMediaStore(ctx.locals).put(key, img.body, img.contentType);
163
+ await repo.createMedia({
164
+ id, key, filename: `map-${slugify(label) || 'pin'}${scheme === 'dark' ? '-dark' : ''}.${ext}`, mime: img.contentType,
165
+ width: MAP_WIDTH, height: MAP_HEIGHT, size: img.body.byteLength, alt: `Map of ${label || `${fix(lat)}, ${fix(lng)}`}`, variants: [],
166
+ });
167
+ return `/media/${key}`;
168
+ }
169
+
170
+ // Pins whose images just failed (quota, bad key…): editor autosaves resend
171
+ // the location, and each one must not wait on the provider again. Bounded.
172
+ const FAILED_TTL = 10 * 60_000;
173
+ const FAILED_MAX = 50;
174
+ const failedPins = new Map<string, number>();
175
+ const recentlyFailed = (key: string, now: number) => {
176
+ const at = failedPins.get(key);
177
+ if (at !== undefined && now - at < FAILED_TTL) return true;
178
+ failedPins.delete(key);
179
+ return false;
180
+ };
181
+ const markFailed = (key: string, now: number) => {
182
+ failedPins.delete(key);
183
+ failedPins.set(key, now);
184
+ while (failedPins.size > FAILED_MAX) failedPins.delete(failedPins.keys().next().value!);
185
+ };
186
+
187
+ type Pinned = Pick<EventLocation, 'name' | 'address' | 'lat' | 'lng' | 'showMap' | 'placeId' | 'mapImage' | 'mapImageDark'>;
188
+
189
+ /**
190
+ * Bring a location's cached map images in line with its pin:
191
+ * - no pin → no images
192
+ * - showMap off → nothing fetched; images for another pin are dropped
193
+ * - images already made for this pin → kept
194
+ * - the saved place it came from has images for this pin → reused
195
+ * - otherwise light + dark images are fetched once and stored as media
196
+ * Provider failures never throw: the location comes back without images.
197
+ */
198
+ export async function ensureMapImages<T extends Pinned>(repo: ContentRepository, loc: T, ctx: MapsContext = {}): Promise<T> {
199
+ const none = { ...loc, mapImage: '', mapImageDark: '' };
200
+ if (loc.lat === null || loc.lng === null) return none;
201
+ const current = mapImageInfo(loc.mapImage);
202
+ if (current && samePin(current, loc)) {
203
+ const dark = mapImageInfo(loc.mapImageDark);
204
+ return { ...loc, mapImageDark: dark && samePin(dark, loc) ? loc.mapImageDark : '' };
205
+ }
206
+ if (loc.placeId) {
207
+ const place = await repo.getPlace(loc.placeId).catch(() => null);
208
+ const info = place ? mapImageInfo(place.mapImage) : null;
209
+ if (place && info && samePin(info, loc)) return { ...loc, mapImage: place.mapImage, mapImageDark: place.mapImageDark };
210
+ }
211
+ if (!loc.showMap) return none;
212
+ const provider = activeProvider(ctx.locals, ctx.origin);
213
+ if (!provider?.staticMap) return none;
214
+ const label = loc.name || loc.address;
215
+ const failKey = `${provider.id}|${fix(loc.lat)},${fix(loc.lng)}`;
216
+ const now = Date.now();
217
+ if (recentlyFailed(failKey, now)) return none;
218
+ try {
219
+ const opts = { width: MAP_WIDTH, height: MAP_HEIGHT };
220
+ const light = await provider.staticMap(loc.lat, loc.lng, { ...opts, dark: false });
221
+ if (!light) return none;
222
+ const id = provider.id as MapsProviderId;
223
+ const mapImage = await storeMapImage(repo, ctx, id, loc.lat, loc.lng, 'light', light, label);
224
+ let mapImageDark = '';
225
+ try {
226
+ const dark = await provider.staticMap(loc.lat, loc.lng, { ...opts, dark: true });
227
+ if (dark) mapImageDark = await storeMapImage(repo, ctx, id, loc.lat, loc.lng, 'dark', dark, label);
228
+ } catch (err) {
229
+ console.warn('[acms maps] dark map image failed:', err instanceof Error ? err.message : err);
230
+ }
231
+ return { ...loc, mapImage, mapImageDark };
232
+ } catch (err) {
233
+ console.warn('[acms maps] map image failed:', err instanceof Error ? err.message : err);
234
+ markFailed(failKey, now);
235
+ return none;
236
+ }
237
+ }
238
+
239
+ /* ---------------- event location input ---------------- */
240
+
241
+ const MEDIA_MAP = /^\/media\/[0-9a-f-]+\/map-/;
242
+
243
+ /**
244
+ * Fill a saved location from what the client sent. Clients that predate maps
245
+ * send only name/address/url: unchanged text keeps the pin, place link and
246
+ * images; changed text drops them (the old pin would point elsewhere).
247
+ * Image fields are only accepted when they are our own map images.
248
+ */
249
+ export function mergeLocationInput(prev: EventLocation, input: Partial<EventLocation>): EventLocation {
250
+ const text = {
251
+ name: input.name ?? prev.name,
252
+ address: input.address ?? prev.address,
253
+ url: input.url ?? prev.url,
254
+ };
255
+ const sameText = text.name === prev.name && text.address === prev.address && text.url === prev.url;
256
+ const pinGiven = input.lat !== undefined || input.lng !== undefined;
257
+ let lat = pinGiven ? input.lat ?? null : sameText ? prev.lat : null;
258
+ let lng = pinGiven ? input.lng ?? null : sameText ? prev.lng : null;
259
+ if (!validLat(lat) || !validLng(lng)) { lat = null; lng = null; }
260
+ const img = (v: string | undefined, fallback: string) => (v === undefined ? fallback : v === '' || MEDIA_MAP.test(v) ? v : fallback);
261
+ return EventLocation.parse({
262
+ ...text,
263
+ lat, lng,
264
+ placeId: input.placeId !== undefined ? input.placeId : sameText ? prev.placeId : '',
265
+ showMap: input.showMap ?? prev.showMap,
266
+ mapImage: img(input.mapImage, prev.mapImage),
267
+ mapImageDark: img(input.mapImageDark, prev.mapImageDark),
268
+ });
269
+ }
270
+
271
+ /** the "directions" link for a pinned location: its map link, else Google Maps search on the pin */
272
+ export function directionsUrl(loc: Pick<EventLocation, 'url' | 'lat' | 'lng' | 'name' | 'address'>): string {
273
+ const p = loc.url ? parseMapLink(loc.url) : null;
274
+ if (loc.url && (isShortMapLink(loc.url) || p?.kind === 'map')) return loc.url;
275
+ if (loc.lat !== null && loc.lng !== null) return `https://www.google.com/maps/search/?api=1&query=${loc.lat},${loc.lng}`;
276
+ const q = [loc.name, loc.address].filter(Boolean).join(', ');
277
+ return q ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}` : '';
278
+ }
279
+
280
+ /** test hook: how many suggestion lists are cached */
281
+ export const searchCacheSize = () => searchCache.size;
@@ -0,0 +1,35 @@
1
+ // Maps support is bring-your-own: acms runs no geocoder or tile server. A site
2
+ // configures a provider (acms({ maps: { provider } }) + MAPS_API_KEY, or
3
+ // MAPS_URL for a self-hosted Photon/Nominatim/Pelias) and gets address
4
+ // suggestions and static map images. With nothing configured, link parsing
5
+ // and saved places still work.
6
+
7
+ export type MapsProviderId = 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias';
8
+
9
+ // one address suggestion / resolved location
10
+ export interface GeoResult {
11
+ name: string;
12
+ address: string;
13
+ lat: number | null;
14
+ lng: number | null;
15
+ // a link that opens the place in a map app ('' = build one from lat/lng)
16
+ url: string;
17
+ }
18
+
19
+ // what a pasted map link / embed tells us (no network needed except for short links)
20
+ export interface ParsedLocation extends GeoResult {
21
+ // 'meeting' = Zoom/Meet/Teams…: online, no map
22
+ kind: 'map' | 'meeting';
23
+ }
24
+
25
+ // what the editor needs to know about the site's setup
26
+ export interface MapsStatus {
27
+ // a provider is configured
28
+ enabled: boolean;
29
+ provider: MapsProviderId | '';
30
+ // display name ("Geoapify")
31
+ label: string;
32
+ // the provider can search (autofill) / draw static maps
33
+ search: boolean;
34
+ staticMaps: boolean;
35
+ }
@@ -18,7 +18,7 @@ export const bookingInput = z.union([
18
18
  z.object({ ...common, resource: z.string().min(1).max(200), link: z.string().min(1).max(200),
19
19
  minutes: z.number().int().min(1).max(44640).optional(), answers: z.record(z.string().max(2000)).default({}),
20
20
  }).strict(),
21
- z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1) }).strict(),
21
+ z.object({ ...common, event: z.string().min(1).max(200), seats: z.number().int().min(1).max(1000).default(1), answers: z.record(z.string().max(2000)).default({}), formVersion: z.string().optional() }).strict(),
22
22
  ]);
23
23
  function canonical(value: unknown): string {
24
24
  if (value && typeof value === 'object' && !Array.isArray(value)) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k,v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
@@ -29,7 +29,7 @@ export const Page = ContentSummary.extend({
29
29
  export const Event = CalEvent.pick({
30
30
  id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
31
31
  allDay: true, timezone: true, rrule: true, exdates: true, location: true, cta: true, tags: true, fields: true,
32
- }).extend({ url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
32
+ }).extend({ rsvp: CalEvent.shape.rsvp.optional(), url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
33
33
  calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
34
34
  });
35
35
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
@@ -46,7 +46,7 @@ export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'da
46
46
  days: z.array(z.object({ date: str, slots: z.array(z.object({ start: date, end: date, minutes: z.number() })) })),
47
47
  });
48
48
  export const BookingResult = z.object({ manageToken: str, booking: Booking.pick({
49
- id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true,
49
+ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true,
50
50
  }).extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date }) });
51
51
  export const Device = z.object({
52
52
  token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, registeredAt: date, lastSeenAt: date,
@@ -5,6 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
+ import { PLACES_MIGRATION } from '../maps/migration';
8
9
  // Versioned schema migrations shared by the two SQLite-dialect adapters
9
10
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
10
11
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -518,6 +519,13 @@ export const MIGRATIONS: Migration[] = [
518
519
  { version: 42, statements: [
519
520
  `ALTER TABLE settings ADD COLUMN site_logo_dark TEXT NOT NULL DEFAULT ''`,
520
521
  ] },
522
+ PLACES_MIGRATION,
523
+ { version: 44, statements: [
524
+ `ALTER TABLE bookings ADD COLUMN form_response TEXT`,
525
+ `CREATE TABLE forms (id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, purpose TEXT NOT NULL, status TEXT NOT NULL, definition TEXT NOT NULL, draft TEXT, revision INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
526
+ `CREATE TABLE form_submissions (id TEXT PRIMARY KEY, form_id TEXT NOT NULL, response TEXT NOT NULL, created_at INTEGER NOT NULL)`,
527
+ `CREATE INDEX form_submissions_form_created ON form_submissions(form_id, created_at)`,
528
+ ] },
521
529
  ];
522
530
 
523
531
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1,3 +1,4 @@
1
+ import { FormStore, formInsert, submissionInsert } from '../forms/store';
1
2
  import { LiveStore } from '../live/store';
2
3
  import { PushStore } from '../push/store';
3
4
  import { EditorStore } from '../editor/store';
@@ -5,7 +6,7 @@ import { LiveConfig } from '../live/types';
5
6
  import { ApiError } from '../api';
6
7
  import { EmailStore, bookingJobs } from '../email/store';
7
8
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
8
- import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
9
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
9
10
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
10
11
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
11
12
  import { cleanRrule } from '../calendar/rrule';
@@ -157,7 +158,15 @@ function rowToResource(r: Row): Resource {
157
158
  capacity: r.capacity === null || r.capacity === undefined ? null : Number(r.capacity),
158
159
  amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
159
160
  busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
160
- showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
161
+ showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
162
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
163
+ });
164
+ }
165
+ function rowToPlace(r: Row): Place {
166
+ const n = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number(v));
167
+ return Place.parse({
168
+ id: r.id, name: r.name, address: r.address ?? '', url: r.url ?? '', lat: n(r.lat), lng: n(r.lng), notes: r.notes ?? '',
169
+ isDefault: Boolean(r.is_default), mapImage: r.map_image ?? '', mapImageDark: r.map_image_dark ?? '', order: Number(r.ord ?? 0),
161
170
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
162
171
  });
163
172
  }
@@ -187,6 +196,7 @@ function rowToBooking(r: Row): Booking {
187
196
  id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
188
197
  status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
189
198
  timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
199
+ formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
190
200
  answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
191
201
  notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
192
202
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -248,12 +258,14 @@ export class SqlRepository implements ContentRepository {
248
258
  private driver!: SqlDriver;
249
259
  private ready: Promise<void>;
250
260
  readonly email: EmailStore;
261
+ readonly forms: FormStore;
251
262
  readonly live: LiveStore;
252
263
  readonly push: PushStore;
253
264
  readonly editor: EditorStore;
254
265
 
255
266
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
256
267
  this.ready = this.init(driver);
268
+ this.forms = new FormStore(async () => { await this.ready; return this.driver; });
257
269
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
258
270
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
259
271
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
@@ -424,7 +436,7 @@ export class SqlRepository implements ContentRepository {
424
436
  await this.ready;
425
437
  // One SELECT snapshots bookings and their retry ledger together. No UI
426
438
  // list limit; include tombstones for deleted bookings as well.
427
- const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
439
+ const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
428
440
  const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
429
441
  UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
430
442
  return {
@@ -931,14 +943,88 @@ export class SqlRepository implements ContentRepository {
931
943
  }
932
944
 
933
945
 
946
+ async setEventLocation(id: string, patch: Parameters<ContentRepository['setEventLocation']>[1]) {
947
+ await this.ready;
948
+ const current = await this.getEvent(id);
949
+ if (!current) return;
950
+ const stmts: SqlStatement[] = [];
951
+ if (patch.live) {
952
+ stmts.push({ sql: 'UPDATE events SET location = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(EventLocation.parse(patch.live)), Date.now(), id] });
953
+ if (patch.notify && current.status === 'published') {
954
+ // same fan-out as updateEvent's location change, minus the time math
955
+ stmts.push(
956
+ { sql: `UPDATE bookings SET notification_version=notification_version+1 WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`, args: [id] },
957
+ ...bookingJobs(id, 'updated').map((st) => ({ ...st, sql: st.sql.replace('b.id = ?', `b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')`) })),
958
+ );
959
+ }
960
+ }
961
+ if (patch.draft && current.draft) {
962
+ const draft = { ...current.draft, location: EventLocation.parse(patch.draft) };
963
+ stmts.push({ sql: 'UPDATE events SET draft = ? WHERE id = ?', args: [JSON.stringify(draft), id] });
964
+ }
965
+ if (!stmts.length) return;
966
+ await this.driver.batch(stmts);
967
+ if (patch.live) await this.syncEventIndex(id);
968
+ }
969
+
970
+ // ---- places ----
971
+ private placeInsert(p: Place): SqlStatement {
972
+ return {
973
+ sql: `INSERT INTO places (id, name, address, url, lat, lng, notes, is_default, map_image, map_image_dark, ord, created_at, updated_at)
974
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
975
+ args: [p.id, p.name, p.address, p.url, p.lat, p.lng, p.notes, p.isDefault ? 1 : 0, p.mapImage, p.mapImageDark, p.order, p.createdAt, p.updatedAt],
976
+ };
977
+ }
978
+ async listPlaces() {
979
+ await this.ready;
980
+ return (await this.driver.all('SELECT * FROM places ORDER BY ord ASC, name COLLATE NOCASE ASC')).map(rowToPlace);
981
+ }
982
+ async getPlace(id: string) {
983
+ await this.ready;
984
+ const r = await this.first('SELECT * FROM places WHERE id = ?', [id]);
985
+ return r ? rowToPlace(r) : null;
986
+ }
987
+ async createPlace(data: Parameters<ContentRepository['createPlace']>[0]) {
988
+ await this.ready;
989
+ const now = Date.now();
990
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM places');
991
+ const p = Place.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
992
+ await this.driver.batch([
993
+ ...(p.isDefault ? [{ sql: 'UPDATE places SET is_default = 0 WHERE is_default = 1', args: [] }] : []),
994
+ this.placeInsert(p),
995
+ ]);
996
+ return p;
997
+ }
998
+ async updatePlace(id: string, data: Parameters<ContentRepository['updatePlace']>[1]) {
999
+ await this.ready;
1000
+ const cur = await this.getPlace(id);
1001
+ if (!cur) return;
1002
+ const p = Place.parse({ ...cur, ...data, id, createdAt: cur.createdAt, updatedAt: Date.now() });
1003
+ await this.driver.batch([
1004
+ ...(p.isDefault ? [{ sql: 'UPDATE places SET is_default = 0 WHERE id != ? AND is_default = 1', args: [id] }] : []),
1005
+ {
1006
+ sql: `UPDATE places SET name = ?, address = ?, url = ?, lat = ?, lng = ?, notes = ?, is_default = ?, map_image = ?, map_image_dark = ?,
1007
+ ord = ?, updated_at = ? WHERE id = ?`,
1008
+ args: [p.name, p.address, p.url, p.lat, p.lng, p.notes, p.isDefault ? 1 : 0, p.mapImage, p.mapImageDark, p.order, p.updatedAt, id],
1009
+ },
1010
+ ]);
1011
+ }
1012
+ async deletePlace(id: string) {
1013
+ await this.ready;
1014
+ await this.driver.batch([
1015
+ { sql: 'DELETE FROM places WHERE id = ?', args: [id] },
1016
+ { sql: "UPDATE resources SET place_id = '' WHERE place_id = ?", args: [id] },
1017
+ ]);
1018
+ }
1019
+
934
1020
  // ---- resources + booking links ----
935
1021
  private resourceInsert(r: Resource): SqlStatement {
936
1022
  return {
937
1023
  sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
938
- notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1024
+ notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
939
1025
  args: [r.id, r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
940
1026
  JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
941
- r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt],
1027
+ r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId],
942
1028
  };
943
1029
  }
944
1030
  private linkInsert(l: BookingLink): SqlStatement {
@@ -955,9 +1041,9 @@ export class SqlRepository implements ContentRepository {
955
1041
  private bookingInsert(b: Booking): SqlStatement {
956
1042
  return {
957
1043
  sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
958
- seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1044
+ seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
959
1045
  args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
960
- b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion],
1046
+ b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null],
961
1047
  };
962
1048
  }
963
1049
  async listResources() {
@@ -989,10 +1075,10 @@ export class SqlRepository implements ContentRepository {
989
1075
  const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
990
1076
  await this.driver.run(
991
1077
  `UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
992
- overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, updated_at = ? WHERE id = ?`,
1078
+ overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?, updated_at = ? WHERE id = ?`,
993
1079
  [r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
994
1080
  JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
995
- r.feedToken, r.updatedAt, id]
1081
+ r.feedToken, r.placeId, r.updatedAt, id]
996
1082
  );
997
1083
  }
998
1084
  async reorderResources(ids: string[]) {
@@ -1615,7 +1701,7 @@ export class SqlRepository implements ContentRepository {
1615
1701
  // Suppression and re-enabling share the restore transaction. A failed
1616
1702
  // restore cannot leave notifications disabled or emit phantom deletes.
1617
1703
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1618
- ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records'].map((t) => ({
1704
+ ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
1619
1705
  sql: `DELETE FROM ${t}`,
1620
1706
  })),
1621
1707
  ...(content.calendars ?? []).map((c) => ({
@@ -1635,7 +1721,10 @@ export class SqlRepository implements ContentRepository {
1635
1721
  JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
1636
1722
  ],
1637
1723
  })),
1724
+ ...(content.forms ?? []).map(formInsert),
1725
+ ...(content.formSubmissions ?? []).map(submissionInsert),
1638
1726
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
1727
+ ...(content.places ?? []).map((p) => this.placeInsert(p)),
1639
1728
  ...(content.bookingLinks ?? []).map((l) => this.linkInsert(l)),
1640
1729
  ...(content.bookings ?? []).map((b) => this.bookingInsert(b)),
1641
1730
  ...(content.bookingRequests ?? []).map(r => ({ sql: 'INSERT INTO mobile_booking_requests (key_hash,request_hash,booking_id,created_at) VALUES (?,?,?,?)', args: [r.keyHash,r.requestHash,r.bookingId,r.createdAt] })),
@@ -1,3 +1,5 @@
1
+ import type { FormStore } from '../forms/store';
2
+ import type { SavedForm, FormSubmission } from '../forms/schema';
1
3
  import type { LiveStore } from '../live/store';
2
4
  import type { BookingRequest } from '../backup/content';
3
5
  import type { EmailStore } from '../email/store';
@@ -8,7 +10,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
8
10
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
9
11
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
10
12
 
11
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
13
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation } from '../schema';
12
14
 
13
15
  export interface Stats {
14
16
  publishedPages: number;
@@ -48,6 +50,7 @@ export type EventUpdate = Partial<
48
50
  // this — never a concrete adapter.
49
51
  export interface ContentRepository {
50
52
  readonly email: EmailStore;
53
+ readonly forms: FormStore;
51
54
  readonly live: LiveStore;
52
55
  readonly push: PushStore;
53
56
  readonly editor: EditorStore;
@@ -131,6 +134,19 @@ export interface ContentRepository {
131
134
  applyEventDraft(id: string): Promise<void>;
132
135
  discardEventDraft(id: string): Promise<void>;
133
136
  deleteEvent(id: string): Promise<void>;
137
+ // saved-place sync (lib/maps/places.ts): rewrite just the location of the
138
+ // live row and/or the staged draft, leaving every other field — and the
139
+ // draft itself — alone. notify = RSVP guests of a published event get an
140
+ // "event updated" email (the address changed).
141
+ setEventLocation(id: string, patch: { live?: EventLocation; draft?: EventLocation; notify?: boolean }): Promise<void>;
142
+
143
+ // saved places. Only one can be the default (saving one as default clears
144
+ // the others). Deleting a place unlinks its rooms; events keep their copy.
145
+ listPlaces(): Promise<Place[]>;
146
+ getPlace(id: string): Promise<Place | null>;
147
+ createPlace(data: Partial<Omit<Place, 'id' | 'createdAt' | 'updatedAt' | 'order'>> & { name: string }): Promise<Place>;
148
+ updatePlace(id: string, data: Partial<Omit<Place, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void>;
149
+ deletePlace(id: string): Promise<void>;
134
150
 
135
151
  // resources (rooms, people, vans…) and their booking links. Deleting a
136
152
  // resource deletes its links; its bookings stay as history.
@@ -265,6 +281,10 @@ export interface ContentRepository {
265
281
  bookingLinks?: BookingLink[];
266
282
  bookings?: Booking[];
267
283
  bookingRequests?: BookingRequest[];
284
+ // added with maps support; older backups restore with none
285
+ places?: Place[];
286
+ forms?: SavedForm[];
287
+ formSubmissions?: FormSubmission[];
268
288
  }): Promise<void>;
269
289
 
270
290
  // imports (lib/import/apply.ts drives these). writeImported is the