@acms-subir/core 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -1,10 +1,17 @@
1
- import type { ContentRepository, SearchResults, Stats } from './types';
2
- import { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
3
- import { pickDesignDraft, pickPageDraft, pickPostDraft } from '../drafts';
1
+ import { LiveStore } from '../live/store';
2
+ import { LiveConfig } from '../live/types';
3
+ import { ApiError } from '../api';
4
+ import { EmailStore, bookingJobs } from '../email/store';
5
+ import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
6
+ import { 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';
7
+ import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
8
+ import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
9
+ import { cleanRrule } from '../calendar/rrule';
4
10
  import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
5
11
  import { htmlToPlaintext } from '../plaintext';
6
12
  import { SEED } from '../brand';
7
13
  import { CONTENT, postsIndexUrl } from '../content';
14
+ import { webhookStatement } from '../webhook-sql';
8
15
 
9
16
  // Shared SQL implementation of ContentRepository. Cloudflare D1 and libSQL
10
17
  // are both SQLite, so every statement, mapper and behavior lives here once;
@@ -92,6 +99,99 @@ function rowToLink(r: Row): Link {
92
99
  });
93
100
  }
94
101
 
102
+ const json = <T>(raw: unknown, fallback: T): T => {
103
+ try {
104
+ return raw === null || raw === undefined || raw === '' ? fallback : (JSON.parse(String(raw)) as T);
105
+ } catch {
106
+ return fallback;
107
+ }
108
+ };
109
+ function rowToCalendar(r: Row): Calendar {
110
+ return Calendar.parse({
111
+ id: r.id, slug: r.slug, name: r.name, color: r.color ?? '#4f46e5', description: r.description ?? '',
112
+ kind: r.kind ?? 'native', feedUrl: r.feed_url ?? '', visibility: r.visibility ?? 'public',
113
+ feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
114
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
115
+ });
116
+ }
117
+ function rowToEvent(r: Row): CalEvent {
118
+ return CalEvent.parse({
119
+ id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
120
+ excerpt: r.excerpt ?? '', cover: r.cover ?? '', coverAlt: r.cover_alt ?? '',
121
+ seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
122
+ featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
123
+ start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
124
+ rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
125
+ location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
126
+ fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
127
+ startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft),
128
+ publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
129
+ scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
130
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
131
+ });
132
+ }
133
+ function rowToResource(r: Row): Resource {
134
+ return Resource.parse({
135
+ id: r.id, slug: r.slug, name: r.name, kind: r.kind ?? 'Room', description: r.description ?? '', photo: r.photo ?? '',
136
+ capacity: r.capacity === null || r.capacity === undefined ? null : Number(r.capacity),
137
+ amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
138
+ busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
139
+ showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
140
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
141
+ });
142
+ }
143
+ function rowToLink2(r: Row): BookingLink {
144
+ return BookingLink.parse({
145
+ id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
146
+ duration: Number(r.duration), lengths: json(r.lengths, []), minDays: Number(r.min_days), maxDays: Number(r.max_days),
147
+ step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
148
+ minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
149
+ hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
150
+ askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
151
+ redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
152
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
153
+ });
154
+ }
155
+ function rowToBooking(r: Row): Booking {
156
+ return Booking.parse({
157
+ id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
158
+ status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
159
+ timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
160
+ answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
161
+ notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
162
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
163
+ });
164
+ }
165
+ function rowToApiKey(r: Row): ApiKey {
166
+ return ApiKey.parse({
167
+ id: r.id, name: r.name, prefix: r.prefix, scopes: json(r.scopes, []),
168
+ lastUsedAt: r.last_used_at === null || r.last_used_at === undefined ? null : Number(r.last_used_at),
169
+ createdAt: Number(r.created_at),
170
+ });
171
+ }
172
+ function rowToWebhook(r: Row): Webhook {
173
+ return Webhook.parse({
174
+ id: r.id, url: r.url, secret: r.secret, events: json(r.events, []), active: Boolean(r.active),
175
+ lastStatus: r.last_status === null || r.last_status === undefined ? null : Number(r.last_status),
176
+ lastAt: r.last_at === null || r.last_at === undefined ? null : Number(r.last_at),
177
+ createdAt: Number(r.created_at),
178
+ });
179
+ }
180
+ function rowToDelivery(r: Row): WebhookDelivery {
181
+ const n = (v: unknown) => (v === null || v === undefined ? null : Number(v));
182
+ return WebhookDelivery.parse({
183
+ id: r.id, webhookId: r.webhook_id, event: r.event, payload: r.payload, status: r.status,
184
+ attempts: Number(r.attempts ?? 0), nextAt: n(r.next_at), responseCode: n(r.response_code),
185
+ createdAt: Number(r.created_at), deliveredAt: n(r.delivered_at),
186
+ });
187
+ }
188
+
189
+ // URL-safe secret for private calendar feeds
190
+ function randomToken(): string {
191
+ const bytes = crypto.getRandomValues(new Uint8Array(18));
192
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
193
+ }
194
+
95
195
  // FTS5 MATCH expression from user input: each whitespace token becomes a
96
196
  // quoted phrase (operators like AND / OR / - stay inert), the last one gets
97
197
  // a * for search-as-you-type prefix matching.
@@ -117,9 +217,13 @@ function safeSnippet(raw: unknown): string {
117
217
  export class SqlRepository implements ContentRepository {
118
218
  private driver!: SqlDriver;
119
219
  private ready: Promise<void>;
220
+ readonly email: EmailStore;
221
+ readonly live: LiveStore;
120
222
 
121
223
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
122
224
  this.ready = this.init(driver);
225
+ this.live = new LiveStore(async () => { await this.ready; return this.driver; });
226
+ this.email = new EmailStore(async () => { await this.ready; return this.driver; });
123
227
  }
124
228
 
125
229
  private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
@@ -215,6 +319,29 @@ export class SqlRepository implements ContentRepository {
215
319
  }
216
320
  return stmts;
217
321
  }
