@acms-subir/core 0.1.0 → 0.3.0

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 (237) hide show
  1. package/CHANGELOG.md +106 -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 -315
  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/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -1,5 +1,10 @@
1
- import type { ContentRepository, SearchResults, Stats } from './types';
2
- import { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
1
+ import { ApiError } from '../api';
2
+ import { EmailStore, bookingJobs } from '../email/store';
3
+ import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
4
+ 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';
5
+ import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
6
+ import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
7
+ import { cleanRrule } from '../calendar/rrule';
3
8
  import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
4
9
  import { htmlToPlaintext } from '../plaintext';
5
10
  import { SEED } from '../brand';
@@ -24,6 +29,17 @@ export interface SqlDriver {
24
29
  batch(stmts: SqlStatement[]): Promise<void>;
25
30
  }
26
31
 
32
+ // a corrupt snapshot must never make the whole row unreadable
33
+ function parseDraft(raw: unknown): Record<string, unknown> | null {
34
+ if (!raw) return null;
35
+ try {
36
+ const d = JSON.parse(String(raw));
37
+ return d && typeof d === 'object' && !Array.isArray(d) ? d : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
27
43
  function rowToPage(r: Row): Page {
28
44
  return Page.parse({
29
45
  id: r.id, slug: r.slug, title: r.title, status: r.status,
@@ -31,7 +47,7 @@ function rowToPage(r: Row): Page {
31
47
  blocks: JSON.parse(String(r.blocks ?? '[]')),
32
48
  bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
33
49
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
34
- template: r.template ?? '',
50
+ template: r.template ?? '', draft: parseDraft(r.draft),
35
51
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
36
52
  });
37
53
  }
@@ -43,12 +59,17 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
43
59
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
44
60
  featured: Boolean(r.featured), authorId: r.author_id ?? null,
45
61
  authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
46
- template: r.template ?? '',
62
+ template: r.template ?? '', draft: parseDraft(r.draft),
47
63
  publishedAt: r.published_at === null ? null : Number(r.published_at),
48
64
  scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
49
65
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
50
66
  });
51
67
  }
68
+ function rowToSnippet(r: Row): Snippet {
69
+ return Snippet.parse({
70
+ id: r.id, name: r.name, contentJson: r.content_json ?? '[]', createdAt: Number(r.created_at),
71
+ });
72
+ }
52
73
  function rowToTag(r: Row): Tag {
53
74
  return Tag.parse({ id: r.id, name: r.name, slug: r.slug, description: r.description ?? '' });
54
75
  }
@@ -75,6 +96,99 @@ function rowToLink(r: Row): Link {
75
96
  });
76
97
  }
77
98
 
99
+ const json = <T>(raw: unknown, fallback: T): T => {
100
+ try {
101
+ return raw === null || raw === undefined || raw === '' ? fallback : (JSON.parse(String(raw)) as T);
102
+ } catch {
103
+ return fallback;
104
+ }
105
+ };
106
+ function rowToCalendar(r: Row): Calendar {
107
+ return Calendar.parse({
108
+ id: r.id, slug: r.slug, name: r.name, color: r.color ?? '#4f46e5', description: r.description ?? '',
109
+ kind: r.kind ?? 'native', feedUrl: r.feed_url ?? '', visibility: r.visibility ?? 'public',
110
+ feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
111
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
112
+ });
113
+ }
114
+ function rowToEvent(r: Row): CalEvent {
115
+ return CalEvent.parse({
116
+ id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
117
+ excerpt: r.excerpt ?? '', cover: r.cover ?? '', coverAlt: r.cover_alt ?? '',
118
+ seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
119
+ featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
120
+ start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
121
+ rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
122
+ location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
123
+ fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
124
+ startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft),
125
+ publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
126
+ scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
127
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
128
+ });
129
+ }
130
+ function rowToResource(r: Row): Resource {
131
+ return Resource.parse({
132
+ id: r.id, slug: r.slug, name: r.name, kind: r.kind ?? 'Room', description: r.description ?? '', photo: r.photo ?? '',
133
+ capacity: r.capacity === null || r.capacity === undefined ? null : Number(r.capacity),
134
+ amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
135
+ busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
136
+ showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
137
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
138
+ });
139
+ }
140
+ function rowToLink2(r: Row): BookingLink {
141
+ return BookingLink.parse({
142
+ id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
143
+ duration: Number(r.duration), lengths: json(r.lengths, []), minDays: Number(r.min_days), maxDays: Number(r.max_days),
144
+ step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
145
+ minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
146
+ hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
147
+ askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
148
+ redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
149
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
150
+ });
151
+ }
152
+ function rowToBooking(r: Row): Booking {
153
+ return Booking.parse({
154
+ id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
155
+ status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
156
+ timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
157
+ answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
158
+ notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
159
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
160
+ });
161
+ }
162
+ function rowToApiKey(r: Row): ApiKey {
163
+ return ApiKey.parse({
164
+ id: r.id, name: r.name, prefix: r.prefix, scopes: json(r.scopes, []),
165
+ lastUsedAt: r.last_used_at === null || r.last_used_at === undefined ? null : Number(r.last_used_at),
166
+ createdAt: Number(r.created_at),
167
+ });
168
+ }
169
+ function rowToWebhook(r: Row): Webhook {
170
+ return Webhook.parse({
171
+ id: r.id, url: r.url, secret: r.secret, events: json(r.events, []), active: Boolean(r.active),
172
+ lastStatus: r.last_status === null || r.last_status === undefined ? null : Number(r.last_status),
173
+ lastAt: r.last_at === null || r.last_at === undefined ? null : Number(r.last_at),
174
+ createdAt: Number(r.created_at),
175
+ });
176
+ }
177
+ function rowToDelivery(r: Row): WebhookDelivery {
178
+ const n = (v: unknown) => (v === null || v === undefined ? null : Number(v));
179
+ return WebhookDelivery.parse({
180
+ id: r.id, webhookId: r.webhook_id, event: r.event, payload: r.payload, status: r.status,
181
+ attempts: Number(r.attempts ?? 0), nextAt: n(r.next_at), responseCode: n(r.response_code),
182
+ createdAt: Number(r.created_at), deliveredAt: n(r.delivered_at),
183
+ });
184
+ }
185
+
186
+ // URL-safe secret for private calendar feeds
187
+ function randomToken(): string {
188
+ const bytes = crypto.getRandomValues(new Uint8Array(18));
189
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
190
+ }
191
+
78
192
  // FTS5 MATCH expression from user input: each whitespace token becomes a
79
193
  // quoted phrase (operators like AND / OR / - stay inert), the last one gets
80
194
  // a * for search-as-you-type prefix matching.
@@ -100,9 +214,11 @@ function safeSnippet(raw: unknown): string {
100
214
  export class SqlRepository implements ContentRepository {
101
215
  private driver!: SqlDriver;
102
216
  private ready: Promise<void>;
217
+ readonly email: EmailStore;
103
218
 
104
219
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
105
220
  this.ready = this.init(driver);
221
+ this.email = new EmailStore(async () => { await this.ready; return this.driver; });
106
222
  }
107
223
 
108
224
  private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
@@ -198,6 +314,29 @@ export class SqlRepository implements ContentRepository {
198
314
  }
199
315
  return stmts;
200
316
  }
