@acms-subir/core 0.3.6 → 0.3.8
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 +27 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EmailSettings.tsx +6 -0
- package/src/components/EventPage.astro +25 -5
- package/src/components/booking/DurationEditor.astro +156 -0
- 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/forms.ts +67 -0
- 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/email/readiness.ts +14 -0
- package/src/lib/email/service.ts +8 -3
- package/src/lib/email/templates.ts +8 -2
- package/src/lib/email/types.ts +1 -0
- 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 +23 -12
- 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/resources/link.astro +95 -49
- 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/admin/settings.astro +1 -1
- 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/email/settings.ts +4 -2
- 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
package/src/layouts/Admin.astro
CHANGED
|
@@ -30,7 +30,7 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
|
|
|
30
30
|
sessionUserId && sessionUserId !== OWNER_ENV ? repo.getUser(sessionUserId) : Promise.resolve(null),
|
|
31
31
|
]);
|
|
32
32
|
// pending booking requests, shown as a count on the Bookings nav item
|
|
33
|
-
const pendingBookings =
|
|
33
|
+
const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
|
|
34
34
|
const me = {
|
|
35
35
|
name: sessionUser?.name || 'Admin',
|
|
36
36
|
role: roleLabels[sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'],
|
|
@@ -101,6 +101,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
101
101
|
items: [
|
|
102
102
|
...collectionItems,
|
|
103
103
|
{ key: 'pages', label: 'Pages', href: '/admin/pages', icon: icons.pages, count: pages.length },
|
|
104
|
+
{ key: 'forms', label: 'Forms', href: '/admin/forms', icon: icons.pages, count: null },
|
|
104
105
|
{ key: 'tags', label: 'Tags', href: '/admin/tags', icon: icons.tags, count: null },
|
|
105
106
|
{ key: 'media', label: 'Media', href: '/admin/media', icon: icons.media, count: null },
|
|
106
107
|
],
|
|
@@ -114,7 +115,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
114
115
|
// people, booking links). Sub-pages highlight their home.
|
|
115
116
|
items: [
|
|
116
117
|
{ key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
|
|
117
|
-
...(
|
|
118
|
+
...(CALENDAR.enabled
|
|
118
119
|
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
119
120
|
: []),
|
|
120
121
|
],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { SavedForm, FormSubmission } from '../forms/schema';
|
|
3
|
+
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place } from '../schema';
|
|
3
4
|
import type { ContentRepository } from '../repo/types';
|
|
4
5
|
|
|
5
6
|
// backups from before booking approval modes: a link that didn't confirm on
|
|
@@ -10,12 +11,15 @@ const legacyApproval = (l: unknown) =>
|
|
|
10
11
|
export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
|
|
11
12
|
export type BookingRequest = z.infer<typeof BookingRequest>;
|
|
12
13
|
export const BackupContent = z.object({
|
|
13
|
-
format: z.literal('acms-backup'), version: z.number().int().min(1).max(
|
|
14
|
+
format: z.literal('acms-backup'), version: z.number().int().min(1).max(6), exportedAt: z.string().optional(),
|
|
14
15
|
settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
|
|
15
16
|
tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
|
|
16
17
|
calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
|
|
17
18
|
bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
|
|
19
|
+
places: z.array(Place).default([]),
|
|
20
|
+
forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
|
|
18
21
|
}).superRefine((data, ctx) => {
|
|
22
|
+
if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
|
|
19
23
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
20
24
|
const keys = new Set<string>();
|
|
21
25
|
for (const r of data.bookingRequests ?? []) {
|
|
@@ -27,15 +31,16 @@ export const BackupContent = z.object({
|
|
|
27
31
|
}
|
|
28
32
|
});
|
|
29
33
|
export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
30
|
-
const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState] = await Promise.all([
|
|
34
|
+
const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState, places, forms, formSubmissions] = await Promise.all([
|
|
31
35
|
repo.getSettings(), repo.listPages(), repo.listPosts(), repo.listMedia(), repo.listLinks(), repo.listTags(), repo.listNavItems(),
|
|
32
36
|
repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
|
|
37
|
+
repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
|
|
33
38
|
]);
|
|
34
39
|
return {
|
|
35
|
-
format: 'acms-backup' as const, version:
|
|
40
|
+
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
36
41
|
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
|
|
37
42
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
38
43
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
39
|
-
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
|
|
44
|
+
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions,
|
|
40
45
|
};
|
|
41
46
|
}
|
package/src/lib/booking/forms.ts
CHANGED
|
@@ -71,6 +71,73 @@ export const int = (v: FormDataEntryValue | null, min: number, max: number, fall
|
|
|
71
71
|
const n = Math.round(Number(v));
|
|
72
72
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
73
73
|
};
|
|
74
|
+
|
|
75
|
+
// Booking lengths as Hours + Minutes: `durH`/`durM` for a fixed length, and
|
|
76
|
+
// repeated `lenH`/`lenM` rows for the lengths people choose from. The older
|
|
77
|
+
// `duration` (minutes) and `lengths` (comma-separated decimal hours) fields
|
|
78
|
+
// are still read when the new ones are absent. Rows keep the raw text so a
|
|
79
|
+
// failed save can put back exactly what was typed.
|
|
80
|
+
export const LENGTH_MIN = 5;
|
|
81
|
+
export const LENGTH_MAX = 1440;
|
|
82
|
+
export type DurationRow = { h: string; m: string; error?: string };
|
|
83
|
+
export const durationRow = (minutes: number): DurationRow => ({ h: String(Math.floor(minutes / 60)), m: String(minutes % 60) });
|
|
84
|
+
|
|
85
|
+
const bounded = (minutes: number): string | undefined =>
|
|
86
|
+
minutes < LENGTH_MIN ? `Make it at least ${LENGTH_MIN} minutes.` : minutes > LENGTH_MAX ? 'Keep it to 24 hours or less.' : undefined;
|
|
87
|
+
|
|
88
|
+
export function partsToMinutes(h: string, m: string): { minutes: number } | { error: string } {
|
|
89
|
+
const hs = h.trim(), ms = m.trim();
|
|
90
|
+
if (!hs && !ms) return { error: 'Enter a length.' };
|
|
91
|
+
if (!/^\d{0,4}$/.test(hs) || !/^\d{0,4}$/.test(ms)) return { error: 'Use whole numbers for hours and minutes.' };
|
|
92
|
+
const minutes = Number(hs || 0) * 60 + Number(ms || 0);
|
|
93
|
+
const error = bounded(minutes);
|
|
94
|
+
return error ? { error } : { minutes };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const str = (v: FormDataEntryValue | null | undefined) => (typeof v === 'string' ? v : '');
|
|
98
|
+
|
|
99
|
+
export function durationFromForm(form: FormData): { minutes: number | null; row: DurationRow; error?: string } {
|
|
100
|
+
if (!form.has('durH') && !form.has('durM') && form.has('duration')) {
|
|
101
|
+
const raw = str(form.get('duration')).trim();
|
|
102
|
+
if (!/^\d{1,5}$/.test(raw)) return { minutes: null, row: { h: '', m: raw }, error: raw ? 'Length must be whole minutes.' : 'Enter a length.' };
|
|
103
|
+
const error = bounded(Number(raw));
|
|
104
|
+
return error ? { minutes: null, row: { h: '', m: raw }, error } : { minutes: Number(raw), row: durationRow(Number(raw)) };
|
|
105
|
+
}
|
|
106
|
+
const row = { h: str(form.get('durH')), m: str(form.get('durM')) };
|
|
107
|
+
const r = partsToMinutes(row.h, row.m);
|
|
108
|
+
return 'error' in r ? { minutes: null, row, error: r.error } : { minutes: r.minutes, row };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function lengthsFromForm(form: FormData): { minutes: number[]; rows: DurationRow[]; error?: string } {
|
|
112
|
+
let rows: DurationRow[];
|
|
113
|
+
const found: number[] = [];
|
|
114
|
+
if (!form.has('lenH') && !form.has('lenM') && form.has('lengths')) {
|
|
115
|
+
rows = list(str(form.get('lengths'))).map((x) => {
|
|
116
|
+
const n = Number(x) * 60;
|
|
117
|
+
if (!/^\d*\.?\d+$/.test(x) || Math.abs(n - Math.round(n)) > 1e-6) return { h: x, m: '', error: `“${x}” isn’t a length in hours.` };
|
|
118
|
+
const error = bounded(Math.round(n));
|
|
119
|
+
if (error) return { h: x, m: '', error };
|
|
120
|
+
found.push(Math.round(n));
|
|
121
|
+
return durationRow(Math.round(n));
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
const hs = form.getAll('lenH').map(str), ms = form.getAll('lenM').map(str);
|
|
125
|
+
rows = Array.from({ length: Math.max(hs.length, ms.length) }, (_, i) => {
|
|
126
|
+
const row: DurationRow = { h: hs[i] ?? '', m: ms[i] ?? '' };
|
|
127
|
+
const r = partsToMinutes(row.h, row.m);
|
|
128
|
+
if ('error' in r) return { ...row, error: r.error };
|
|
129
|
+
found.push(r.minutes);
|
|
130
|
+
return row;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const minutes = [...new Set(found)].sort((a, b) => a - b);
|
|
134
|
+
const bad = rows.filter((r) => r.error).length;
|
|
135
|
+
const error = !rows.length ? 'Add at least one length.'
|
|
136
|
+
: bad ? (bad === 1 ? 'One length needs fixing.' : `${bad} lengths need fixing.`)
|
|
137
|
+
: undefined;
|
|
138
|
+
return error ? { minutes: [], rows, error } : { minutes, rows };
|
|
139
|
+
}
|
|
140
|
+
|
|
74
141
|
export const DEFAULT_HOURS: WeeklyHours = {
|
|
75
142
|
su: [], mo: [{ start: '09:00', end: '17:00' }], tu: [{ start: '09:00', end: '17:00' }], we: [{ start: '09:00', end: '17:00' }],
|
|
76
143
|
th: [{ start: '09:00', end: '17:00' }], fr: [{ start: '09:00', end: '17:00' }], sa: [],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collectResponse, displayAnswers } from '../forms/service';
|
|
1
2
|
// Booking workflows shared by the public booking pages, the admin, the REST
|
|
2
3
|
// API and agent tools, so every path validates, notifies and fires webhooks
|
|
3
4
|
// the same way. Auto-confirm links confirm on the spot (and show on the
|
|
@@ -101,7 +102,7 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
101
102
|
id: b.id, kind: b.kind, status: b.status,
|
|
102
103
|
start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(),
|
|
103
104
|
resourceId: b.resourceId || null, linkId: b.linkId || null, eventId: b.eventId || null,
|
|
104
|
-
name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers,
|
|
105
|
+
name: b.name, email: b.email, phone: b.phone, seats: b.seats, answers: b.answers, formResponse: b.formResponse,
|
|
105
106
|
source: b.source, createdAt: new Date(b.createdAt).toISOString(), ...extra,
|
|
106
107
|
};
|
|
107
108
|
}
|
|
@@ -140,6 +141,7 @@ export interface BookerInput {
|
|
|
140
141
|
email: string;
|
|
141
142
|
phone?: string;
|
|
142
143
|
answers?: Record<string, string>;
|
|
144
|
+
formVersion?: string;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
|
|
@@ -217,10 +219,12 @@ export async function createRsvp(
|
|
|
217
219
|
if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
|
|
218
220
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
219
221
|
const booker = validateBooker(null, args.booker);
|
|
222
|
+
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
223
|
+
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
220
224
|
const b = await repo.createBooking({
|
|
221
225
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
222
226
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
223
|
-
notes: '', manageToken: token(), source: args.source ?? 'web',
|
|
227
|
+
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web',
|
|
224
228
|
}, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
|
|
225
229
|
if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
226
230
|
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
@@ -12,7 +12,13 @@ const eventFields = {
|
|
|
12
12
|
allDay: z.boolean().optional(),
|
|
13
13
|
timezone: z.string().max(80).optional(),
|
|
14
14
|
rrule: z.string().max(500).optional(),
|
|
15
|
-
|
|
15
|
+
// lat/lng pin the map image; placeId links a saved place (its details are
|
|
16
|
+
// copied in); showMap: false hides the map image on the event page
|
|
17
|
+
location: z.object({
|
|
18
|
+
name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default(''),
|
|
19
|
+
lat: z.number().min(-90).max(90).nullable().optional(), lng: z.number().min(-180).max(180).nullable().optional(),
|
|
20
|
+
placeId: z.string().max(100).optional(), showMap: z.boolean().optional(),
|
|
21
|
+
}).strict().refine((l) => (l.lat === undefined || l.lat === null) === (l.lng === undefined || l.lng === null), { message: 'Give both lat and lng, or neither.' }).optional(),
|
|
16
22
|
cta: z.object({ label: z.string().max(120).default(''), href: z.string().max(1000).default(''), style: z.enum(['primary', 'outline']).default('primary') }).strict().optional(),
|
|
17
23
|
calendars: z.array(z.string().max(100)).max(20).optional(),
|
|
18
24
|
// bookable rooms/people (slugs from the booking resources) this event uses:
|
|
@@ -14,12 +14,35 @@ import { mergeEventDraft } from '../drafts';
|
|
|
14
14
|
import { eventChanged } from './hooks';
|
|
15
15
|
import { eventToJson } from './api';
|
|
16
16
|
import { siteLocale } from '../dates';
|
|
17
|
+
import { EventLocation } from '../schema';
|
|
18
|
+
import { ensureMapImages, mergeLocationInput } from '../maps/service';
|
|
17
19
|
|
|
18
20
|
export type EventToolInput = z.infer<(typeof EventToolInputs)[number]>;
|
|
19
21
|
|
|
20
22
|
export interface AgentContext {
|
|
21
23
|
origin: string;
|
|
22
24
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
25
|
+
// request locals (maps provider + media storage for map images)
|
|
26
|
+
locals?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type LocationArg = NonNullable<Extract<EventToolInput, { tool: 'create_event' }>['arguments']['location']>;
|
|
30
|
+
|
|
31
|
+
// A location from the API/agent: a known placeId copies the saved place's
|
|
32
|
+
// details in (typed fields still win), a pin gets its map image. On update,
|
|
33
|
+
// unchanged text keeps the existing pin (mergeLocationInput).
|
|
34
|
+
async function locationFrom(repo: ContentRepository, a: LocationArg, prev: EventLocation, ctx: AgentContext): Promise<EventLocation> {
|
|
35
|
+
let input: Partial<EventLocation> = { ...a };
|
|
36
|
+
if (a.placeId) {
|
|
37
|
+
const place = await repo.getPlace(a.placeId);
|
|
38
|
+
if (!place) throw new ApiError(400, `Unknown place "${a.placeId}".`);
|
|
39
|
+
input = {
|
|
40
|
+
name: a.name || place.name, address: a.address || place.address, url: a.url || place.url,
|
|
41
|
+
lat: a.lat ?? place.lat, lng: a.lng ?? place.lng, placeId: place.id, showMap: a.showMap,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const loc = mergeLocationInput(prev, input);
|
|
45
|
+
return ensureMapImages(repo, loc, { locals: ctx.locals, origin: ctx.origin });
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
const editUrl = (id: string) => `/admin/calendar/events/${id}`;
|
|
@@ -100,7 +123,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
|
|
|
100
123
|
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
|
101
124
|
const e = await repo.createEvent({
|
|
102
125
|
title: a.title, slug, ...times, allDay: a.allDay ?? a.start.length === 10, timezone: a.timezone ?? '',
|
|
103
|
-
rrule: a.rrule ?? '', excerpt: a.excerpt ?? '',
|
|
126
|
+
rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', cta: a.cta, tags: a.tags,
|
|
127
|
+
...(a.location ? { location: await locationFrom(repo, a.location, EventLocation.parse({}), ctx) } : {}),
|
|
104
128
|
cancelled: a.cancelled ?? false, seoTitle: a.seoTitle, seoDesc: a.seoDesc,
|
|
105
129
|
calendarIds: (await resolveCalendars(repo, a.calendars)) ?? [],
|
|
106
130
|
...(a.rooms ? { roomHold: await resolveRooms(repo, a.rooms) } : {}),
|
|
@@ -110,11 +134,12 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
|
|
|
110
134
|
return { ...summary(e) };
|
|
111
135
|
}
|
|
112
136
|
case 'update_event': {
|
|
113
|
-
const { id, content, calendars: slugs, rooms, ...rest } = input.arguments;
|
|
114
|
-
checkLinks(rest);
|
|
137
|
+
const { id, content, calendars: slugs, rooms, location, ...rest } = input.arguments;
|
|
138
|
+
checkLinks({ ...rest, location });
|
|
115
139
|
const before = await repo.getEvent(id);
|
|
116
140
|
if (!before) throw new ApiError(404, 'Event not found.');
|
|
117
141
|
const data: Partial<CalEvent> = { ...rest };
|
|
142
|
+
if (location) data.location = await locationFrom(repo, location, mergeEventDraft(before).location, ctx);
|
|
118
143
|
if (content !== undefined) {
|
|
119
144
|
data.bodyHtml = renderAgentMarkdown(content);
|
|
120
145
|
data.bodyJson = '';
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface EventJson {
|
|
|
25
25
|
exdates: string[];
|
|
26
26
|
location: CalEvent['location'];
|
|
27
27
|
cta: CalEvent['cta'];
|
|
28
|
+
rsvp?: CalEvent['rsvp'];
|
|
28
29
|
image: string;
|
|
29
30
|
imageAlt: string;
|
|
30
31
|
tags: string[];
|
|
@@ -55,6 +56,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
55
56
|
exdates: e.exdates,
|
|
56
57
|
location: e.location,
|
|
57
58
|
cta: e.cta,
|
|
59
|
+
rsvp: e.rsvp,
|
|
58
60
|
image: e.cover ? abs(origin, e.cover) : '',
|
|
59
61
|
imageAlt: e.coverAlt,
|
|
60
62
|
tags: e.tags.filter((t) => !t.startsWith('#')),
|
|
@@ -92,6 +92,8 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
92
92
|
lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
|
|
93
93
|
const loc = locationLine(e.location);
|
|
94
94
|
if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
|
|
95
|
+
// RFC 5545 GEO: latitude;longitude
|
|
96
|
+
if (typeof e.location.lat === 'number' && typeof e.location.lng === 'number') lines.push(`GEO:${e.location.lat.toFixed(6)};${e.location.lng.toFixed(6)}`);
|
|
95
97
|
const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
|
|
96
98
|
lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
|
|
97
99
|
const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
|
package/src/lib/calendar/ics.ts
CHANGED
|
@@ -55,6 +55,7 @@ const DAY = 86_400_000;
|
|
|
55
55
|
/* ---------------- timezone math (no dependencies) ---------------- */
|
|
56
56
|
|
|
57
57
|
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
|
58
|
+
const FORMATTER_CACHE_MAX = 64;
|
|
58
59
|
function tzFormatter(tz: string): Intl.DateTimeFormat | null {
|
|
59
60
|
let f = fmtCache.get(tz);
|
|
60
61
|
if (f) return f;
|
|
@@ -68,6 +69,9 @@ function tzFormatter(tz: string): Intl.DateTimeFormat | null {
|
|
|
68
69
|
return null; // unknown TZID (Outlook's "Customized Time Zone" etc.)
|
|
69
70
|
}
|
|
70
71
|
fmtCache.set(tz, f);
|
|
72
|
+
// Intl formatters retain native ICU data; timezone aliases/case variants
|
|
73
|
+
// must not accumulate for the lifetime of a self-hosted server.
|
|
74
|
+
if (fmtCache.size > FORMATTER_CACHE_MAX) fmtCache.delete(fmtCache.keys().next().value!);
|
|
71
75
|
return f;
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// import action saves as DRAFTS — nothing goes live without a review.
|
|
4
4
|
// Recurring ICS events keep their RRULE/EXDATEs; per-instance overrides
|
|
5
5
|
// (RECURRENCE-ID) are skipped, since a native event can't hold them yet.
|
|
6
|
-
import
|
|
6
|
+
import { EventLocation } from '../schema';
|
|
7
7
|
import { parseICS, tzParts, type IcsTime, type VEvent } from './ics';
|
|
8
8
|
import { isValidTimeZone, normalizeEventTimes, parseWall, addWallDays, formatWall } from './events';
|
|
9
9
|
import { cleanRrule } from './rrule';
|
|
@@ -78,7 +78,7 @@ export function eventsFromVEvents(vevents: VEvent[], calTz: string, siteTz: stri
|
|
|
78
78
|
exdates: v.exdates.map((t) => wallOf(t, siteTz).wall),
|
|
79
79
|
excerpt: v.description.slice(0, 300),
|
|
80
80
|
bodyHtml: paragraphs(v.description),
|
|
81
|
-
location: { name: v.location.slice(0, 300)
|
|
81
|
+
location: EventLocation.parse({ name: v.location.slice(0, 300) }),
|
|
82
82
|
cancelled: v.cancelled,
|
|
83
83
|
sourceUid: v.uid,
|
|
84
84
|
});
|
|
@@ -192,7 +192,7 @@ export function eventsFromCsv(text: string): { events: ImportedEvent[]; skipped:
|
|
|
192
192
|
events.push({
|
|
193
193
|
title: title.slice(0, 300), ...times, allDay, timezone: '', rrule: '', exdates: [],
|
|
194
194
|
excerpt: desc.slice(0, 300), bodyHtml: paragraphs(desc),
|
|
195
|
-
location: { name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) },
|
|
195
|
+
location: EventLocation.parse({ name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) }),
|
|
196
196
|
cancelled: false, sourceUid: '',
|
|
197
197
|
});
|
|
198
198
|
} catch {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// the editor's "stage until Update" flow is for content edits, not dates.
|
|
6
6
|
import type { ContentRepository } from '../repo/types';
|
|
7
7
|
import type { CalEvent } from '../schema';
|
|
8
|
-
import { slugify } from '../schema';
|
|
8
|
+
import { EventLocation, slugify } from '../schema';
|
|
9
|
+
import { defaultPlaceLocation } from '../maps/places';
|
|
9
10
|
import { ApiError } from '../api';
|
|
10
11
|
import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
|
|
11
12
|
import { addWallDays, formatWall, parseWall } from './events';
|
|
@@ -49,10 +50,13 @@ export interface QuickEvent {
|
|
|
49
50
|
export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
|
|
50
51
|
const title = q.title.trim().slice(0, 300) || 'Untitled event';
|
|
51
52
|
const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
|
|
53
|
+
// no place typed → the site's default place (if one is set)
|
|
54
|
+
const typed = q.location.trim().slice(0, 300);
|
|
55
|
+
const location = typed ? EventLocation.parse({ name: typed }) : (await defaultPlaceLocation(repo)) ?? EventLocation.parse({});
|
|
52
56
|
const created = await repo.createEvent({
|
|
53
57
|
title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
|
|
54
58
|
calendarIds: cal?.kind === 'native' ? [cal.id] : [],
|
|
55
|
-
location
|
|
59
|
+
location,
|
|
56
60
|
});
|
|
57
61
|
if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
|
|
58
62
|
// times + repeat go through the editor's validation (end defaults, pairs)
|
package/src/lib/calendar/seo.ts
CHANGED
|
@@ -24,8 +24,11 @@ export function eventJsonLd(e: CalEvent, o: Pick<Occurrence, 'start' | 'end' | '
|
|
|
24
24
|
return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
25
25
|
};
|
|
26
26
|
const online = Boolean(e.location.url && isMeetingUrl(e.location.url));
|
|
27
|
+
const pin = typeof e.location.lat === 'number' && typeof e.location.lng === 'number'
|
|
28
|
+
? { geo: { '@type': 'GeoCoordinates', latitude: e.location.lat, longitude: e.location.lng } }
|
|
29
|
+
: {};
|
|
27
30
|
const place = e.location.name || e.location.address
|
|
28
|
-
? { '@type': 'Place', name: e.location.name || e.location.address, ...(e.location.address ? { address: e.location.address } : {}) }
|
|
31
|
+
? { '@type': 'Place', name: e.location.name || e.location.address, ...(e.location.address ? { address: e.location.address } : {}), ...pin }
|
|
29
32
|
: null;
|
|
30
33
|
const virtual = online ? { '@type': 'VirtualLocation', url: e.location.url } : null;
|
|
31
34
|
const location = place && virtual ? [place, virtual] : place ?? virtual;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContentRepository } from './repo/types';
|
|
2
|
-
import
|
|
2
|
+
import { FormDefinition } from './forms/schema';
|
|
3
|
+
import type { CalEvent, EventLocation, Page, Post, WidgetInstance } from './schema';
|
|
3
4
|
import { slugify } from './schema';
|
|
4
5
|
import { ApiError } from './api';
|
|
5
6
|
import { CONTENT, collectionUrl } from './content';
|
|
@@ -9,6 +10,8 @@ import { eventChanged } from './calendar/hooks';
|
|
|
9
10
|
import { normalizePageLocale } from './i18n';
|
|
10
11
|
import { siteOrigin } from './seo';
|
|
11
12
|
import { waitUntilFrom } from './webhooks';
|
|
13
|
+
import { mergeEventDraft } from './drafts';
|
|
14
|
+
import { ensureMapImages, mergeLocationInput } from './maps/service';
|
|
12
15
|
|
|
13
16
|
// Saving posts, pages and events: the one implementation behind the admin
|
|
14
17
|
// editor (astro:actions) and the editor-app API (/api/v1/admin/documents).
|
|
@@ -27,7 +30,12 @@ export interface SaveHooks {
|
|
|
27
30
|
|
|
28
31
|
export type PostSave = Parameters<ContentRepository['updatePost']>[1];
|
|
29
32
|
export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blocks'>;
|
|
30
|
-
|
|
33
|
+
// location may be partial: missing pin fields are filled from the current
|
|
34
|
+
// location (see mergeLocationInput) — older clients send name/address/url only
|
|
35
|
+
export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt' | 'location'>> & {
|
|
36
|
+
location?: Partial<EventLocation>;
|
|
37
|
+
};
|
|
38
|
+
type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
|
|
31
39
|
|
|
32
40
|
const live = (status: string) => status === 'published' || status === 'scheduled';
|
|
33
41
|
|
|
@@ -107,8 +115,21 @@ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks
|
|
|
107
115
|
|
|
108
116
|
// Validate + normalize an event save. Times are checked as a PAIR against the
|
|
109
117
|
// merged (staged) state so a half-edited range can't slip through.
|
|
110
|
-
export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<
|
|
111
|
-
const
|
|
118
|
+
export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventClean> {
|
|
119
|
+
const { location, ...rest } = data;
|
|
120
|
+
const out: EventClean = { ...rest };
|
|
121
|
+
// Older editor clients resend the original RSVP shape. Omission must not
|
|
122
|
+
// erase questions that were authored in a newer editor.
|
|
123
|
+
if (out.rsvp && !Object.hasOwn(out.rsvp, 'form')) {
|
|
124
|
+
const previous = mergeEventDraft(before).rsvp.form;
|
|
125
|
+
if (previous) out.rsvp = { ...out.rsvp, form: previous };
|
|
126
|
+
}
|
|
127
|
+
if (out.rsvp?.form !== undefined) {
|
|
128
|
+
const form = FormDefinition.safeParse(out.rsvp.form);
|
|
129
|
+
if (!form.success) throw new ApiError(400, form.error.issues[0]?.message || 'Invalid registration form.');
|
|
130
|
+
out.rsvp = { ...out.rsvp, form: form.data };
|
|
131
|
+
}
|
|
132
|
+
if (location !== undefined) out.location = mergeLocationInput(mergeEventDraft(before).location, location);
|
|
112
133
|
if (out.slug !== undefined) {
|
|
113
134
|
const base = slugify(out.slug || out.title || before.title);
|
|
114
135
|
let slug = base;
|
|
@@ -178,6 +199,11 @@ export async function saveEvent(repo: ContentRepository, id: string, data: Event
|
|
|
178
199
|
const before = await repo.getEvent(id);
|
|
179
200
|
if (!before) throw new ApiError(404, 'Event not found.');
|
|
180
201
|
const clean = await cleanEventInput(repo, data, before);
|
|
202
|
+
// static map images follow the pin (fetched once per pin, never blocking)
|
|
203
|
+
if (clean.location) {
|
|
204
|
+
const settings = await repo.getSettings();
|
|
205
|
+
clean.location = await ensureMapImages(repo, clean.location, { locals: hooks.locals, origin: siteOrigin(settings, hooks.url) });
|
|
206
|
+
}
|
|
181
207
|
// live events stage edits until Update, exactly like posts
|
|
182
208
|
const transitions = clean.status !== undefined && clean.status !== before.status;
|
|
183
209
|
if (live(before.status) && !transitions) {
|
package/src/lib/drafts.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CalEvent, NavItem, Page, Post, Settings } from './schema';
|
|
2
|
+
import { EventLocation } from './schema';
|
|
2
3
|
|
|
3
4
|
// Pending-edit ("draft") snapshots for live documents. A published or
|
|
4
5
|
// scheduled post/page never receives content edits directly: they accumulate
|
|
@@ -44,7 +45,14 @@ export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post
|
|
|
44
45
|
return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
|
|
45
46
|
}
|
|
46
47
|
export function pickEventDraft(draft: Record<string, unknown>): Partial<Pick<CalEvent, EventDraftKey>> {
|
|
47
|
-
|
|
48
|
+
const out = pick(draft, EVENT_DRAFT_KEYS) as Partial<Pick<CalEvent, EventDraftKey>>;
|
|
49
|
+
// snapshots staged before maps support lack the pin fields: fill defaults
|
|
50
|
+
// so merged events always carry a complete location
|
|
51
|
+
if (out.location !== undefined) {
|
|
52
|
+
const loc = EventLocation.safeParse(out.location);
|
|
53
|
+
if (loc.success) out.location = loc.data; else delete out.location;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
48
56
|
}
|
|
49
57
|
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
50
58
|
return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { renderEventCards, type EventCardAttrs } from './calendar/cards';
|
|
|
19
19
|
// <div data-dyn="events" data-mode="upcoming" data-calendar="youth"
|
|
20
20
|
// data-event="" data-limit="3" data-layout="grid" data-show-image="1"></div>
|
|
21
21
|
|
|
22
|
-
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events)"[^>]*>\s*<\/div>/g;
|
|
22
|
+
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc|events|form)"[^>]*>\s*<\/div>/g;
|
|
23
23
|
|
|
24
24
|
const esc = (s: string) =>
|
|
25
25
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -133,11 +133,12 @@ function eventCardAttrs(tag: string): EventCardAttrs {
|
|
|
133
133
|
|
|
134
134
|
export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
|
|
135
135
|
html = stampHeadingAnchors(html);
|
|
136
|
+
const hasForms = html.includes('data-dyn="form"');
|
|
136
137
|
const hasPostList = html.includes('data-dyn="post-list"');
|
|
137
138
|
const hasCalendar = html.includes('data-dyn="calendar"');
|
|
138
139
|
const hasToc = html.includes('data-dyn="toc"');
|
|
139
140
|
const hasEvents = html.includes('data-dyn="events"');
|
|
140
|
-
if (!hasPostList && !hasCalendar && !hasToc && !hasEvents) return html;
|
|
141
|
+
if (!hasPostList && !hasCalendar && !hasToc && !hasEvents && !hasForms) return html;
|
|
141
142
|
const [posts, settings] = await Promise.all([
|
|
142
143
|
hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
|
|
143
144
|
repo.getSettings(),
|
|
@@ -160,9 +161,19 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
160
161
|
await Promise.all(tags.map(async (tag) => calendarHtml.set(tag, await renderEventCards(eventCardAttrs(tag), repo, settings, now))));
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
if (hasForms) {
|
|
165
|
+
const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter(t => t.includes('data-dyn="form"')))];
|
|
166
|
+
await Promise.all(tags.map(async tag => {
|
|
167
|
+
const form = await repo.forms.get(attr(tag, 'form'));
|
|
168
|
+
// A same-origin frame gives each visitor an uncached form/receipt even
|
|
169
|
+
// when the containing content page is served from a shared cache.
|
|
170
|
+
calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
|
|
171
|
+
? `<iframe class="pc-form-embed" src="/forms/${encodeURIComponent(form.id)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
163
174
|
const anchored = html;
|
|
164
175
|
return html.replace(PLACEHOLDER, (tag) => {
|
|
165
|
-
if (tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
|
|
176
|
+
if (tag.includes('data-dyn="form"') || tag.includes('data-dyn="calendar"') || tag.includes('data-dyn="events"')) return calendarHtml.get(tag) ?? '';
|
|
166
177
|
if (tag.includes('data-dyn="toc"')) return renderToc(anchored, attr(tag, 'title'));
|
|
167
178
|
const source = attr(tag, 'source') || 'latest';
|
|
168
179
|
const tagName = attr(tag, 'tag');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormDefinition } from '../forms/schema';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import type { ContentRepository } from '../repo/types';
|
|
3
4
|
import type { CalEvent, DocTranslations, Page, Post } from '../schema';
|
|
@@ -10,6 +11,7 @@ import { draftSavedAt, mergeEventDraft, mergePageDraft, mergePostDraft, pickEven
|
|
|
10
11
|
import { localeHomeUrl } from '../i18n';
|
|
11
12
|
import { pageQuery } from '../mobile/http';
|
|
12
13
|
import { applyEventUpdate, applyPageUpdate, applyPostUpdate, saveEvent, savePage, savePost, type SaveHooks } from '../content-service';
|
|
14
|
+
import { defaultPlaceLocation } from '../maps/places';
|
|
13
15
|
|
|
14
16
|
// Posts, pages and events for editor apps. Reads show the document as it
|
|
15
17
|
// will look after Update (staged edits merged); writes go through the same
|
|
@@ -134,7 +136,9 @@ export async function createDoc(repo: ContentRepository, input: z.infer<typeof C
|
|
|
134
136
|
catch (err) { throw new ApiError(400, err instanceof Error ? err.message : 'Invalid start time.'); }
|
|
135
137
|
let slug = base;
|
|
136
138
|
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
|
137
|
-
|
|
139
|
+
// new events start at the site's default place, like the admin
|
|
140
|
+
const place = await defaultPlaceLocation(repo);
|
|
141
|
+
return repo.createEvent({ title: input.title, slug, start: times.start, end: times.end, allDay: input.allDay ?? !input.start.includes('T'), ...(place ? { location: place } : {}) });
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
// ---- save ----------------------------------------------------------------
|
|
@@ -148,10 +152,15 @@ export const PostFields = z.object({ ...common, excerpt: text(5000), tags: z.arr
|
|
|
148
152
|
export const PageFields = z.object({ ...common, isHome: z.boolean(), locale: text(20) }).partial().strict();
|
|
149
153
|
export const EventFields = z.object({ ...common, excerpt: text(1000), tags: z.array(text(100)).max(100), cover: text(2000), coverAlt: text(1000), featured: z.boolean(),
|
|
150
154
|
start: text(40), end: text(40), allDay: z.boolean(), timezone: text(100), rrule: text(500), exdates: z.array(text(40)).max(500), cancelled: z.boolean(),
|
|
151
|
-
location: z.object({
|
|
155
|
+
location: z.object({
|
|
156
|
+
name: text(300), address: text(500), url: text(1000),
|
|
157
|
+
// optional pin fields; omitted = kept while the text is unchanged
|
|
158
|
+
lat: z.number().min(-90).max(90).nullable(), lng: z.number().min(-180).max(180).nullable(),
|
|
159
|
+
placeId: text(100), showMap: z.boolean(), mapImage: text(500), mapImageDark: text(500),
|
|
160
|
+
}).partial({ lat: true, lng: true, placeId: true, showMap: true, mapImage: true, mapImageDark: true }).strict(),
|
|
152
161
|
cta: z.object({ label: text(120), href: text(1000), style: z.enum(['primary', 'outline']) }).strict(),
|
|
153
162
|
calendarIds: z.array(text(100)).max(50), fields: z.record(text(2000)),
|
|
154
|
-
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).strict(),
|
|
163
|
+
rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50), form: FormDefinition.optional() }).strict(),
|
|
155
164
|
}).partial().strict();
|
|
156
165
|
export const SaveInput = z.object({ revision: z.string().max(60), fields: z.record(z.unknown()) }).strict();
|
|
157
166
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { FormDefinition } from '../forms/schema';
|
|
2
3
|
import { Booking } from '../schema';
|
|
3
4
|
|
|
4
5
|
// Response shapes of the editor-app API (OpenAPI components, client types).
|
|
@@ -40,8 +41,8 @@ export const EditorDocument = EditorDocumentSummary.extend({
|
|
|
40
41
|
authorIds: z.array(str), locale: str.nullable(), isHome: bool, publishedAt: date.nullable(), scheduledAt: date.nullable(), createdAt: date,
|
|
41
42
|
event: z.object({
|
|
42
43
|
start: str, end: str, allDay: bool, timezone: str, rrule: str, exdates: z.array(str), cancelled: bool,
|
|
43
|
-
location: z.object({ name: str, address: str, url: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
|
|
44
|
-
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num }),
|
|
44
|
+
location: z.object({ name: str, address: str, url: str, lat: num.nullable(), lng: num.nullable(), placeId: str, showMap: bool, mapImage: str, mapImageDark: str }), cta: z.object({ label: str, href: str, style: z.enum(['primary', 'outline']) }),
|
|
45
|
+
calendarIds: z.array(str), fields: z.record(str), rsvp: z.object({ enabled: bool, capacity: num, autoConfirm: bool, maxSeats: num, form: FormDefinition.optional() }),
|
|
45
46
|
}).nullable(),
|
|
46
47
|
languages: z.array(str),
|
|
47
48
|
});
|
|
@@ -79,7 +80,7 @@ export const EditorMedia = z.object({
|
|
|
79
80
|
});
|
|
80
81
|
export const EditorMediaList = z.object({ items: z.array(EditorMedia), pagination: Pagination });
|
|
81
82
|
|
|
82
|
-
export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true, notes: true })
|
|
83
|
+
export const EditorBooking = Booking.pick({ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true, notes: true })
|
|
83
84
|
.extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date, updatedAt: date, title: str });
|
|
84
85
|
export const EditorBookingList = z.object({ pending: num.int(), items: z.array(EditorBooking), pagination: Pagination });
|
|
85
86
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { encryptionAvailable } from './config';
|
|
2
|
+
import { canonicalOrigin } from './templates';
|
|
3
|
+
import type { ResolvedConfig } from './types';
|
|
4
|
+
|
|
5
|
+
// Transport readiness and readiness for messages containing links are separate:
|
|
6
|
+
// a successful connection test does not validate the canonical site address.
|
|
7
|
+
export function emailReadiness(resolved: ResolvedConfig, siteUrl: string, locals?: unknown) {
|
|
8
|
+
const encrypted = encryptionAvailable(locals);
|
|
9
|
+
const error = resolved.error || (!encrypted ? 'Configure EMAIL_ENCRYPTION_KEY for protected email delivery.' : '');
|
|
10
|
+
const configured = !error && resolved.provider !== 'none' && resolved.config.enabled;
|
|
11
|
+
let linkError = '';
|
|
12
|
+
try { canonicalOrigin(siteUrl); } catch (e) { linkError = (e as Error).message; }
|
|
13
|
+
return { configured, error, encryptionAvailable: encrypted, linksReady: configured && !linkError, linkError };
|
|
14
|
+
}
|