322
+ private eventIndexStatements(r: Row): SqlStatement[] {
323
+ const stmts: SqlStatement[] = [
324
+ { sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [r.id] },
325
+ ];
326
+ if (r.status === 'published') {
327
+ const loc = json<{ name?: string; address?: string }>(r.location, {});
328
+ stmts.push({
329
+ sql: "INSERT INTO search_index (kind, ref_id, locale, title, excerpt, plaintext, tags) VALUES ('event', ?, '', ?, ?, ?, ?)",
330
+ args: [
331
+ r.id, r.title, r.excerpt ?? '',
332
+ [htmlToPlaintext(String(r.body_html ?? '')), loc.name ?? '', loc.address ?? ''].filter(Boolean).join(' '),
333
+ publicTags(json<string[]>(r.tags, [])).join(' '),
334
+ ],
335
+ });
336
+ }
337
+ return stmts;
338
+ }
339
+ private async syncEventIndex(id: string) {
340
+ const r = await this.first('SELECT id, status, title, excerpt, tags, body_html, location FROM events WHERE id = ?', [id]);
341
+ await this.driver.batch(
342
+ r ? this.eventIndexStatements(r) : [{ sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] }]
343
+ );
344
+ }
218
345
  private async syncPostIndex(id: string) {
219
346
  const r = await this.first('SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE id = ?', [id]);
220
347
  await this.driver.batch(
@@ -229,17 +356,55 @@ export class SqlRepository implements ContentRepository {
229
356
  }
230
357
  // full rebuild: the v21 backfill, restores, seeds
231
358
  private async reindexSearch() {
232
- const [posts, pages] = await Promise.all([
359
+ const [posts, pages, events] = await Promise.all([
233
360
  this.driver.all("SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE status = 'published'"),
234
361
  this.driver.all("SELECT id, status, locale, title, seo_desc, body_html FROM pages WHERE status = 'published'"),
362
+ this.driver.all("SELECT id, status, title, excerpt, tags, body_html, location FROM events WHERE status = 'published'"),
235
363
  ]);
236
364
  await this.driver.batch([
237
365
  { sql: 'DELETE FROM search_index' },
238
366
  ...posts.flatMap((r) => this.postIndexStatements(r)),
239
367
  ...pages.flatMap((r) => this.pageIndexStatements(r)),
368
+ ...events.flatMap((r) => this.eventIndexStatements(r)),
240
369
  ]);
241
370
  }
242
371
 
372
+ async listPublicContent(q: Parameters<ContentRepository['listPublicContent']>[0]) {
373
+ await this.ready;
374
+ const collections = q.collection ? [q.collection] : q.collections;
375
+ const rows = await this.driver.all(`SELECT id, kind FROM (
376
+ SELECT id, 'post' AS kind, COALESCE(published_at, created_at) AS sort_at FROM posts
377
+ WHERE status='published' AND collection IN (${collections.map(() => '?').join(',') || "''"})
378
+ AND ? != 'page' AND (? IS NULL OR ? = ?)
379
+ UNION ALL
380
+ SELECT id, 'page' AS kind, created_at AS sort_at FROM pages WHERE status='published'
381
+ AND ? != 'post' AND ? IS NULL AND (? IS NULL OR COALESCE(NULLIF(locale,''),?) = ?)
382
+ ) ORDER BY sort_at DESC, kind ASC, id ASC LIMIT ? OFFSET ?`,
383
+ [...collections, q.kind ?? '', q.locale ?? null, q.locale ?? null, q.defaultLocale,
384
+ q.kind ?? '', q.collection ?? null, q.locale ?? null, q.defaultLocale, q.locale ?? null,
385
+ q.limit + 1, (q.page - 1) * q.limit]);
386
+ return rows.map(r => ({ id: String(r.id), kind: r.kind === 'post' ? 'post' as const : 'page' as const }));
387
+ }
388
+
389
+ async exportBookingState() {
390
+ await this.ready;
391
+ // One SELECT snapshots bookings and their retry ledger together. No UI
392
+ // list limit; include tombstones for deleted bookings as well.
393
+ 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'];
394
+ const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
395
+ UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
396
+ return {
397
+ bookings: rows.filter(r => r.type === 'booking').map(r => rowToBooking(JSON.parse(String(r.data)))),
398
+ bookingRequests: rows.filter(r => r.type === 'request').map(r => JSON.parse(String(r.data)) as import('../backup/content').BookingRequest),
399
+ };
400
+ }
401
+
402
+ async getBookingRequest(keyHash: string) {
403
+ await this.ready;
404
+ const r = await this.first('SELECT request_hash, booking_id FROM mobile_booking_requests WHERE key_hash=?', [keyHash]);
405
+ return r ? { requestHash: String(r.request_hash), bookingId: String(r.booking_id) } : null;
406
+ }
407
+
243
408
  // ---- pages ----
244
409
  async listPages() {
245
410
  await this.ready;
@@ -275,6 +440,8 @@ export class SqlRepository implements ContentRepository {
275
440
  }
276
441
  async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
277
442
  await this.ready;
443
+ if (!(await this.getPage(id))) return;
444
+ const writes: SqlStatement[] = [];
278
445
  // a direct live write supersedes any staged edits (applyPageDraft routes
279
446
  // through here too, so applying clears the snapshot in the same UPDATE)
280
447
  const sets: string[] = ['updated_at = ?', 'draft = NULL'];
@@ -297,12 +464,13 @@ export class SqlRepository implements ContentRepository {
297
464
  data.locale !== undefined
298
465
  ? data.locale
299
466
  : String((await this.first('SELECT locale FROM pages WHERE id = ?', [id]))?.locale ?? '');
300
- await this.driver.run('UPDATE pages SET is_home = 0 WHERE locale = ?', [locale]);
467
+ writes.push({ sql: 'UPDATE pages SET is_home = 0 WHERE locale = ? AND id != ? AND is_home = 1', args: [locale, id] });
301
468
  }
302
469
  sets.push('is_home = ?'); args.push(data.isHome ? 1 : 0);
303
470
  }
304
471
  args.push(id);
305
- await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
472
+ writes.push({ sql: `UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args });
473
+ await this.driver.batch(writes);
306
474
  await this.syncPageIndex(id);
307
475
  }
308
476
  // stage edits on a live page: only the `draft` JSON changes — live columns,
@@ -417,8 +585,7 @@ export class SqlRepository implements ContentRepository {
417
585
  if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
418
586
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
419
587
  // authorIds is the source of truth; author_id mirrors the first entry.
420
- // The join rows are written AFTER the main UPDATE succeeds, so a failed
421
- // update (e.g. duplicate slug) can't leave the two out of sync.
588
+ // Main row, join rows and webhook notifications share one transaction.
422
589
  let authorJoin: string[] | null = null;
423
590
  if (data.authorIds !== undefined) {
424
591
  authorJoin = data.authorIds;
@@ -436,16 +603,28 @@ export class SqlRepository implements ContentRepository {
436
603
  }
437
604
  }
438
605
  args.push(id);
439
- await this.driver.run(`UPDATE posts SET ${sets.join(', ')} WHERE id = ?`, args);
606
+ const writes: SqlStatement[] = [{ sql: `UPDATE posts SET ${sets.join(', ')} WHERE id = ?`, args }];
440
607
  if (authorJoin !== null) {
441
- await this.driver.batch([
608
+ writes.push(
442
609
  { sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
443
610
  ...authorJoin.map((aid, i) => ({
444
611
  sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)',
445
612
  args: [id, aid, i],
446
613
  })),
447
- ]);
614
+ );
615
+ // The main-row trigger detects primary-author changes. Additional
616
+ // authors live in a join table, so emit once for an authors-only edit.
617
+ const authorsOnly = current.status === 'published'
618
+ && (authorJoin[0] ?? null) === current.authorId
619
+ && JSON.stringify(authorJoin) !== JSON.stringify(current.authorIds)
620
+ && Object.entries(data).every(([key, value]) => key === 'authorIds' || value === undefined
621
+ || JSON.stringify(value) === JSON.stringify(current[key as keyof Post]));
622
+ if (authorsOnly) {
623
+ const post = { id, slug: current.slug, title: current.title, status: current.status, collection: current.collection };
624
+ writes.push(webhookStatement('post.updated', { post, previous: post }));
625
+ }
448
626
  }
627
+ await this.driver.batch(writes);
449
628
  await this.syncPostIndex(id);
450
629
  }
451
630
  // stage edits on a live post: only the `draft` JSON changes — live columns,
@@ -485,15 +664,584 @@ export class SqlRepository implements ContentRepository {
485
664
  "SELECT id FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
486
665
  [now]
487
666
  );
667
+ if (due.length) {
668
+ await this.driver.run(
669
+ "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, scheduled_at), scheduled_at = NULL, updated_at = ? WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
670
+ [now, now]
671
+ );
672
+ for (const r of due) await this.syncPostIndex(String(r.id));
673
+ }
674
+ return due.length + (await this.publishDueEvents(now));
675
+ }
676
+ private async publishDueEvents(now: number) {
677
+ const due = await this.driver.all(
678
+ "SELECT id FROM events WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
679
+ [now]
680
+ );
488
681
  if (!due.length) return 0;
489
682
  await this.driver.run(
490
- "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, scheduled_at), scheduled_at = NULL, updated_at = ? WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
683
+ "UPDATE events SET status = 'published', published_at = COALESCE(published_at, scheduled_at), scheduled_at = NULL, updated_at = ? WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
491
684
  [now, now]
492
685
  );
493
- for (const r of due) await this.syncPostIndex(String(r.id));
686
+ for (const r of due) await this.syncEventIndex(String(r.id));
494
687
  return due.length;
495
688
  }
496
689
 
690
+ // ---- calendars ----
691
+ async listCalendars() {
692
+ await this.ready;
693
+ const rows = await this.driver.all('SELECT * FROM calendars ORDER BY ord ASC, name COLLATE NOCASE ASC');
694
+ return rows.map(rowToCalendar);
695
+ }
696
+ async getCalendar(id: string) {
697
+ await this.ready;
698
+ const r = await this.first('SELECT * FROM calendars WHERE id = ?', [id]);
699
+ return r ? rowToCalendar(r) : null;
700
+ }
701
+ async getCalendarBySlug(slug: string) {
702
+ await this.ready;
703
+ const r = await this.first('SELECT * FROM calendars WHERE slug = ?', [slug]);
704
+ return r ? rowToCalendar(r) : null;
705
+ }
706
+ async createCalendar(data: Parameters<ContentRepository['createCalendar']>[0]) {
707
+ await this.ready;
708
+ const now = Date.now();
709
+ const id = crypto.randomUUID();
710
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM calendars');
711
+ await this.driver.run(
712
+ 'INSERT INTO calendars (id, slug, name, color, description, kind, feed_url, visibility, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
713
+ [
714
+ id, data.slug, data.name, data.color ?? '#4f46e5', data.description ?? '', data.kind ?? 'native',
715
+ data.feedUrl ?? '', data.visibility ?? 'public', randomToken(), Number(next?.n ?? 0), now, now,
716
+ ]
717
+ );
718
+ return (await this.getCalendar(id))!;
719
+ }
720
+ async updateCalendar(id: string, data: Parameters<ContentRepository['updateCalendar']>[1]) {
721
+ await this.ready;
722
+ const sets: string[] = ['updated_at = ?'];
723
+ const args: unknown[] = [Date.now()];
724
+ const cols: Record<string, string> = {
725
+ name: 'name', slug: 'slug', color: 'color', description: 'description',
726
+ feedUrl: 'feed_url', visibility: 'visibility', feedToken: 'feed_token',
727
+ };
728
+ for (const [k, col] of Object.entries(cols)) {
729
+ const v = (data as Record<string, unknown>)[k];
730
+ if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
731
+ }
732
+ args.push(id);
733
+ await this.driver.run(`UPDATE calendars SET ${sets.join(', ')} WHERE id = ?`, args);
734
+ }
735
+ async reorderCalendars(ids: string[]) {
736
+ await this.ready;
737
+ await this.driver.batch(ids.map((id, i) => ({ sql: 'UPDATE calendars SET ord = ? WHERE id = ?', args: [i, id] })));
738
+ }
739
+ async deleteCalendar(id: string) {
740
+ await this.ready;
741
+ const holders = await this.driver.all("SELECT id, calendar_ids FROM events WHERE calendar_ids LIKE ?", [`%${id}%`]);
742
+ await this.driver.batch([
743
+ { sql: 'DELETE FROM calendars WHERE id = ?', args: [id] },
744
+ ...holders.map((r) => ({
745
+ sql: 'UPDATE events SET calendar_ids = ? WHERE id = ?',
746
+ args: [JSON.stringify(json<string[]>(r.calendar_ids, []).filter((c) => c !== id)), r.id],
747
+ })),
748
+ ]);
749
+ }
750
+
751
+ // ---- events ----
752
+ async listEvents() {
753
+ await this.ready;
754
+ const rows = await this.driver.all('SELECT * FROM events ORDER BY start_at ASC');
755
+ return rows.map(rowToEvent);
756
+ }
757
+ async listPublishedEvents() {
758
+ await this.ready;
759
+ const rows = await this.driver.all("SELECT * FROM events WHERE status = 'published' ORDER BY start_at ASC");
760
+ return rows.map(rowToEvent);
761
+ }
762
+ async getEvent(id: string) {
763
+ await this.ready;
764
+ const r = await this.first('SELECT * FROM events WHERE id = ?', [id]);
765
+ return r ? rowToEvent(r) : null;
766
+ }
767
+ async getEventBySlug(slug: string) {
768
+ await this.ready;
769
+ const r = await this.first('SELECT * FROM events WHERE slug = ?', [slug]);
770
+ return r ? rowToEvent(r) : null;
771
+ }
772
+ private async siteTimezone() {
773
+ const r = await this.first('SELECT timezone FROM settings WHERE id = 1');
774
+ return String(r?.timezone ?? '');
775
+ }
776
+ async createEvent(data: Parameters<ContentRepository['createEvent']>[0]) {
777
+ await this.ready;
778
+ const now = Date.now();
779
+ const id = crypto.randomUUID();
780
+ const allDay = data.allDay ?? false;
781
+ const times = normalizeEventTimes({ start: data.start, end: data.end, allDay });
782
+ const tz = eventTimeZone({ timezone: data.timezone ?? '' }, { timezone: await this.siteTimezone() });
783
+ const { startAt, endAt } = eventInstants({ ...times, allDay }, tz);
784
+ await this.driver.run(
785
+ `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
786
+ starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
787
+ published_at, scheduled_at, created_at, updated_at, rsvp)
788
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
789
+ [
790
+ id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
791
+ data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
792
+ times.start, times.end, allDay ? 1 : 0, data.timezone ?? '', cleanRrule(data.rrule ?? ''), JSON.stringify(data.exdates ?? []),
793
+ data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
794
+ JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
795
+ data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
796
+ JSON.stringify(data.rsvp ?? {}),
797
+ ]
798
+ );
799
+ await this.syncEventIndex(id);
800
+ return (await this.getEvent(id))!;
801
+ }
802
+ async updateEvent(id: string, data: EventUpdate) {
803
+ await this.ready;
804
+ const current = await this.getEvent(id);
805
+ if (!current) return;
806
+ // a direct live write supersedes any staged edits (applyEventDraft routes
807
+ // through here too, so applying clears the snapshot in the same UPDATE)
808
+ const sets: string[] = ['updated_at = ?', 'draft = NULL'];
809
+ const args: unknown[] = [Date.now()];
810
+ const plain: Record<string, string> = {
811
+ title: 'title', slug: 'slug', bodyJson: 'body_json', bodyHtml: 'body_html', excerpt: 'excerpt',
812
+ cover: 'cover', coverAlt: 'cover_alt', seoTitle: 'seo_title', seoDesc: 'seo_desc', status: 'status',
813
+ template: 'template', timezone: 'timezone', sourceUid: 'source_uid',
814
+ publishedAt: 'published_at', scheduledAt: 'scheduled_at',
815
+ };
816
+ for (const [k, col] of Object.entries(plain)) {
817
+ const v = (data as Record<string, unknown>)[k];
818
+ if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
819
+ }
820
+ const jsonCols: Record<string, string> = {
821
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
822
+ };
823
+ for (const [k, col] of Object.entries(jsonCols)) {
824
+ const v = (data as Record<string, unknown>)[k];
825
+ if (v !== undefined) { sets.push(`${col} = ?`); args.push(JSON.stringify(v)); }
826
+ }
827
+ if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
828
+ if (data.cancelled !== undefined) { sets.push('cancelled = ?'); args.push(data.cancelled ? 1 : 0); }
829
+ if (data.rrule !== undefined) { sets.push('rrule = ?'); args.push(cleanRrule(data.rrule)); }
830
+ // first publish stamps published_at, like posts
831
+ if (data.status === 'published' && current.publishedAt === null && data.publishedAt === undefined) {
832
+ sets.push('published_at = ?'); args.push(Date.now());
833
+ }
834
+ // times: re-normalize the pair whenever any time field moves, then
835
+ // re-derive the UTC instants against the (possibly new) timezone
836
+ if (data.start !== undefined || data.end !== undefined || data.allDay !== undefined || data.timezone !== undefined) {
837
+ const allDay = data.allDay ?? current.allDay;
838
+ const times = normalizeEventTimes({ start: data.start ?? current.start, end: data.end ?? current.end, allDay });
839
+ const tz = eventTimeZone({ timezone: data.timezone ?? current.timezone }, { timezone: await this.siteTimezone() });
840
+ const { startAt, endAt } = eventInstants({ ...times, allDay }, tz);
841
+ sets.push('starts = ?', 'ends = ?', 'all_day = ?', 'start_at = ?', 'end_at = ?');
842
+ args.push(times.start, times.end, allDay ? 1 : 0, startAt, endAt);
843
+ }
844
+ args.push(id);
845
+ const changesCalendar = current.status === 'published' && ['title','start','end','allDay','timezone','location','cancelled','status'].some(k => k in data);
846
+ const cancelled = data.cancelled === true || (data.status !== undefined && data.status !== 'published');
847
+ const eventWrite = { sql:`UPDATE events SET ${sets.join(', ')} WHERE id = ?`, args };
848
+ if (changesCalendar) {
849
+ // One SQL INSERT...SELECT fans out recipient jobs without loading an
850
+ // unbounded attendee list. The update + jobs share the event batch.
851
+ const notifications = bookingJobs(id,cancelled?'cancelled':'updated').map(s=>({...s,sql:s.sql.replace('b.id = ?',`b.event_id = ? AND b.kind='rsvp' AND b.status IN (${cancelled ? "'cancelled'" : "'pending','confirmed'"})`)}));
852
+ const single = !current.rrule;
853
+ const siteTimezone = await this.siteTimezone();
854
+ await this.driver.batch([eventWrite,
855
+ {sql:`UPDATE bookings SET notification_version=notification_version+1,reminder_sent_at=NULL,
856
+ timezone=COALESCE(NULLIF((SELECT timezone FROM events WHERE id=?),''),?),
857
+ status=CASE WHEN ? THEN 'cancelled' ELSE status END,
858
+ start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
859
+ end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
860
+ WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`,args:[id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
861
+ ...notifications]);
862
+ } else await this.driver.batch([eventWrite]);
863
+ await this.syncEventIndex(id);
864
+ }
865
+ async saveEventDraft(id: string, data: Parameters<ContentRepository['saveEventDraft']>[1]) {
866
+ await this.ready;
867
+ const r = await this.first('SELECT draft FROM events WHERE id = ?', [id]);
868
+ if (!r) return;
869
+ const merged: Record<string, unknown> = parseDraft(r.draft) ?? {};
870
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
871
+ merged.savedAt = Date.now();
872
+ await this.driver.run('UPDATE events SET draft = ? WHERE id = ?', [JSON.stringify(merged), id]);
873
+ }
874
+ async applyEventDraft(id: string) {
875
+ await this.ready;
876
+ const r = await this.first('SELECT draft FROM events WHERE id = ?', [id]);
877
+ const draft = parseDraft(r?.draft);
878
+ if (!draft) return;
879
+ await this.updateEvent(id, pickEventDraft(draft));
880
+ }
881
+ async discardEventDraft(id: string) {
882
+ await this.ready;
883
+ await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
884
+ }
885
+ async deleteEvent(id: string) {
886
+ await this.ready;
887
+ const mutation = crypto.randomUUID();
888
+ const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
889
+ await this.driver.batch([
890
+ {sql:"UPDATE bookings SET status='cancelled',notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')",args:[mutation,id]},
891
+ ...notifications,
892
+ { sql: 'DELETE FROM events WHERE id = ?', args: [id] },
893
+ { sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
894
+ ]);
895
+ }
896
+
897
+
898
+ // ---- resources + booking links ----
899
+ private resourceInsert(r: Resource): SqlStatement {
900
+ return {
901
+ sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
902
+ notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
903
+ 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),
904
+ JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
905
+ r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt],
906
+ };
907
+ }
908
+ private linkInsert(l: BookingLink): SqlStatement {
909
+ return {
910
+ sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
911
+ buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
912
+ active, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
913
+ args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
914
+ l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
915
+ l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt],
916
+ };
917
+ }
918
+ private bookingInsert(b: Booking): SqlStatement {
919
+ return {
920
+ 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,
921
+ seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
922
+ 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,
923
+ b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion],
924
+ };
925
+ }
926
+ async listResources() {
927
+ await this.ready;
928
+ return (await this.driver.all('SELECT * FROM resources ORDER BY ord ASC, name COLLATE NOCASE ASC')).map(rowToResource);
929
+ }
930
+ async getResource(id: string) {
931
+ await this.ready;
932
+ const r = await this.first('SELECT * FROM resources WHERE id = ?', [id]);
933
+ return r ? rowToResource(r) : null;
934
+ }
935
+ async getResourceBySlug(slug: string) {
936
+ await this.ready;
937
+ const r = await this.first('SELECT * FROM resources WHERE slug = ?', [slug]);
938
+ return r ? rowToResource(r) : null;
939
+ }
940
+ async createResource(data: Parameters<ContentRepository['createResource']>[0]) {
941
+ await this.ready;
942
+ const now = Date.now();
943
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM resources');
944
+ const r = Resource.parse({ ...data, id: crypto.randomUUID(), feedToken: randomToken(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
945
+ await this.driver.run(this.resourceInsert(r).sql, this.resourceInsert(r).args);
946
+ return r;
947
+ }
948
+ async updateResource(id: string, data: Parameters<ContentRepository['updateResource']>[1]) {
949
+ await this.ready;
950
+ const cur = await this.getResource(id);
951
+ if (!cur) return;
952
+ const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
953
+ await this.driver.run(
954
+ `UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
955
+ overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, updated_at = ? WHERE id = ?`,
956
+ [r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
957
+ JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
958
+ r.feedToken, r.updatedAt, id]
959
+ );
960
+ }
961
+ async reorderResources(ids: string[]) {
962
+ await this.ready;
963
+ await this.driver.batch(ids.map((id, i) => ({ sql: 'UPDATE resources SET ord = ? WHERE id = ?', args: [i, id] })));
964
+ }
965
+ async deleteResource(id: string) {
966
+ await this.ready;
967
+ await this.driver.batch([
968
+ { sql: 'DELETE FROM resources WHERE id = ?', args: [id] },
969
+ { sql: 'DELETE FROM booking_links WHERE resource_id = ?', args: [id] },
970
+ ]);
971
+ }
972
+ async listBookingLinks(resourceId?: string) {
973
+ await this.ready;
974
+ const rows = resourceId
975
+ ? await this.driver.all('SELECT * FROM booking_links WHERE resource_id = ? ORDER BY ord ASC, name COLLATE NOCASE ASC', [resourceId])
976
+ : await this.driver.all('SELECT * FROM booking_links ORDER BY ord ASC, name COLLATE NOCASE ASC');
977
+ return rows.map(rowToLink2);
978
+ }
979
+ async getBookingLink(id: string) {
980
+ await this.ready;
981
+ const r = await this.first('SELECT * FROM booking_links WHERE id = ?', [id]);
982
+ return r ? rowToLink2(r) : null;
983
+ }
984
+ async getBookingLinkBySlug(resourceId: string, slug: string) {
985
+ await this.ready;
986
+ const r = await this.first('SELECT * FROM booking_links WHERE resource_id = ? AND slug = ?', [resourceId, slug]);
987
+ return r ? rowToLink2(r) : null;
988
+ }
989
+ async createBookingLink(data: Parameters<ContentRepository['createBookingLink']>[0]) {
990
+ await this.ready;
991
+ const now = Date.now();
992
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM booking_links WHERE resource_id = ?', [data.resourceId]);
993
+ const l = BookingLink.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
994
+ const st = this.linkInsert(l);
995
+ await this.driver.run(st.sql, st.args);
996
+ return l;
997
+ }
998
+ async updateBookingLink(id: string, data: Parameters<ContentRepository['updateBookingLink']>[1]) {
999
+ await this.ready;
1000
+ const cur = await this.getBookingLink(id);
1001
+ if (!cur) return;
1002
+ const l = BookingLink.parse({ ...cur, ...data, id, updatedAt: Date.now() });
1003
+ await this.driver.run(
1004
+ `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1005
+ buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1006
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ? WHERE id = ?`,
1007
+ [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1008
+ l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1009
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, id]
1010
+ );
1011
+ }
1012
+ async deleteBookingLink(id: string) {
1013
+ await this.ready;
1014
+ await this.driver.run('DELETE FROM booking_links WHERE id = ?', [id]);
1015
+ }
1016
+
1017
+ // ---- bookings ----
1018
+ async listBookings(filter: Parameters<ContentRepository['listBookings']>[0] = {}) {
1019
+ await this.ready;
1020
+ const where: string[] = [];
1021
+ const args: unknown[] = [];
1022
+ if (filter.resourceId) { where.push('resource_id = ?'); args.push(filter.resourceId); }
1023
+ if (filter.eventId) { where.push('event_id = ?'); args.push(filter.eventId); }
1024
+ if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
1025
+ if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
1026
+ if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
1027
+ if (filter.kinds?.length) { where.push(`kind IN (${filter.kinds.map(() => '?').join(', ')})`); args.push(...filter.kinds); }
1028
+ const rows = await this.driver.all(`SELECT * FROM bookings${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY start_at ASC LIMIT 5000`, args);
1029
+ return rows.map(rowToBooking);
1030
+ }
1031
+ async getBooking(id: string) {
1032
+ await this.ready;
1033
+ const r = await this.first('SELECT * FROM bookings WHERE id = ?', [id]);
1034
+ return r ? rowToBooking(r) : null;
1035
+ }
1036
+ async getBookingByToken(token: string) {
1037
+ await this.ready;
1038
+ if (!token) return null;
1039
+ const r = await this.first('SELECT * FROM bookings WHERE manage_token = ?', [token]);
1040
+ return r ? rowToBooking(r) : null;
1041
+ }
1042
+ // One INSERT … SELECT … WHERE NOT EXISTS: the overlap test and the write
1043
+ // are a single statement, so two people racing for the same slot can't
1044
+ // both win — the loser's insert simply affects no rows.
1045
+ async createBooking(data: Parameters<ContentRepository['createBooking']>[0], guard: NonNullable<Parameters<ContentRepository['createBooking']>[1]> = {}) {
1046
+ await this.ready;
1047
+ const now = Date.now();
1048
+ const b = Booking.parse({ ...data, id: crypto.randomUUID(), reminderSentAt: null, createdAt: now, updatedAt: now });
1049
+ const ins = this.bookingInsert(b);
1050
+ const cols = ins.sql.slice(ins.sql.indexOf('('), ins.sql.indexOf(')') + 1);
1051
+ const holds = b.status === 'pending' || b.status === 'confirmed';
1052
+ let write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE 1=1`, args: ins.args };
1053
+ if (b.kind === 'rsvp') {
1054
+ const cap = guard.capacity ?? 0;
1055
+ write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
1056
+ WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ('pending','confirmed')) + ? <= ?)`,
1057
+ args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
1058
+ } else if (holds && b.resourceId) {
1059
+ write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
1060
+ SELECT 1 FROM bookings WHERE resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,
1061
+ args: [...ins.args!,b.resourceId,b.holdEnd,b.holdStart] };
1062
+ }
1063
+ const ledger: SqlStatement[] = [];
1064
+ if (guard.idempotency) {
1065
+ const { keyHash, requestHash } = guard.idempotency;
1066
+ write = { sql: write.sql + ' AND NOT EXISTS (SELECT 1 FROM mobile_booking_requests WHERE key_hash=?)', args: [...write.args!, keyHash] };
1067
+ ledger.push({ sql: `INSERT INTO mobile_booking_requests (key_hash,request_hash,booking_id,created_at)
1068
+ SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
1069
+ }
1070
+ // Booking, retry ledger and email outbox commit together on D1 and libSQL.
1071
+ await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true)]);
1072
+ return this.getBooking(b.id);
1073
+ }
1074
+ async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
1075
+ await this.ready;
1076
+ const cur = await this.getBooking(id);
1077
+ if (!cur) return false;
1078
+ const nextVersion = cur.notificationVersion + 1;
1079
+ const mutation = crypto.randomUUID();
1080
+ await this.driver.batch([
1081
+ { sql: `UPDATE bookings SET start_at=?,end_at=?,hold_start=?,hold_end=?,reminder_sent_at=NULL,updated_at=?,notification_version=notification_version+1,email_mutation=?
1082
+ WHERE id=? AND notification_version=? AND status IN ('pending','confirmed') AND NOT EXISTS(SELECT 1 FROM bookings WHERE id!=? AND resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,
1083
+ args:[t.start,t.end,t.holdStart,t.holdEnd,Date.now(),mutation,id,cur.notificationVersion,id,cur.resourceId,t.holdEnd,t.holdStart] },
1084
+ ...bookingJobs(id,'rescheduled',nextVersion,false,mutation),
1085
+ ]);
1086
+ return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?',[id,mutation]);
1087
+ }
1088
+
1089
+ async updateBooking(id: string, data: Parameters<ContentRepository['updateBooking']>[1]) {
1090
+ await this.ready;
1091
+ const current = await this.getBooking(id);
1092
+ if (!current) return;
1093
+ const changed = data.status !== undefined && data.status !== current.status;
1094
+ const mutation = crypto.randomUUID();
1095
+ const sets = ['updated_at = ?', 'email_mutation = ?'];
1096
+ if (changed) sets.push('notification_version = notification_version + 1', 'reminder_sent_at = NULL');
1097
+ const args: unknown[] = [Date.now(), mutation];
1098
+ if (data.status !== undefined) { sets.push('status = ?'); args.push(data.status); }
1099
+ if (data.notes !== undefined) { sets.push('notes = ?'); args.push(data.notes); }
1100
+ if (data.name !== undefined) { sets.push('name = ?'); args.push(data.name); }
1101
+ if (data.email !== undefined) { sets.push('email = ?'); args.push(data.email); }
1102
+ if (data.phone !== undefined) { sets.push('phone = ?'); args.push(data.phone); }
1103
+ if (data.answers !== undefined) { sets.push('answers = ?'); args.push(JSON.stringify(data.answers)); }
1104
+ if (data.reminderSentAt !== undefined) { sets.push('reminder_sent_at = ?'); args.push(data.reminderSentAt); }
1105
+ args.push(id);
1106
+ const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received' : 'cancelled';
1107
+ await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?`,args:[...args,current.notificationVersion]},
1108
+ ...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : [])]);
1109
+ }
1110
+ async deleteBooking(id: string) {
1111
+ await this.ready;
1112
+ await this.driver.run('DELETE FROM bookings WHERE id = ?', [id]);
1113
+ }
1114
+ async listDueReminders(from: number, to: number) {
1115
+ await this.ready;
1116
+ const rows = await this.driver.all(
1117
+ "SELECT * FROM bookings WHERE status = 'confirmed' AND kind IN ('booking', 'rsvp') AND reminder_sent_at IS NULL AND email != '' AND start_at >= ? AND start_at < ? LIMIT 100",
1118
+ [from, to]
1119
+ );
1120
+ return rows.map(rowToBooking);
1121
+ }
1122
+
1123
+ // ---- API keys ----
1124
+ async listApiKeys() {
1125
+ await this.ready;
1126
+ const rows = await this.driver.all('SELECT * FROM api_keys ORDER BY created_at DESC');
1127
+ return rows.map(rowToApiKey);
1128
+ }
1129
+ async createApiKey(data: Parameters<ContentRepository['createApiKey']>[0]) {
1130
+ await this.ready;
1131
+ const id = crypto.randomUUID();
1132
+ await this.driver.run(
1133
+ 'INSERT INTO api_keys (id, name, prefix, key_hash, scopes, created_at) VALUES (?, ?, ?, ?, ?, ?)',
1134
+ [id, data.name, data.prefix, data.keyHash, JSON.stringify(data.scopes), Date.now()]
1135
+ );
1136
+ return rowToApiKey((await this.first('SELECT * FROM api_keys WHERE id = ?', [id]))!);
1137
+ }
1138
+ async findApiKeyByHash(keyHash: string) {
1139
+ await this.ready;
1140
+ const r = await this.first('SELECT * FROM api_keys WHERE key_hash = ?', [keyHash]);
1141
+ if (!r) return null;
1142
+ const now = Date.now();
1143
+ // at most one write a minute per key: the stamp is informational
1144
+ if (!r.last_used_at || now - Number(r.last_used_at) > 60_000) {
1145
+ await this.driver.run('UPDATE api_keys SET last_used_at = ? WHERE id = ?', [now, r.id]);
1146
+ r.last_used_at = now;
1147
+ }
1148
+ return rowToApiKey(r);
1149
+ }
1150
+ async deleteApiKey(id: string) {
1151
+ await this.ready;
1152
+ await this.driver.run('DELETE FROM api_keys WHERE id = ?', [id]);
1153
+ }
1154
+
1155
+ // ---- webhooks ----
1156
+ async listWebhooks() {
1157
+ await this.ready;
1158
+ const rows = await this.driver.all('SELECT * FROM webhooks ORDER BY created_at ASC');
1159
+ return rows.map(rowToWebhook);
1160
+ }
1161
+ async createWebhook(data: Parameters<ContentRepository['createWebhook']>[0]) {
1162
+ await this.ready;
1163
+ const id = crypto.randomUUID();
1164
+ await this.driver.run(
1165
+ 'INSERT INTO webhooks (id, url, secret, events, active, created_at) VALUES (?, ?, ?, ?, 1, ?)',
1166
+ [id, data.url, data.secret, JSON.stringify(data.events), Date.now()]
1167
+ );
1168
+ return rowToWebhook((await this.first('SELECT * FROM webhooks WHERE id = ?', [id]))!);
1169
+ }
1170
+ async updateWebhook(id: string, data: Parameters<ContentRepository['updateWebhook']>[1]) {
1171
+ await this.ready;
1172
+ const sets: string[] = [];
1173
+ const args: unknown[] = [];
1174
+ if (data.url !== undefined) { sets.push('url = ?'); args.push(data.url); }
1175
+ if (data.secret !== undefined) { sets.push('secret = ?'); args.push(data.secret); }
1176
+ if (data.events !== undefined) { sets.push('events = ?'); args.push(JSON.stringify(data.events)); }
1177
+ if (data.active !== undefined) { sets.push('active = ?'); args.push(data.active ? 1 : 0); }
1178
+ if (!sets.length) return;
1179
+ args.push(id);
1180
+ await this.driver.run(`UPDATE webhooks SET ${sets.join(', ')} WHERE id = ?`, args);
1181
+ }
1182
+ async deleteWebhook(id: string) {
1183
+ await this.ready;
1184
+ await this.driver.batch([
1185
+ { sql: 'DELETE FROM webhooks WHERE id = ?', args: [id] },
1186
+ { sql: 'DELETE FROM webhook_deliveries WHERE webhook_id = ?', args: [id] },
1187
+ ]);
1188
+ }
1189
+ async createDeliveries(rows: Parameters<ContentRepository['createDeliveries']>[0]) {
1190
+ await this.ready;
1191
+ await this.driver.batch(
1192
+ rows.map((d) => ({
1193
+ sql: 'INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, attempts, next_at, created_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
1194
+ args: [d.id, d.webhookId, d.event, d.payload, d.status, d.nextAt, d.createdAt],
1195
+ }))
1196
+ );
1197
+ }
1198
+ async listDueDeliveries(now: number, limit: number) {
1199
+ await this.ready;
1200
+ const rows = await this.driver.all(
1201
+ "SELECT * FROM webhook_deliveries WHERE status = 'pending' AND (next_at IS NULL OR next_at <= ?) ORDER BY created_at ASC LIMIT ?",
1202
+ [now, limit]
1203
+ );
1204
+ return rows.map(rowToDelivery);
1205
+ }
1206
+ async listDeliveries(webhookId: string, limit: number) {
1207
+ await this.ready;
1208
+ const rows = await this.driver.all(
1209
+ 'SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT ?',
1210
+ [webhookId, limit]
1211
+ );
1212
+ return rows.map(rowToDelivery);
1213
+ }
1214
+ async claimDelivery(id: string, now: number) {
1215
+ await this.ready;
1216
+ // A lease outlasts the HTTP timeout. Competing request/cron flushes must
1217
+ // not send the same pending delivery at the same time.
1218
+ const r = await this.first(`UPDATE webhook_deliveries SET next_at = ?
1219
+ WHERE id = ? AND status = 'pending' AND (next_at IS NULL OR next_at <= ?)
1220
+ RETURNING *`, [now + 30_000, id, now]);
1221
+ return r ? rowToDelivery(r) : null;
1222
+ }
1223
+ async recordDelivery(id: string, data: Parameters<ContentRepository['recordDelivery']>[1]) {
1224
+ await this.ready;
1225
+ const r = await this.first('SELECT webhook_id FROM webhook_deliveries WHERE id = ?', [id]);
1226
+ await this.driver.batch([
1227
+ {
1228
+ sql: 'UPDATE webhook_deliveries SET status = ?, attempts = ?, next_at = ?, response_code = ?, delivered_at = ? WHERE id = ?',
1229
+ args: [data.status, data.attempts, data.nextAt, data.responseCode, data.deliveredAt, id],
1230
+ },
1231
+ ...(r
1232
+ ? [{ sql: 'UPDATE webhooks SET last_status = ?, last_at = ? WHERE id = ?', args: [data.responseCode ?? 0, Date.now(), r.webhook_id] }]
1233
+ : []),
1234
+ ]);
1235
+ // keep the log bounded: the newest 50 deliveries per endpoint survive
1236
+ if (r) {
1237
+ await this.driver.run(
1238
+ `DELETE FROM webhook_deliveries WHERE webhook_id = ? AND status != 'pending' AND id NOT IN (
1239
+ SELECT id FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT 50)`,
1240
+ [r.webhook_id, r.webhook_id]
1241
+ );
1242
+ }
1243
+ }
1244
+
497
1245
  // ---- media ----
498
1246
  async listMedia() {
499
1247
  await this.ready;
@@ -678,13 +1426,18 @@ export class SqlRepository implements ContentRepository {
678
1426
  if (data.name !== undefined) { sets.push('name = ?'); args.push(data.name); }
679
1427
  if (data.email !== undefined) { sets.push('email = ?'); args.push(data.email.toLowerCase()); }
680
1428
  if (data.role !== undefined) { sets.push('role = ?'); args.push(data.role); }
681
- if (data.passHash !== undefined) { sets.push('pass_hash = ?'); args.push(data.passHash); }
1429
+ if (data.passHash !== undefined) { sets.push('pass_hash = ?', 'session_version = session_version + 1'); args.push(data.passHash); }
682
1430
  if (data.bio !== undefined) { sets.push('bio = ?'); args.push(data.bio); }
683
1431
  if (data.avatar !== undefined) { sets.push('avatar = ?'); args.push(data.avatar); }
684
1432
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
685
1433
  if (!sets.length) return;
686
1434
  args.push(id);
687
- await this.driver.run(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, args);
1435
+ await this.driver.batch([
1436
+ {sql:`UPDATE users SET ${sets.join(', ')} WHERE id = ?`,args},
1437
+ ...(data.passHash === undefined ? [] : [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
1438
+ SELECT ?,?,'password-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=?`,
1439
+ args:[crypto.randomUUID(),crypto.randomUUID(),Date.now(),Date.now(),Date.now(),id]}]),
1440
+ ]);
688
1441
  }