317
+ private eventIndexStatements(r: Row): SqlStatement[] {
318
+ const stmts: SqlStatement[] = [
319
+ { sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [r.id] },
320
+ ];
321
+ if (r.status === 'published') {
322
+ const loc = json<{ name?: string; address?: string }>(r.location, {});
323
+ stmts.push({
324
+ sql: "INSERT INTO search_index (kind, ref_id, locale, title, excerpt, plaintext, tags) VALUES ('event', ?, '', ?, ?, ?, ?)",
325
+ args: [
326
+ r.id, r.title, r.excerpt ?? '',
327
+ [htmlToPlaintext(String(r.body_html ?? '')), loc.name ?? '', loc.address ?? ''].filter(Boolean).join(' '),
328
+ publicTags(json<string[]>(r.tags, [])).join(' '),
329
+ ],
330
+ });
331
+ }
332
+ return stmts;
333
+ }
334
+ private async syncEventIndex(id: string) {
335
+ const r = await this.first('SELECT id, status, title, excerpt, tags, body_html, location FROM events WHERE id = ?', [id]);
336
+ await this.driver.batch(
337
+ r ? this.eventIndexStatements(r) : [{ sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] }]
338
+ );
339
+ }
201
340
  private async syncPostIndex(id: string) {
202
341
  const r = await this.first('SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE id = ?', [id]);
203
342
  await this.driver.batch(
@@ -212,17 +351,55 @@ export class SqlRepository implements ContentRepository {
212
351
  }
213
352
  // full rebuild: the v21 backfill, restores, seeds
214
353
  private async reindexSearch() {
215
- const [posts, pages] = await Promise.all([
354
+ const [posts, pages, events] = await Promise.all([
216
355
  this.driver.all("SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE status = 'published'"),
217
356
  this.driver.all("SELECT id, status, locale, title, seo_desc, body_html FROM pages WHERE status = 'published'"),
357
+ this.driver.all("SELECT id, status, title, excerpt, tags, body_html, location FROM events WHERE status = 'published'"),
218
358
  ]);
219
359
  await this.driver.batch([
220
360
  { sql: 'DELETE FROM search_index' },
221
361
  ...posts.flatMap((r) => this.postIndexStatements(r)),
222
362
  ...pages.flatMap((r) => this.pageIndexStatements(r)),
363
+ ...events.flatMap((r) => this.eventIndexStatements(r)),
223
364
  ]);
224
365
  }
225
366
 
367
+ async listPublicContent(q: Parameters<ContentRepository['listPublicContent']>[0]) {
368
+ await this.ready;
369
+ const collections = q.collection ? [q.collection] : q.collections;
370
+ const rows = await this.driver.all(`SELECT id, kind FROM (
371
+ SELECT id, 'post' AS kind, COALESCE(published_at, created_at) AS sort_at FROM posts
372
+ WHERE status='published' AND collection IN (${collections.map(() => '?').join(',') || "''"})
373
+ AND ? != 'page' AND (? IS NULL OR ? = ?)
374
+ UNION ALL
375
+ SELECT id, 'page' AS kind, created_at AS sort_at FROM pages WHERE status='published'
376
+ AND ? != 'post' AND ? IS NULL AND (? IS NULL OR COALESCE(NULLIF(locale,''),?) = ?)
377
+ ) ORDER BY sort_at DESC, kind ASC, id ASC LIMIT ? OFFSET ?`,
378
+ [...collections, q.kind ?? '', q.locale ?? null, q.locale ?? null, q.defaultLocale,
379
+ q.kind ?? '', q.collection ?? null, q.locale ?? null, q.defaultLocale, q.locale ?? null,
380
+ q.limit + 1, (q.page - 1) * q.limit]);
381
+ return rows.map(r => ({ id: String(r.id), kind: r.kind === 'post' ? 'post' as const : 'page' as const }));
382
+ }
383
+
384
+ async exportBookingState() {
385
+ await this.ready;
386
+ // One SELECT snapshots bookings and their retry ledger together. No UI
387
+ // list limit; include tombstones for deleted bookings as well.
388
+ 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'];
389
+ const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
390
+ UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
391
+ return {
392
+ bookings: rows.filter(r => r.type === 'booking').map(r => rowToBooking(JSON.parse(String(r.data)))),
393
+ bookingRequests: rows.filter(r => r.type === 'request').map(r => JSON.parse(String(r.data)) as import('../backup/content').BookingRequest),
394
+ };
395
+ }
396
+
397
+ async getBookingRequest(keyHash: string) {
398
+ await this.ready;
399
+ const r = await this.first('SELECT request_hash, booking_id FROM mobile_booking_requests WHERE key_hash=?', [keyHash]);
400
+ return r ? { requestHash: String(r.request_hash), bookingId: String(r.booking_id) } : null;
401
+ }
402
+
226
403
  // ---- pages ----
227
404
  async listPages() {
228
405
  await this.ready;
@@ -258,7 +435,9 @@ export class SqlRepository implements ContentRepository {
258
435
  }
259
436
  async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
260
437
  await this.ready;
261
- const sets: string[] = ['updated_at = ?'];
438
+ // a direct live write supersedes any staged edits (applyPageDraft routes
439
+ // through here too, so applying clears the snapshot in the same UPDATE)
440
+ const sets: string[] = ['updated_at = ?', 'draft = NULL'];
262
441
  const args: unknown[] = [Date.now()];
263
442
  if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
264
443
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
@@ -286,6 +465,29 @@ export class SqlRepository implements ContentRepository {
286
465
  await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
287
466
  await this.syncPageIndex(id);
288
467
  }
468
+ // stage edits on a live page: only the `draft` JSON changes — live columns,
469
+ // updated_at (public auto-nav order) and the search index stay untouched
470
+ async savePageDraft(id: string, data: Parameters<ContentRepository['savePageDraft']>[1]) {
471
+ await this.ready;
472
+ const r = await this.first('SELECT draft FROM pages WHERE id = ?', [id]);
473
+ if (!r) return;
474
+ const merged: Record<string, unknown> = parseDraft(r.draft) ?? {};
475
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
476
+ merged.savedAt = Date.now();
477
+ await this.driver.run('UPDATE pages SET draft = ? WHERE id = ?', [JSON.stringify(merged), id]);
478
+ }
479
+ async applyPageDraft(id: string) {
480
+ await this.ready;
481
+ const r = await this.first('SELECT draft FROM pages WHERE id = ?', [id]);
482
+ const draft = parseDraft(r?.draft);
483
+ if (!draft) return;
484
+ // updatePage handles is_home displacement, FTS sync and clears the draft
485
+ await this.updatePage(id, pickPageDraft(draft));
486
+ }
487
+ async discardPageDraft(id: string) {
488
+ await this.ready;
489
+ await this.driver.run('UPDATE pages SET draft = NULL WHERE id = ?', [id]);
490
+ }
289
491
  async deletePage(id: string) {
290
492
  await this.ready;
291
493
  await this.driver.batch([
@@ -356,7 +558,9 @@ export class SqlRepository implements ContentRepository {
356
558
  await this.ready;
357
559
  const current = await this.getPost(id);
358
560
  if (!current) return;
359
- const sets: string[] = ['updated_at = ?'];
561
+ // a direct live write supersedes any staged edits (applyPostDraft routes
562
+ // through here too, so applying clears the snapshot in the same UPDATE)
563
+ const sets: string[] = ['updated_at = ?', 'draft = NULL'];
360
564
  const args: unknown[] = [Date.now()];
361
565
  if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
362
566
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
@@ -404,6 +608,29 @@ export class SqlRepository implements ContentRepository {
404
608
  }
405
609
  await this.syncPostIndex(id);
406
610
  }
611
+ // stage edits on a live post: only the `draft` JSON changes — live columns,
612
+ // author joins and the search index stay untouched until Update
613
+ async savePostDraft(id: string, data: Parameters<ContentRepository['savePostDraft']>[1]) {
614
+ await this.ready;
615
+ const r = await this.first('SELECT draft FROM posts WHERE id = ?', [id]);
616
+ if (!r) return;
617
+ const merged: Record<string, unknown> = parseDraft(r.draft) ?? {};
618
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
619
+ merged.savedAt = Date.now();
620
+ await this.driver.run('UPDATE posts SET draft = ? WHERE id = ?', [JSON.stringify(merged), id]);
621
+ }
622
+ async applyPostDraft(id: string) {
623
+ await this.ready;
624
+ const r = await this.first('SELECT draft FROM posts WHERE id = ?', [id]);
625
+ const draft = parseDraft(r?.draft);
626
+ if (!draft) return;
627
+ // updatePost handles author joins, FTS sync and clears the draft
628
+ await this.updatePost(id, pickPostDraft(draft));
629
+ }
630
+ async discardPostDraft(id: string) {
631
+ await this.ready;
632
+ await this.driver.run('UPDATE posts SET draft = NULL WHERE id = ?', [id]);
633
+ }
407
634
  async deletePost(id: string) {
408
635
  await this.ready;
409
636
  await this.driver.batch([
@@ -418,15 +645,575 @@ export class SqlRepository implements ContentRepository {
418
645
  "SELECT id FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
419
646
  [now]
420
647
  );
648
+ if (due.length) {
649
+ await this.driver.run(
650
+ "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 <= ?",
651
+ [now, now]
652
+ );
653
+ for (const r of due) await this.syncPostIndex(String(r.id));
654
+ }
655
+ return due.length + (await this.publishDueEvents(now));
656
+ }
657
+ private async publishDueEvents(now: number) {
658
+ const due = await this.driver.all(
659
+ "SELECT id FROM events WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
660
+ [now]
661
+ );
421
662
  if (!due.length) return 0;
422
663
  await this.driver.run(
423
- "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 <= ?",
664
+ "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 <= ?",
424
665
  [now, now]
425
666
  );
426
- for (const r of due) await this.syncPostIndex(String(r.id));
667
+ for (const r of due) await this.syncEventIndex(String(r.id));
427
668
  return due.length;
428
669
  }
429
670
 
671
+ // ---- calendars ----
672
+ async listCalendars() {
673
+ await this.ready;
674
+ const rows = await this.driver.all('SELECT * FROM calendars ORDER BY ord ASC, name COLLATE NOCASE ASC');
675
+ return rows.map(rowToCalendar);
676
+ }
677
+ async getCalendar(id: string) {
678
+ await this.ready;
679
+ const r = await this.first('SELECT * FROM calendars WHERE id = ?', [id]);
680
+ return r ? rowToCalendar(r) : null;
681
+ }
682
+ async getCalendarBySlug(slug: string) {
683
+ await this.ready;
684
+ const r = await this.first('SELECT * FROM calendars WHERE slug = ?', [slug]);
685
+ return r ? rowToCalendar(r) : null;
686
+ }
687
+ async createCalendar(data: Parameters<ContentRepository['createCalendar']>[0]) {
688
+ await this.ready;
689
+ const now = Date.now();
690
+ const id = crypto.randomUUID();
691
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM calendars');
692
+ await this.driver.run(
693
+ 'INSERT INTO calendars (id, slug, name, color, description, kind, feed_url, visibility, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
694
+ [
695
+ id, data.slug, data.name, data.color ?? '#4f46e5', data.description ?? '', data.kind ?? 'native',
696
+ data.feedUrl ?? '', data.visibility ?? 'public', randomToken(), Number(next?.n ?? 0), now, now,
697
+ ]
698
+ );
699
+ return (await this.getCalendar(id))!;
700
+ }
701
+ async updateCalendar(id: string, data: Parameters<ContentRepository['updateCalendar']>[1]) {
702
+ await this.ready;
703
+ const sets: string[] = ['updated_at = ?'];
704
+ const args: unknown[] = [Date.now()];
705
+ const cols: Record<string, string> = {
706
+ name: 'name', slug: 'slug', color: 'color', description: 'description',
707
+ feedUrl: 'feed_url', visibility: 'visibility', feedToken: 'feed_token',
708
+ };
709
+ for (const [k, col] of Object.entries(cols)) {
710
+ const v = (data as Record<string, unknown>)[k];
711
+ if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
712
+ }
713
+ args.push(id);
714
+ await this.driver.run(`UPDATE calendars SET ${sets.join(', ')} WHERE id = ?`, args);
715
+ }
716
+ async reorderCalendars(ids: string[]) {
717
+ await this.ready;
718
+ await this.driver.batch(ids.map((id, i) => ({ sql: 'UPDATE calendars SET ord = ? WHERE id = ?', args: [i, id] })));
719
+ }
720
+ async deleteCalendar(id: string) {
721
+ await this.ready;
722
+ const holders = await this.driver.all("SELECT id, calendar_ids FROM events WHERE calendar_ids LIKE ?", [`%${id}%`]);
723
+ await this.driver.batch([
724
+ { sql: 'DELETE FROM calendars WHERE id = ?', args: [id] },
725
+ ...holders.map((r) => ({
726
+ sql: 'UPDATE events SET calendar_ids = ? WHERE id = ?',
727
+ args: [JSON.stringify(json<string[]>(r.calendar_ids, []).filter((c) => c !== id)), r.id],
728
+ })),
729
+ ]);
730
+ }
731
+
732
+ // ---- events ----
733
+ async listEvents() {
734
+ await this.ready;
735
+ const rows = await this.driver.all('SELECT * FROM events ORDER BY start_at ASC');
736
+ return rows.map(rowToEvent);
737
+ }
738
+ async listPublishedEvents() {
739
+ await this.ready;
740
+ const rows = await this.driver.all("SELECT * FROM events WHERE status = 'published' ORDER BY start_at ASC");
741
+ return rows.map(rowToEvent);
742
+ }
743
+ async getEvent(id: string) {
744
+ await this.ready;
745
+ const r = await this.first('SELECT * FROM events WHERE id = ?', [id]);
746
+ return r ? rowToEvent(r) : null;
747
+ }
748
+ async getEventBySlug(slug: string) {
749
+ await this.ready;
750
+ const r = await this.first('SELECT * FROM events WHERE slug = ?', [slug]);
751
+ return r ? rowToEvent(r) : null;
752
+ }
753
+ private async siteTimezone() {
754
+ const r = await this.first('SELECT timezone FROM settings WHERE id = 1');
755
+ return String(r?.timezone ?? '');
756
+ }
757
+ async createEvent(data: Parameters<ContentRepository['createEvent']>[0]) {
758
+ await this.ready;
759
+ const now = Date.now();
760
+ const id = crypto.randomUUID();
761
+ const allDay = data.allDay ?? false;
762
+ const times = normalizeEventTimes({ start: data.start, end: data.end, allDay });
763
+ const tz = eventTimeZone({ timezone: data.timezone ?? '' }, { timezone: await this.siteTimezone() });
764
+ const { startAt, endAt } = eventInstants({ ...times, allDay }, tz);
765
+ await this.driver.run(
766
+ `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
767
+ starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
768
+ published_at, scheduled_at, created_at, updated_at, rsvp)
769
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
770
+ [
771
+ id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
772
+ data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
773
+ times.start, times.end, allDay ? 1 : 0, data.timezone ?? '', cleanRrule(data.rrule ?? ''), JSON.stringify(data.exdates ?? []),
774
+ data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
775
+ JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
776
+ data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
777
+ JSON.stringify(data.rsvp ?? {}),
778
+ ]
779
+ );
780
+ await this.syncEventIndex(id);
781
+ return (await this.getEvent(id))!;
782
+ }
783
+ async updateEvent(id: string, data: EventUpdate) {
784
+ await this.ready;
785
+ const current = await this.getEvent(id);
786
+ if (!current) return;
787
+ // a direct live write supersedes any staged edits (applyEventDraft routes
788
+ // through here too, so applying clears the snapshot in the same UPDATE)
789
+ const sets: string[] = ['updated_at = ?', 'draft = NULL'];
790
+ const args: unknown[] = [Date.now()];
791
+ const plain: Record<string, string> = {
792
+ title: 'title', slug: 'slug', bodyJson: 'body_json', bodyHtml: 'body_html', excerpt: 'excerpt',
793
+ cover: 'cover', coverAlt: 'cover_alt', seoTitle: 'seo_title', seoDesc: 'seo_desc', status: 'status',
794
+ template: 'template', timezone: 'timezone', sourceUid: 'source_uid',
795
+ publishedAt: 'published_at', scheduledAt: 'scheduled_at',
796
+ };
797
+ for (const [k, col] of Object.entries(plain)) {
798
+ const v = (data as Record<string, unknown>)[k];
799
+ if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
800
+ }
801
+ const jsonCols: Record<string, string> = {
802
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
803
+ };
804
+ for (const [k, col] of Object.entries(jsonCols)) {
805
+ const v = (data as Record<string, unknown>)[k];
806
+ if (v !== undefined) { sets.push(`${col} = ?`); args.push(JSON.stringify(v)); }
807
+ }
808
+ if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
809
+ if (data.cancelled !== undefined) { sets.push('cancelled = ?'); args.push(data.cancelled ? 1 : 0); }
810
+ if (data.rrule !== undefined) { sets.push('rrule = ?'); args.push(cleanRrule(data.rrule)); }
811
+ // first publish stamps published_at, like posts
812
+ if (data.status === 'published' && current.publishedAt === null && data.publishedAt === undefined) {
813
+ sets.push('published_at = ?'); args.push(Date.now());
814
+ }
815
+ // times: re-normalize the pair whenever any time field moves, then
816
+ // re-derive the UTC instants against the (possibly new) timezone
817
+ if (data.start !== undefined || data.end !== undefined || data.allDay !== undefined || data.timezone !== undefined) {
818
+ const allDay = data.allDay ?? current.allDay;
819
+ const times = normalizeEventTimes({ start: data.start ?? current.start, end: data.end ?? current.end, allDay });
820
+ const tz = eventTimeZone({ timezone: data.timezone ?? current.timezone }, { timezone: await this.siteTimezone() });
821
+ const { startAt, endAt } = eventInstants({ ...times, allDay }, tz);
822
+ sets.push('starts = ?', 'ends = ?', 'all_day = ?', 'start_at = ?', 'end_at = ?');
823
+ args.push(times.start, times.end, allDay ? 1 : 0, startAt, endAt);
824
+ }
825
+ args.push(id);
826
+ const changesCalendar = current.status === 'published' && ['title','start','end','allDay','timezone','location','cancelled','status'].some(k => k in data);
827
+ const cancelled = data.cancelled === true || (data.status !== undefined && data.status !== 'published');
828
+ const eventWrite = { sql:`UPDATE events SET ${sets.join(', ')} WHERE id = ?`, args };
829
+ if (changesCalendar) {
830
+ // One SQL INSERT...SELECT fans out recipient jobs without loading an
831
+ // unbounded attendee list. The update + jobs share the event batch.
832
+ 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'"})`)}));
833
+ const single = !current.rrule;
834
+ const siteTimezone = await this.siteTimezone();
835
+ await this.driver.batch([eventWrite,
836
+ {sql:`UPDATE bookings SET notification_version=notification_version+1,reminder_sent_at=NULL,
837
+ timezone=COALESCE(NULLIF((SELECT timezone FROM events WHERE id=?),''),?),
838
+ status=CASE WHEN ? THEN 'cancelled' ELSE status END,
839
+ start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
840
+ end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
841
+ 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]},
842
+ ...notifications]);
843
+ } else await this.driver.batch([eventWrite]);
844
+ await this.syncEventIndex(id);
845
+ }
846
+ async saveEventDraft(id: string, data: Parameters<ContentRepository['saveEventDraft']>[1]) {
847
+ await this.ready;
848
+ const r = await this.first('SELECT draft FROM events WHERE id = ?', [id]);
849
+ if (!r) return;
850
+ const merged: Record<string, unknown> = parseDraft(r.draft) ?? {};
851
+ for (const [k, v] of Object.entries(data)) if (v !== undefined) merged[k] = v;
852
+ merged.savedAt = Date.now();
853
+ await this.driver.run('UPDATE events SET draft = ? WHERE id = ?', [JSON.stringify(merged), id]);
854
+ }
855
+ async applyEventDraft(id: string) {
856
+ await this.ready;
857
+ const r = await this.first('SELECT draft FROM events WHERE id = ?', [id]);
858
+ const draft = parseDraft(r?.draft);
859
+ if (!draft) return;
860
+ await this.updateEvent(id, pickEventDraft(draft));
861
+ }
862
+ async discardEventDraft(id: string) {
863
+ await this.ready;
864
+ await this.driver.run('UPDATE events SET draft = NULL WHERE id = ?', [id]);
865
+ }
866
+ async deleteEvent(id: string) {
867
+ await this.ready;
868
+ const mutation = crypto.randomUUID();
869
+ const notifications = bookingJobs(id,'cancelled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp'")}));
870
+ await this.driver.batch([
871
+ {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]},
872
+ ...notifications,
873
+ { sql: 'DELETE FROM events WHERE id = ?', args: [id] },
874
+ { sql: "DELETE FROM search_index WHERE kind = 'event' AND ref_id = ?", args: [id] },
875
+ ]);
876
+ }
877
+
878
+
879
+ // ---- resources + booking links ----
880
+ private resourceInsert(r: Resource): SqlStatement {
881
+ return {
882
+ sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
883
+ notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
884
+ 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),
885
+ JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
886
+ r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt],
887
+ };
888
+ }
889
+ private linkInsert(l: BookingLink): SqlStatement {
890
+ return {
891
+ sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
892
+ buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
893
+ active, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
894
+ 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,
895
+ l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
896
+ l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt],
897
+ };
898
+ }
899
+ private bookingInsert(b: Booking): SqlStatement {
900
+ return {
901
+ 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,
902
+ seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
903
+ 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,
904
+ b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion],
905
+ };
906
+ }
907
+ async listResources() {
908
+ await this.ready;
909
+ return (await this.driver.all('SELECT * FROM resources ORDER BY ord ASC, name COLLATE NOCASE ASC')).map(rowToResource);
910
+ }
911
+ async getResource(id: string) {
912
+ await this.ready;
913
+ const r = await this.first('SELECT * FROM resources WHERE id = ?', [id]);
914
+ return r ? rowToResource(r) : null;
915
+ }
916
+ async getResourceBySlug(slug: string) {
917
+ await this.ready;
918
+ const r = await this.first('SELECT * FROM resources WHERE slug = ?', [slug]);
919
+ return r ? rowToResource(r) : null;
920
+ }
921
+ async createResource(data: Parameters<ContentRepository['createResource']>[0]) {
922
+ await this.ready;
923
+ const now = Date.now();
924
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM resources');
925
+ const r = Resource.parse({ ...data, id: crypto.randomUUID(), feedToken: randomToken(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
926
+ await this.driver.run(this.resourceInsert(r).sql, this.resourceInsert(r).args);
927
+ return r;
928
+ }
929
+ async updateResource(id: string, data: Parameters<ContentRepository['updateResource']>[1]) {
930
+ await this.ready;
931
+ const cur = await this.getResource(id);
932
+ if (!cur) return;
933
+ const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
934
+ await this.driver.run(
935
+ `UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
936
+ overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, updated_at = ? WHERE id = ?`,
937
+ [r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
938
+ JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
939
+ r.feedToken, r.updatedAt, id]
940
+ );
941
+ }
942
+ async reorderResources(ids: string[]) {
943
+ await this.ready;
944
+ await this.driver.batch(ids.map((id, i) => ({ sql: 'UPDATE resources SET ord = ? WHERE id = ?', args: [i, id] })));
945
+ }
946
+ async deleteResource(id: string) {
947
+ await this.ready;
948
+ await this.driver.batch([
949
+ { sql: 'DELETE FROM resources WHERE id = ?', args: [id] },
950
+ { sql: 'DELETE FROM booking_links WHERE resource_id = ?', args: [id] },
951
+ ]);
952
+ }
953
+ async listBookingLinks(resourceId?: string) {
954
+ await this.ready;
955
+ const rows = resourceId
956
+ ? await this.driver.all('SELECT * FROM booking_links WHERE resource_id = ? ORDER BY ord ASC, name COLLATE NOCASE ASC', [resourceId])
957
+ : await this.driver.all('SELECT * FROM booking_links ORDER BY ord ASC, name COLLATE NOCASE ASC');
958
+ return rows.map(rowToLink2);
959
+ }
960
+ async getBookingLink(id: string) {
961
+ await this.ready;
962
+ const r = await this.first('SELECT * FROM booking_links WHERE id = ?', [id]);
963
+ return r ? rowToLink2(r) : null;
964
+ }
965
+ async getBookingLinkBySlug(resourceId: string, slug: string) {
966
+ await this.ready;
967
+ const r = await this.first('SELECT * FROM booking_links WHERE resource_id = ? AND slug = ?', [resourceId, slug]);
968
+ return r ? rowToLink2(r) : null;
969
+ }
970
+ async createBookingLink(data: Parameters<ContentRepository['createBookingLink']>[0]) {
971
+ await this.ready;
972
+ const now = Date.now();
973
+ const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM booking_links WHERE resource_id = ?', [data.resourceId]);
974
+ const l = BookingLink.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
975
+ const st = this.linkInsert(l);
976
+ await this.driver.run(st.sql, st.args);
977
+ return l;
978
+ }
979
+ async updateBookingLink(id: string, data: Parameters<ContentRepository['updateBookingLink']>[1]) {
980
+ await this.ready;
981
+ const cur = await this.getBookingLink(id);
982
+ if (!cur) return;
983
+ const l = BookingLink.parse({ ...cur, ...data, id, updatedAt: Date.now() });
984
+ await this.driver.run(
985
+ `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
986
+ buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
987
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ? WHERE id = ?`,
988
+ [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
989
+ l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
990
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, id]
991
+ );
992
+ }
993
+ async deleteBookingLink(id: string) {
994
+ await this.ready;
995
+ await this.driver.run('DELETE FROM booking_links WHERE id = ?', [id]);
996
+ }
997
+
998
+ // ---- bookings ----
999
+ async listBookings(filter: Parameters<ContentRepository['listBookings']>[0] = {}) {
1000
+ await this.ready;
1001
+ const where: string[] = [];
1002
+ const args: unknown[] = [];
1003
+ if (filter.resourceId) { where.push('resource_id = ?'); args.push(filter.resourceId); }
1004
+ if (filter.eventId) { where.push('event_id = ?'); args.push(filter.eventId); }
1005
+ if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
1006
+ if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
1007
+ if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
1008
+ if (filter.kinds?.length) { where.push(`kind IN (${filter.kinds.map(() => '?').join(', ')})`); args.push(...filter.kinds); }
1009
+ const rows = await this.driver.all(`SELECT * FROM bookings${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY start_at ASC LIMIT 5000`, args);
1010
+ return rows.map(rowToBooking);
1011
+ }
1012
+ async getBooking(id: string) {
1013
+ await this.ready;
1014
+ const r = await this.first('SELECT * FROM bookings WHERE id = ?', [id]);
1015
+ return r ? rowToBooking(r) : null;
1016
+ }
1017
+ async getBookingByToken(token: string) {
1018
+ await this.ready;
1019
+ if (!token) return null;
1020
+ const r = await this.first('SELECT * FROM bookings WHERE manage_token = ?', [token]);
1021
+ return r ? rowToBooking(r) : null;
1022
+ }
1023
+ // One INSERT … SELECT … WHERE NOT EXISTS: the overlap test and the write
1024
+ // are a single statement, so two people racing for the same slot can't
1025
+ // both win — the loser's insert simply affects no rows.
1026
+ async createBooking(data: Parameters<ContentRepository['createBooking']>[0], guard: NonNullable<Parameters<ContentRepository['createBooking']>[1]> = {}) {
1027
+ await this.ready;
1028
+ const now = Date.now();
1029
+ const b = Booking.parse({ ...data, id: crypto.randomUUID(), reminderSentAt: null, createdAt: now, updatedAt: now });
1030
+ const ins = this.bookingInsert(b);
1031
+ const cols = ins.sql.slice(ins.sql.indexOf('('), ins.sql.indexOf(')') + 1);
1032
+ const holds = b.status === 'pending' || b.status === 'confirmed';
1033
+ let write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE 1=1`, args: ins.args };
1034
+ if (b.kind === 'rsvp') {
1035
+ const cap = guard.capacity ?? 0;
1036
+ write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')}
1037
+ WHERE (? = 0 OR (SELECT COALESCE(SUM(seats),0) FROM bookings WHERE kind='rsvp' AND event_id=? AND start_at=? AND status IN ('pending','confirmed')) + ? <= ?)`,
1038
+ args: [...ins.args!,cap,b.eventId,b.start,b.seats,cap] };
1039
+ } else if (holds && b.resourceId) {
1040
+ write = { sql: `INSERT INTO bookings ${cols} SELECT ${ins.args!.map(() => '?').join(', ')} WHERE NOT EXISTS (
1041
+ SELECT 1 FROM bookings WHERE resource_id=? AND kind IN ('booking','block') AND status IN ('pending','confirmed') AND hold_start < ? AND hold_end > ?)`,
1042
+ args: [...ins.args!,b.resourceId,b.holdEnd,b.holdStart] };
1043
+ }
1044
+ const ledger: SqlStatement[] = [];
1045
+ if (guard.idempotency) {
1046
+ const { keyHash, requestHash } = guard.idempotency;
1047
+ write = { sql: write.sql + ' AND NOT EXISTS (SELECT 1 FROM mobile_booking_requests WHERE key_hash=?)', args: [...write.args!, keyHash] };
1048
+ ledger.push({ sql: `INSERT INTO mobile_booking_requests (key_hash,request_hash,booking_id,created_at)
1049
+ SELECT ?,?,id,? FROM bookings WHERE id=?`, args: [keyHash, requestHash, now, b.id] });
1050
+ }
1051
+ // Booking, retry ledger and email outbox commit together on D1 and libSQL.
1052
+ await this.driver.batch([write, ...ledger, ...bookingJobs(b.id, b.status === 'confirmed' ? 'confirmed' : 'received', 0, true)]);
1053
+ return this.getBooking(b.id);
1054
+ }
1055
+ async moveBooking(id: string, t: { start: number; end: number; holdStart: number; holdEnd: number }) {
1056
+ await this.ready;
1057
+ const cur = await this.getBooking(id);
1058
+ if (!cur) return false;
1059
+ const nextVersion = cur.notificationVersion + 1;
1060
+ const mutation = crypto.randomUUID();
1061
+ await this.driver.batch([
1062
+ { 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=?
1063
+ 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 > ?)`,
1064
+ args:[t.start,t.end,t.holdStart,t.holdEnd,Date.now(),mutation,id,cur.notificationVersion,id,cur.resourceId,t.holdEnd,t.holdStart] },
1065
+ ...bookingJobs(id,'rescheduled',nextVersion,false,mutation),
1066
+ ]);
1067
+ return !!await this.first('SELECT id FROM bookings WHERE id=? AND email_mutation=?',[id,mutation]);
1068
+ }
1069
+
1070
+ async updateBooking(id: string, data: Parameters<ContentRepository['updateBooking']>[1]) {
1071
+ await this.ready;
1072
+ const current = await this.getBooking(id);
1073
+ if (!current) return;
1074
+ const changed = data.status !== undefined && data.status !== current.status;
1075
+ const mutation = crypto.randomUUID();
1076
+ const sets = ['updated_at = ?', 'email_mutation = ?'];
1077
+ if (changed) sets.push('notification_version = notification_version + 1', 'reminder_sent_at = NULL');
1078
+ const args: unknown[] = [Date.now(), mutation];
1079
+ if (data.status !== undefined) { sets.push('status = ?'); args.push(data.status); }
1080
+ if (data.notes !== undefined) { sets.push('notes = ?'); args.push(data.notes); }
1081
+ if (data.name !== undefined) { sets.push('name = ?'); args.push(data.name); }
1082
+ if (data.email !== undefined) { sets.push('email = ?'); args.push(data.email); }
1083
+ if (data.phone !== undefined) { sets.push('phone = ?'); args.push(data.phone); }
1084
+ if (data.answers !== undefined) { sets.push('answers = ?'); args.push(JSON.stringify(data.answers)); }
1085
+ if (data.reminderSentAt !== undefined) { sets.push('reminder_sent_at = ?'); args.push(data.reminderSentAt); }
1086
+ args.push(id);
1087
+ const kind = data.status === 'confirmed' ? 'confirmed' : data.status === 'declined' ? 'declined' : data.status === 'pending' ? 'received' : 'cancelled';
1088
+ await this.driver.batch([{sql:`UPDATE bookings SET ${sets.join(', ')} WHERE id = ? AND notification_version = ?`,args:[...args,current.notificationVersion]},
1089
+ ...(changed ? bookingJobs(id,kind,current.notificationVersion+1,false,mutation,data.notificationMessage) : [])]);
1090
+ }
1091
+ async deleteBooking(id: string) {
1092
+ await this.ready;
1093
+ await this.driver.run('DELETE FROM bookings WHERE id = ?', [id]);
1094
+ }
1095
+ async listDueReminders(from: number, to: number) {
1096
+ await this.ready;
1097
+ const rows = await this.driver.all(
1098
+ "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",
1099
+ [from, to]
1100
+ );
1101
+ return rows.map(rowToBooking);
1102
+ }
1103
+
1104
+ // ---- API keys ----
1105
+ async listApiKeys() {
1106
+ await this.ready;
1107
+ const rows = await this.driver.all('SELECT * FROM api_keys ORDER BY created_at DESC');
1108
+ return rows.map(rowToApiKey);
1109
+ }
1110
+ async createApiKey(data: Parameters<ContentRepository['createApiKey']>[0]) {
1111
+ await this.ready;
1112
+ const id = crypto.randomUUID();
1113
+ await this.driver.run(
1114
+ 'INSERT INTO api_keys (id, name, prefix, key_hash, scopes, created_at) VALUES (?, ?, ?, ?, ?, ?)',
1115
+ [id, data.name, data.prefix, data.keyHash, JSON.stringify(data.scopes), Date.now()]
1116
+ );
1117
+ return rowToApiKey((await this.first('SELECT * FROM api_keys WHERE id = ?', [id]))!);
1118
+ }
1119
+ async findApiKeyByHash(keyHash: string) {
1120
+ await this.ready;
1121
+ const r = await this.first('SELECT * FROM api_keys WHERE key_hash = ?', [keyHash]);
1122
+ if (!r) return null;
1123
+ const now = Date.now();
1124
+ // at most one write a minute per key: the stamp is informational
1125
+ if (!r.last_used_at || now - Number(r.last_used_at) > 60_000) {
1126
+ await this.driver.run('UPDATE api_keys SET last_used_at = ? WHERE id = ?', [now, r.id]);
1127
+ r.last_used_at = now;
1128
+ }
1129
+ return rowToApiKey(r);
1130
+ }
1131
+ async deleteApiKey(id: string) {
1132
+ await this.ready;
1133
+ await this.driver.run('DELETE FROM api_keys WHERE id = ?', [id]);
1134
+ }
1135
+
1136
+ // ---- webhooks ----
1137
+ async listWebhooks() {
1138
+ await this.ready;
1139
+ const rows = await this.driver.all('SELECT * FROM webhooks ORDER BY created_at ASC');
1140
+ return rows.map(rowToWebhook);
1141
+ }
1142
+ async createWebhook(data: Parameters<ContentRepository['createWebhook']>[0]) {
1143
+ await this.ready;
1144
+ const id = crypto.randomUUID();
1145
+ await this.driver.run(
1146
+ 'INSERT INTO webhooks (id, url, secret, events, active, created_at) VALUES (?, ?, ?, ?, 1, ?)',
1147
+ [id, data.url, data.secret, JSON.stringify(data.events), Date.now()]
1148
+ );
1149
+ return rowToWebhook((await this.first('SELECT * FROM webhooks WHERE id = ?', [id]))!);
1150
+ }
1151
+ async updateWebhook(id: string, data: Parameters<ContentRepository['updateWebhook']>[1]) {
1152
+ await this.ready;
1153
+ const sets: string[] = [];
1154
+ const args: unknown[] = [];
1155
+ if (data.url !== undefined) { sets.push('url = ?'); args.push(data.url); }
1156
+ if (data.secret !== undefined) { sets.push('secret = ?'); args.push(data.secret); }
1157
+ if (data.events !== undefined) { sets.push('events = ?'); args.push(JSON.stringify(data.events)); }
1158
+ if (data.active !== undefined) { sets.push('active = ?'); args.push(data.active ? 1 : 0); }
1159
+ if (!sets.length) return;
1160
+ args.push(id);
1161
+ await this.driver.run(`UPDATE webhooks SET ${sets.join(', ')} WHERE id = ?`, args);
1162
+ }
1163
+ async deleteWebhook(id: string) {
1164
+ await this.ready;
1165
+ await this.driver.batch([
1166
+ { sql: 'DELETE FROM webhooks WHERE id = ?', args: [id] },
1167
+ { sql: 'DELETE FROM webhook_deliveries WHERE webhook_id = ?', args: [id] },
1168
+ ]);
1169
+ }
1170
+ async createDeliveries(rows: Parameters<ContentRepository['createDeliveries']>[0]) {
1171
+ await this.ready;
1172
+ await this.driver.batch(
1173
+ rows.map((d) => ({
1174
+ sql: 'INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, attempts, next_at, created_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
1175
+ args: [d.id, d.webhookId, d.event, d.payload, d.status, d.nextAt, d.createdAt],
1176
+ }))
1177
+ );
1178
+ }
1179
+ async listDueDeliveries(now: number, limit: number) {
1180
+ await this.ready;
1181
+ const rows = await this.driver.all(
1182
+ "SELECT * FROM webhook_deliveries WHERE status = 'pending' AND (next_at IS NULL OR next_at <= ?) ORDER BY created_at ASC LIMIT ?",
1183
+ [now, limit]
1184
+ );
1185
+ return rows.map(rowToDelivery);
1186
+ }
1187
+ async listDeliveries(webhookId: string, limit: number) {
1188
+ await this.ready;
1189
+ const rows = await this.driver.all(
1190
+ 'SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT ?',
1191
+ [webhookId, limit]
1192
+ );
1193
+ return rows.map(rowToDelivery);
1194
+ }
1195
+ async recordDelivery(id: string, data: Parameters<ContentRepository['recordDelivery']>[1]) {
1196
+ await this.ready;
1197
+ const r = await this.first('SELECT webhook_id FROM webhook_deliveries WHERE id = ?', [id]);
1198
+ await this.driver.batch([
1199
+ {
1200
+ sql: 'UPDATE webhook_deliveries SET status = ?, attempts = ?, next_at = ?, response_code = ?, delivered_at = ? WHERE id = ?',
1201
+ args: [data.status, data.attempts, data.nextAt, data.responseCode, data.deliveredAt, id],
1202
+ },
1203
+ ...(r
1204
+ ? [{ sql: 'UPDATE webhooks SET last_status = ?, last_at = ? WHERE id = ?', args: [data.responseCode ?? 0, Date.now(), r.webhook_id] }]
1205
+ : []),
1206
+ ]);
1207
+ // keep the log bounded: the newest 50 deliveries per endpoint survive
1208
+ if (r) {
1209
+ await this.driver.run(
1210
+ `DELETE FROM webhook_deliveries WHERE webhook_id = ? AND status != 'pending' AND id NOT IN (
1211
+ SELECT id FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT 50)`,
1212
+ [r.webhook_id, r.webhook_id]
1213
+ );
1214
+ }
1215
+ }
1216
+
430
1217
  // ---- media ----
431
1218
  async listMedia() {
432
1219
  await this.ready;
@@ -564,6 +1351,26 @@ export class SqlRepository implements ContentRepository {
564
1351
  }
565
1352
  }
566
1353
 
1354
+ // ---- snippets ----
1355
+ async listSnippets() {
1356
+ await this.ready;
1357
+ const rows = await this.driver.all('SELECT * FROM snippets ORDER BY name COLLATE NOCASE ASC');
1358
+ return rows.map(rowToSnippet);
1359
+ }
1360
+ async createSnippet({ name, contentJson }: { name: string; contentJson: string }) {
1361
+ await this.ready;
1362
+ const id = crypto.randomUUID();
1363
+ await this.driver.run('INSERT INTO snippets (id, name, content_json, created_at) VALUES (?, ?, ?, ?)', [
1364
+ id, name, contentJson, Date.now(),
1365
+ ]);
1366
+ const r = await this.first('SELECT * FROM snippets WHERE id = ?', [id]);
1367
+ return rowToSnippet(r!);
1368
+ }
1369
+ async deleteSnippet(id: string) {
1370
+ await this.ready;
1371
+ await this.driver.run('DELETE FROM snippets WHERE id = ?', [id]);
1372
+ }
1373
+
567
1374
  // ---- users ----
568
1375
  async listUsers() {
569
1376
  await this.ready;
@@ -591,13 +1398,18 @@ export class SqlRepository implements ContentRepository {
591
1398
  if (data.name !== undefined) { sets.push('name = ?'); args.push(data.name); }
592
1399
  if (data.email !== undefined) { sets.push('email = ?'); args.push(data.email.toLowerCase()); }
593
1400
  if (data.role !== undefined) { sets.push('role = ?'); args.push(data.role); }
594
- if (data.passHash !== undefined) { sets.push('pass_hash = ?'); args.push(data.passHash); }
1401
+ if (data.passHash !== undefined) { sets.push('pass_hash = ?', 'session_version = session_version + 1'); args.push(data.passHash); }
595
1402
  if (data.bio !== undefined) { sets.push('bio = ?'); args.push(data.bio); }
596
1403
  if (data.avatar !== undefined) { sets.push('avatar = ?'); args.push(data.avatar); }
597
1404
  if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
598
1405
  if (!sets.length) return;
599
1406
  args.push(id);
600
- await this.driver.run(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, args);
1407
+ await this.driver.batch([
1408
+ {sql:`UPDATE users SET ${sets.join(', ')} WHERE id = ?`,args},
1409
+ ...(data.passHash === undefined ? [] : [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
1410
+ SELECT ?,?,'password-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=?`,
1411
+ args:[crypto.randomUUID(),crypto.randomUUID(),Date.now(),Date.now(),Date.now(),id]}]),
1412
+ ]);
601
1413
  }
602
1414
  async deleteUser(id: string) {
603
1415
  await this.ready;
@@ -606,7 +1418,7 @@ export class SqlRepository implements ContentRepository {
606
1418
  async getUserAuth(email: string) {
607
1419
  await this.ready;
608
1420
  const r = await this.first('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
609
- return r ? { user: rowToUser(r), passHash: String(r.pass_hash) } : null;
1421
+ return r ? { user: rowToUser(r), passHash: String(r.pass_hash), sessionVersion: Number(r.session_version) } : null;
610
1422
  }
611
1423
 
612
1424
  // ---- navigation ----
@@ -650,12 +1462,13 @@ export class SqlRepository implements ContentRepository {
650
1462
  async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
651
1463
  await this.ready;
652
1464
  const trimmed = query.trim();
653
- if (!trimmed) return { posts: [], pages: [], tags: [], authors: [] };
1465
+ if (!trimmed) return { posts: [], pages: [], events: [], tags: [], authors: [] };
654
1466
  const locale = opts?.locale ?? '';
655
1467
  let postRows: Row[];
656
1468
  let pageRows: Row[];
1469
+ let eventRows: Row[];
657
1470
  try {
658
- [postRows, pageRows] = await Promise.all([
1471
+ [postRows, pageRows, eventRows] = await Promise.all([
659
1472
  this.driver.all(
660
1473
  `SELECT p.*, ${SNIPPET} AS snip FROM search_index JOIN posts p ON p.id = search_index.ref_id
661
1474
  WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'
@@ -670,11 +1483,17 @@ export class SqlRepository implements ContentRepository {
670
1483
  ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT 50`,
671
1484
  [ftsQuery(trimmed), locale]
672
1485
  ),
1486
+ this.driver.all(
1487
+ `SELECT e.*, ${SNIPPET} AS snip FROM search_index JOIN events e ON e.id = search_index.ref_id
1488
+ WHERE search_index MATCH ? AND search_index.kind = 'event' AND e.status = 'published'
1489
+ ORDER BY ${RANK} LIMIT 50`,
1490
+ [ftsQuery(trimmed)]
1491
+ ),
673
1492
  ]);
674
1493
  } catch {
675
1494
  // FTS5 unavailable or a query form it rejects — degrade to the old
676
1495
  // LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
677
- [postRows, pageRows] = await this.likeSearch(trimmed, locale);
1496
+ [postRows, pageRows, eventRows] = await this.likeSearch(trimmed, locale);
678
1497
  }
679
1498
  const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
680
1499
  const [authors, tagRows, userRows] = await Promise.all([
@@ -699,13 +1518,14 @@ export class SqlRepository implements ContentRepository {
699
1518
  snippet: safeSnippet(r.snip ?? r.excerpt ?? ''),
700
1519
  })),
701
1520
  pages: pageRows.map((r) => ({ doc: rowToPage(r), snippet: safeSnippet(r.snip ?? r.seo_desc ?? '') })),
1521
+ events: eventRows.map((r) => ({ doc: rowToEvent(r), snippet: safeSnippet(r.snip ?? r.excerpt ?? '') })),
702
1522
  tags: tags.map((tag) => ({ tag, count: tagCounts.get(tag.name.toLowerCase()) ?? 0 })),
703
1523
  authors: userRows.map(rowToUser),
704
1524
  };
705
1525
  }
706
- private async likeSearch(query: string, locale: string): Promise<[Row[], Row[]]> {
1526
+ private async likeSearch(query: string, locale: string): Promise<[Row[], Row[], Row[]]> {
707
1527
  const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
708
- if (q === '%%') return [[], []];
1528
+ if (q === '%%') return [[], [], []];
709
1529
  return Promise.all([
710
1530
  this.driver.all(
711
1531
  "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",
@@ -715,6 +1535,10 @@ export class SqlRepository implements ContentRepository {
715
1535
  "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT 50",
716
1536
  [q, q, locale]
717
1537
  ),
1538
+ this.driver.all(
1539
+ "SELECT * FROM events WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ?) ORDER BY start_at DESC LIMIT 50",
1540
+ [q, q, q]
1541
+ ),
718
1542
  ]);
719
1543
  }
720
1544
 
@@ -722,20 +1546,41 @@ export class SqlRepository implements ContentRepository {
722
1546
  async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
723
1547
  await this.ready;
724
1548
  const stmts: SqlStatement[] = [
725
- ...['posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects'].map((t) => ({
1549
+ ...['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) => ({
726
1550
  sql: `DELETE FROM ${t}`,
727
1551
  })),
1552
+ ...(content.calendars ?? []).map((c) => ({
1553
+ sql: 'INSERT INTO calendars (id, slug, name, color, description, kind, feed_url, visibility, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1554
+ 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],
1555
+ })),
1556
+ ...(content.events ?? []).map((e) => ({
1557
+ sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1558
+ starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1559
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp)
1560
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1561
+ args: [
1562
+ 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,
1563
+ JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1564
+ e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1565
+ e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1566
+ JSON.stringify(e.rsvp ?? {}),
1567
+ ],
1568
+ })),
1569
+ ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
1570
+ ...(content.bookingLinks ?? []).map((l) => this.linkInsert(l)),
1571
+ ...(content.bookings ?? []).map((b) => this.bookingInsert(b)),
1572
+ ...(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] })),
728
1573
  // p.locale defaults to '' via the zod schema, so pre-v20 backups
729
1574
  // restore cleanly as default-locale pages
730
1575
  ...content.pages.map((p) => ({
731
- sql: 'INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
732
- args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
1576
+ sql: 'INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, draft, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1577
+ args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.draft ? JSON.stringify(p.draft) : null, p.createdAt, p.updatedAt],
733
1578
  })),
734
1579
  // p.collection defaults to 'posts' via the zod schema, so pre-v19
735
1580
  // backups restore cleanly into the built-in collection
736
1581
  ...content.posts.map((p) => ({
737
- sql: 'INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, published_at, scheduled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
738
- args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? p.authorId, p.template, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
1582
+ sql: 'INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, draft, published_at, scheduled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1583
+ args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? p.authorId, p.template, p.draft ? JSON.stringify(p.draft) : null, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
739
1584
  })),
740
1585
  ...content.posts.flatMap((p) =>
741
1586
  (p.authorIds.length ? p.authorIds : p.authorId ? [p.authorId] : []).map((aid, i) => ({
@@ -761,8 +1606,8 @@ export class SqlRepository implements ContentRepository {
761
1606
  })),
762
1607
  // accounts restore without hashes — sign in via ADMIN_PASSWORD and reset
763
1608
  ...content.users.map((u) => ({
764
- sql: 'INSERT INTO users (id, name, email, role, pass_hash, bio, avatar, slug, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
765
- args: [u.id, u.name, u.email, u.role, '', u.bio, u.avatar, u.slug, u.createdAt],
1609
+ sql: 'INSERT INTO users (id, name, email, role, pass_hash, bio, avatar, slug, created_at, session_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1610
+ args: [u.id, u.name, u.email, u.role, '', u.bio, u.avatar, u.slug, u.createdAt, Date.now()],
766
1611
  })),
767
1612
  ...content.redirects.map((r) => ({
768
1613
  sql: 'INSERT INTO redirects (id, from_path, to_path, code, created_at) VALUES (?, ?, ?, ?, ?)',
@@ -813,40 +1658,99 @@ export class SqlRepository implements ContentRepository {
813
1658
  siteTitle: r?.site_title ?? 'My Site', tagline: r?.tagline ?? '', footerText: r?.footer_text ?? '',
814
1659
  linksName: r?.links_name ?? '', linksBio: r?.links_bio ?? '', linksAvatar: r?.links_avatar ?? '',
815
1660
  activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
1661
+ sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
816
1662
  siteLogo: r?.site_logo ?? '',
817
1663
  siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
818
1664
  announcement: JSON.parse(String(r?.announcement ?? '{}')),
1665
+ analytics: JSON.parse(String(r?.analytics ?? '{}')),
819
1666
  headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
820
1667
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
1668
+ ai: JSON.parse(String(r?.ai ?? '{}')),
1669
+ agent: JSON.parse(String(r?.agent ?? '{}')),
821
1670
  draftsApiKey: r?.drafts_api_key ?? '',
822
1671
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
1672
+ draftDesign: parseDraft(r?.draft_design),
823
1673
  });
824
1674
  }
825
1675
  async updateSettings(data: Partial<Settings>) {
826
1676
  await this.ready;
1677
+ const columns: Record<string, string> = {
1678
+ siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1679
+ activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1680
+ announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1681
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent',
1682
+ };
1683
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent']);
1684
+ const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1685
+ if (!entries.length) return;
1686
+ const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
1687
+ await this.driver.run(`UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
1688
+ entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v));
1689
+ }
1690
+ private async designWrite(expected: number, actor: string, action: string, statements: SqlStatement[]) {
1691
+ try {
1692
+ await this.driver.batch([
1693
+ { 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] },
1694
+ ...statements,
1695
+ { sql: 'UPDATE settings SET design_revision = design_revision + 1 WHERE id = 1' },
1696
+ ]);
1697
+ } catch (e) {
1698
+ if (/CHECK constraint|expected_revision/i.test(String(e))) throw new ApiError(409, 'Design changed. Pull the latest revision before saving.');
1699
+ throw e;
1700
+ }
1701
+ }
1702
+ async listDesignAudit() {
1703
+ await this.ready;
1704
+ return this.driver.all('SELECT id,created_at,actor,action,actual_revision FROM design_audit ORDER BY created_at DESC LIMIT 100');
1705
+ }
1706
+ // Design-screen staging: only the draft_design JSON changes on save — the
1707
+ // live design columns wait for applyDesignDraft ("Publish design").
1708
+ async saveDesignDraft(data: Parameters<ContentRepository['saveDesignDraft']>[0]) {
827
1709
  const current = await this.getSettings();
828
- await this.driver.run(
829
- '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 = ?, head_code = ?, footer_code = ?, timezone = ?, locale = ?, drafts_api_key = ?, drafts_allowed_origins = ? WHERE id = 1',
830
- [
831
- data.siteTitle ?? current.siteTitle, data.tagline ?? current.tagline,
832
- data.footerText ?? current.footerText,
833
- data.linksName ?? current.linksName, data.linksBio ?? current.linksBio,
834
- data.linksAvatar ?? current.linksAvatar,
835
- data.activeTheme ?? current.activeTheme,
836
- JSON.stringify(data.designOverrides ?? current.designOverrides),
837
- data.siteLogo ?? current.siteLogo,
838
- data.siteUrl ?? current.siteUrl,
839
- data.siteFavicon ?? current.siteFavicon,
840
- data.siteCover ?? current.siteCover,
841
- JSON.stringify(data.announcement ?? current.announcement),
842
- data.headCode ?? current.headCode,
843
- data.footerCode ?? current.footerCode,
844
- data.timezone ?? current.timezone,
845
- data.locale ?? current.locale,
846
- data.draftsApiKey ?? current.draftsApiKey,
847
- JSON.stringify(data.draftsAllowedOrigins ?? current.draftsAllowedOrigins),
848
- ]
849
- );
1710
+ const merged = { ...current.draftDesign, ...data };
1711
+ // Preserve a valid identity only while editing this same draft.
1712
+ merged.shareEpoch = typeof current.draftDesign?.shareEpoch === 'string' ? current.draftDesign.shareEpoch : crypto.randomUUID();
1713
+ if (data.shareEpoch) merged.shareEpoch = data.shareEpoch;
1714
+ await this.setDesignDraft(merged, current.designRevision);
1715
+ }
1716
+ async setDesignDraft(draft: Record<string, unknown> | null, expectedRevision?: number, actor = 'admin') {
1717
+ const current = await this.getSettings();
1718
+ const value = draft ? { ...draft, shareEpoch: typeof draft.shareEpoch === 'string' ? draft.shareEpoch : crypto.randomUUID(), savedAt: Date.now() } : null;
1719
+ await this.designWrite(expectedRevision ?? current.designRevision, actor, value ? 'stage' : 'discard', [
1720
+ { sql: 'UPDATE settings SET draft_design = ? WHERE id = 1', args: [value ? JSON.stringify(value) : null] },
1721
+ ]);
1722
+ }
1723
+ async applyDesignDraft(opts: { author?: string; summary?: string; expectedRevision?: number } = {}) {
1724
+ const current = await this.getSettings();
1725
+ const draft = current.draftDesign;
1726
+ if (!draft) {
1727
+ if (opts.expectedRevision !== undefined && opts.expectedRevision !== current.designRevision) throw new ApiError(409, 'Design changed. Pull the latest revision.');
1728
+ return;
1729
+ }
1730
+ const nav = draftNavItems(draft);
1731
+ const liveNav = await this.listNavItems();
1732
+ const design = { ...current, ...pickDesignDraft(draft) };
1733
+ const statements: SqlStatement[] = [
1734
+ { 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))] },
1735
+ { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1736
+ { sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
1737
+ args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteFavicon, design.siteCover] },
1738
+ ];
1739
+ if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
1740
+ 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],
1741
+ })));
1742
+ await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
1743
+ }
1744
+ async discardDesignDraft() { await this.setDesignDraft(null); }
1745
+ async listDesignVersions(limit = 20) {
1746
+ await this.ready;
1747
+ const rows = await this.driver.all('SELECT * FROM design_versions ORDER BY created_at DESC LIMIT ?', [limit]);
1748
+ return rows.map(designVersionRow);
1749
+ }
1750
+ async getDesignVersion(id: string) {
1751
+ await this.ready;
1752
+ const r = await this.first('SELECT * FROM design_versions WHERE id = ?', [id]);
1753
+ return r ? designVersionRow(r) : null;
850
1754
  }
851
1755
  async getStats(): Promise<Stats> {
852
1756
  await this.ready;
@@ -863,3 +1767,9 @@ export class SqlRepository implements ContentRepository {
863
1767
  };
864
1768
  }
865
1769
  }
1770
+
1771
+ function designVersionRow(r: Row): DesignVersion {
1772
+ let snapshot: Record<string, unknown> = {};
1773
+ try { snapshot = JSON.parse(String(r.snapshot ?? '{}')); } catch { /* corrupt row: empty snapshot */ }
1774
+ return { id: String(r.id), createdAt: Number(r.created_at), author: String(r.author ?? ''), summary: String(r.summary ?? ''), snapshot };
1775
+ }