@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.
- package/CHANGELOG.md +17 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EventPage.astro +25 -5
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/drafts.ts +9 -1
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +99 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +38 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +12 -7
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- 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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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] })),
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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
|