689
1442
  async deleteUser(id: string) {
690
1443
  await this.ready;
@@ -693,7 +1446,7 @@ export class SqlRepository implements ContentRepository {
693
1446
  async getUserAuth(email: string) {
694
1447
  await this.ready;
695
1448
  const r = await this.first('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
696
- return r ? { user: rowToUser(r), passHash: String(r.pass_hash) } : null;
1449
+ return r ? { user: rowToUser(r), passHash: String(r.pass_hash), sessionVersion: Number(r.session_version) } : null;
697
1450
  }
698
1451
 
699
1452
  // ---- navigation ----
@@ -737,12 +1490,13 @@ export class SqlRepository implements ContentRepository {
737
1490
  async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
738
1491
  await this.ready;
739
1492
  const trimmed = query.trim();
740
- if (!trimmed) return { posts: [], pages: [], tags: [], authors: [] };
1493
+ if (!trimmed) return { posts: [], pages: [], events: [], tags: [], authors: [] };
741
1494
  const locale = opts?.locale ?? '';
742
1495
  let postRows: Row[];
743
1496
  let pageRows: Row[];
1497
+ let eventRows: Row[];
744
1498
  try {
745
- [postRows, pageRows] = await Promise.all([
1499
+ [postRows, pageRows, eventRows] = await Promise.all([
746
1500
  this.driver.all(
747
1501
  `SELECT p.*, ${SNIPPET} AS snip FROM search_index JOIN posts p ON p.id = search_index.ref_id
748
1502
  WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'
@@ -757,11 +1511,17 @@ export class SqlRepository implements ContentRepository {
757
1511
  ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT 50`,
758
1512
  [ftsQuery(trimmed), locale]
759
1513
  ),
1514
+ this.driver.all(
1515
+ `SELECT e.*, ${SNIPPET} AS snip FROM search_index JOIN events e ON e.id = search_index.ref_id
1516
+ WHERE search_index MATCH ? AND search_index.kind = 'event' AND e.status = 'published'
1517
+ ORDER BY ${RANK} LIMIT 50`,
1518
+ [ftsQuery(trimmed)]
1519
+ ),
760
1520
  ]);
761
1521
  } catch {
762
1522
  // FTS5 unavailable or a query form it rejects — degrade to the old
763
1523
  // LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
764
- [postRows, pageRows] = await this.likeSearch(trimmed, locale);
1524
+ [postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale);
765
1525
  }
766
1526
  const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
767
1527
  const [authors, tagRows, userRows] = await Promise.all([
@@ -786,13 +1546,14 @@ export class SqlRepository implements ContentRepository {
786
1546
  snippet: safeSnippet(r.snip ?? r.excerpt ?? ''),
787
1547
  })),
788
1548
  pages: pageRows.map((r) => ({ doc: rowToPage(r), snippet: safeSnippet(r.snip ?? r.seo_desc ?? '') })),
1549
+ events: eventRows.map((r) => ({ doc: rowToEvent(r), snippet: safeSnippet(r.snip ?? r.excerpt ?? '') })),
789
1550
  tags: tags.map((tag) => ({ tag, count: tagCounts.get(tag.name.toLowerCase()) ?? 0 })),
790
1551
  authors: userRows.map(rowToUser),
791
1552
  };
792
1553
  }
793
- private async likeSearch(query: string, locale: string): Promise<[Row[], Row[]]> {
1554
+ private async likeSearch(query: string, locale: string): Promise<[Row[], Row[], Row[]]> {
794
1555
  const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
795
- if (q === '%%') return [[], []];
1556
+ if (q === '%%') return [[], [], []];
796
1557
  return Promise.all([
797
1558
  this.driver.all(
798
1559
  "SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT 50",
@@ -802,6 +1563,10 @@ export class SqlRepository implements ContentRepository {
802
1563
  "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT 50",
803
1564
  [q, q, locale]
804
1565
  ),
1566
+ this.driver.all(
1567
+ "SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT 50",
1568
+ [q, q, q]
1569
+ ),
805
1570
  ]);
806
1571
  }
807
1572
 
@@ -809,9 +1574,33 @@ export class SqlRepository implements ContentRepository {
809
1574
  async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
810
1575
  await this.ready;
811
1576
  const stmts: SqlStatement[] = [
812
- ...['posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects'].map((t) => ({
1577
+ // Suppression and re-enabling share the restore transaction. A failed
1578
+ // restore cannot leave notifications disabled or emit phantom deletes.
1579
+ { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1580
+ ...['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'].map((t) => ({
813
1581
  sql: `DELETE FROM ${t}`,
814
1582
  })),
1583
+ ...(content.calendars ?? []).map((c) => ({
1584
+ sql: 'INSERT INTO calendars (id, slug, name, color, description, kind, feed_url, visibility, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1585
+ args: [c.id, c.slug, c.name, c.color, c.description, c.kind, c.feedUrl, c.visibility, c.feedToken || randomToken(), c.order, c.createdAt, c.updatedAt],
1586
+ })),
1587
+ ...(content.events ?? []).map((e) => ({
1588
+ sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1589
+ starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1590
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp)
1591
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1592
+ args: [
1593
+ e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1594
+ JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1595
+ e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1596
+ e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1597
+ JSON.stringify(e.rsvp ?? {}),
1598
+ ],
1599
+ })),
1600
+ ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
1601
+ ...(content.bookingLinks ?? []).map((l) => this.linkInsert(l)),
1602
+ ...(content.bookings ?? []).map((b) => this.bookingInsert(b)),
1603
+ ...(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] })),
815
1604
  // p.locale defaults to '' via the zod schema, so pre-v20 backups
816
1605
  // restore cleanly as default-locale pages
817
1606
  ...content.pages.map((p) => ({
@@ -848,16 +1637,22 @@ export class SqlRepository implements ContentRepository {
848
1637
  })),
849
1638
  // accounts restore without hashes — sign in via ADMIN_PASSWORD and reset
850
1639
  ...content.users.map((u) => ({
851
- sql: 'INSERT INTO users (id, name, email, role, pass_hash, bio, avatar, slug, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
852
- args: [u.id, u.name, u.email, u.role, '', u.bio, u.avatar, u.slug, u.createdAt],
1640
+ sql: 'INSERT INTO users (id, name, email, role, pass_hash, bio, avatar, slug, created_at, session_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1641
+ args: [u.id, u.name, u.email, u.role, '', u.bio, u.avatar, u.slug, u.createdAt, Date.now()],
853
1642
  })),
854
1643
  ...content.redirects.map((r) => ({
855
1644
  sql: 'INSERT INTO redirects (id, from_path, to_path, code, created_at) VALUES (?, ?, ?, ?, ?)',
856
1645
  args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
857
1646
  })),
858
1647
  ];
1648
+ const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
1649
+ stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
1650
+ if (settingsWrite) stmts.push(settingsWrite);
1651
+ stmts.push(
1652
+ { sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' },
1653
+ webhookStatement('site.restored', { counts: { posts: content.posts.length, pages: content.pages.length, media: content.media.length } }),
1654
+ );
859
1655
  await this.driver.batch(stmts);
860
- await this.updateSettings(content.settings);
861
1656
  await this.reindexSearch();
862
1657
  }
863
1658
 
@@ -900,12 +1695,16 @@ export class SqlRepository implements ContentRepository {
900
1695
  siteTitle: r?.site_title ?? 'My Site', tagline: r?.tagline ?? '', footerText: r?.footer_text ?? '',
901
1696
  linksName: r?.links_name ?? '', linksBio: r?.links_bio ?? '', linksAvatar: r?.links_avatar ?? '',
902
1697
  activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
1698
+ sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
903
1699
  siteLogo: r?.site_logo ?? '',
904
1700
  siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
905
1701
  announcement: JSON.parse(String(r?.announcement ?? '{}')),
906
1702
  analytics: JSON.parse(String(r?.analytics ?? '{}')),
907
1703
  headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
908
1704
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
1705
+ ai: JSON.parse(String(r?.ai ?? '{}')),
1706
+ live: JSON.parse(String(r?.live ?? '{}')),
1707
+ agent: JSON.parse(String(r?.agent ?? '{}')),
909
1708
  draftsApiKey: r?.drafts_api_key ?? '',
910
1709
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
911
1710
  draftDesign: parseDraft(r?.draft_design),
@@ -913,57 +1712,91 @@ export class SqlRepository implements ContentRepository {
913
1712
  }
914
1713
  async updateSettings(data: Partial<Settings>) {
915
1714
  await this.ready;
916
- const current = await this.getSettings();
917
- // draft_design: undefined = leave the staged design alone (a settings
918
- // save must not clobber it); null/value = explicit write (restore)
919
- const draftDesign =
920
- data.draftDesign === undefined ? current.draftDesign : data.draftDesign;
921
- await this.driver.run(
922
- 'UPDATE settings SET site_title = ?, tagline = ?, footer_text = ?, links_name = ?, links_bio = ?, links_avatar = ?, active_theme = ?, design_overrides = ?, site_logo = ?, site_url = ?, site_favicon = ?, site_cover = ?, announcement = ?, analytics = ?, head_code = ?, footer_code = ?, timezone = ?, locale = ?, drafts_api_key = ?, drafts_allowed_origins = ?, draft_design = ? WHERE id = 1',
923
- [
924
- data.siteTitle ?? current.siteTitle, data.tagline ?? current.tagline,
925
- data.footerText ?? current.footerText,
926
- data.linksName ?? current.linksName, data.linksBio ?? current.linksBio,
927
- data.linksAvatar ?? current.linksAvatar,
928
- data.activeTheme ?? current.activeTheme,
929
- JSON.stringify(data.designOverrides ?? current.designOverrides),
930
- data.siteLogo ?? current.siteLogo,
931
- data.siteUrl ?? current.siteUrl,
932
- data.siteFavicon ?? current.siteFavicon,
933
- data.siteCover ?? current.siteCover,
934
- JSON.stringify(data.announcement ?? current.announcement),
935
- JSON.stringify(data.analytics ?? current.analytics),
936
- data.headCode ?? current.headCode,
937
- data.footerCode ?? current.footerCode,
938
- data.timezone ?? current.timezone,
939
- data.locale ?? current.locale,
940
- data.draftsApiKey ?? current.draftsApiKey,
941
- JSON.stringify(data.draftsAllowedOrigins ?? current.draftsAllowedOrigins),
942
- draftDesign ? JSON.stringify(draftDesign) : null,
943
- ]
944
- );
1715
+ const statement = this.settingsStatement(data);
1716
+ if (statement) await this.driver.run(statement.sql, statement.args);
1717
+ }
1718
+ private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
1719
+ const columns: Record<string, string> = {
1720
+ siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1721
+ activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1722
+ announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1723
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
1724
+ };
1725
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
1726
+ const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1727
+ if (!entries.length) return;
1728
+ const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
1729
+ return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
1730
+ args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
1731
+ }
1732
+ private async designWrite(expected: number, actor: string, action: string, statements: SqlStatement[]) {
1733
+ try {
1734
+ await this.driver.batch([
1735
+ { sql: 'INSERT INTO design_audit (id,created_at,actor,action,expected_revision,actual_revision) SELECT ?,?,?,?,?,design_revision FROM settings WHERE id = 1', args: [crypto.randomUUID(), Date.now(), actor, action, expected] },
1736
+ ...statements,
1737
+ { sql: 'UPDATE settings SET design_revision = design_revision + 1 WHERE id = 1' },
1738
+ ]);
1739
+ } catch (e) {
1740
+ if (/CHECK constraint|expected_revision/i.test(String(e))) throw new ApiError(409, 'Design changed. Pull the latest revision before saving.');
1741
+ throw e;
1742
+ }
1743
+ }
1744
+ async listDesignAudit() {
1745
+ await this.ready;
1746
+ return this.driver.all('SELECT id,created_at,actor,action,actual_revision FROM design_audit ORDER BY created_at DESC LIMIT 100');
945
1747
  }
946
1748
  // Design-screen staging: only the draft_design JSON changes on save — the
947
1749
  // live design columns wait for applyDesignDraft ("Publish design").
948
1750
  async saveDesignDraft(data: Parameters<ContentRepository['saveDesignDraft']>[0]) {
949
- await this.ready;
950
- const r = await this.first('SELECT draft_design FROM settings WHERE id = 1');
951
- const merged: Record<string, unknown> = parseDraft(r?.draft_design) ?? {};
952
- for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
953
- merged.savedAt = Date.now();
954
- await this.driver.run('UPDATE settings SET draft_design = ? WHERE id = 1', [JSON.stringify(merged)]);
1751
+ const current = await this.getSettings();
1752
+ const merged = { ...current.draftDesign, ...data };
1753
+ // Preserve a valid identity only while editing this same draft.
1754
+ merged.shareEpoch = typeof current.draftDesign?.shareEpoch === 'string' ? current.draftDesign.shareEpoch : crypto.randomUUID();
1755
+ if (data.shareEpoch) merged.shareEpoch = data.shareEpoch;
1756
+ await this.setDesignDraft(merged, current.designRevision);
1757
+ }
1758
+ async setDesignDraft(draft: Record<string, unknown> | null, expectedRevision?: number, actor = 'admin') {
1759
+ const current = await this.getSettings();
1760
+ const value = draft ? { ...draft, shareEpoch: typeof draft.shareEpoch === 'string' ? draft.shareEpoch : crypto.randomUUID(), savedAt: Date.now() } : null;
1761
+ await this.designWrite(expectedRevision ?? current.designRevision, actor, value ? 'stage' : 'discard', [
1762
+ { sql: 'UPDATE settings SET draft_design = ? WHERE id = 1', args: [value ? JSON.stringify(value) : null] },
1763
+ ]);
955
1764
  }
956
- async applyDesignDraft() {
1765
+ async applyDesignDraft(opts: { author?: string; summary?: string; expectedRevision?: number } = {}) {
1766
+ const current = await this.getSettings();
1767
+ const draft = current.draftDesign;
1768
+ if (!draft) {
1769
+ if (opts.expectedRevision !== undefined && opts.expectedRevision !== current.designRevision) throw new ApiError(409, 'Design changed. Pull the latest revision.');
1770
+ return;
1771
+ }
1772
+ const nav = draftNavItems(draft);
1773
+ const liveNav = await this.listNavItems();
1774
+ const design = { ...current, ...pickDesignDraft(draft) };
1775
+ const statements: SqlStatement[] = [
1776
+ { sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
1777
+ { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1778
+ { sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
1779
+ args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteFavicon, design.siteCover] },
1780
+ ];
1781
+ if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
1782
+ sql: 'INSERT INTO nav_items (id,label,url,area,ord) VALUES (?,?,?,?,?)', args: [n.id.startsWith('tmp-') ? crypto.randomUUID() : n.id,n.label,n.url,n.area,i],
1783
+ })));
1784
+ statements.push(webhookStatement('design.published', { design: {
1785
+ revision: current.designRevision + 1, activeTheme: design.activeTheme,
1786
+ navigationChanged: nav !== null, sectionPageIds: Object.keys(design.sectionPages),
1787
+ } }));
1788
+ await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
1789
+ }
1790
+ async discardDesignDraft() { await this.setDesignDraft(null); }
1791
+ async listDesignVersions(limit = 20) {
957
1792
  await this.ready;
958
- const r = await this.first('SELECT draft_design FROM settings WHERE id = 1');
959
- const draft = parseDraft(r?.draft_design);
960
- if (!draft) return;
961
- await this.updateSettings(pickDesignDraft(draft));
962
- await this.discardDesignDraft();
1793
+ const rows = await this.driver.all('SELECT * FROM design_versions ORDER BY created_at DESC LIMIT ?', [limit]);
1794
+ return rows.map(designVersionRow);
963
1795
  }
964
- async discardDesignDraft() {
1796
+ async getDesignVersion(id: string) {
965
1797
  await this.ready;
966
- await this.driver.run('UPDATE settings SET draft_design = NULL WHERE id = 1');
1798
+ const r = await this.first('SELECT * FROM design_versions WHERE id = ?', [id]);
1799
+ return r ? designVersionRow(r) : null;
967
1800
  }
968
1801
  async getStats(): Promise<Stats> {
969
1802
  await this.ready;
@@ -980,3 +1813,9 @@ export class SqlRepository implements ContentRepository {
980
1813
  };
981
1814
  }
982
1815
  }
1816
+
1817
+ function designVersionRow(r: Row): DesignVersion {
1818
+ let snapshot: Record<string, unknown> = {};
1819
+ try { snapshot = JSON.parse(String(r.snapshot ?? '{}')); } catch { /* corrupt row: empty snapshot */ }
1820
+ return { id: String(r.id), createdAt: Number(r.created_at), author: String(r.author ?? ''), summary: String(r.summary ?? ''), snapshot };
1821
+ }