@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4

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 (289) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,9 +1,11 @@
1
1
  import { LiveStore } from '../live/store';
2
+ import { PushStore } from '../push/store';
3
+ import { EditorStore } from '../editor/store';
2
4
  import { LiveConfig } from '../live/types';
3
5
  import { ApiError } from '../api';
4
6
  import { EmailStore, bookingJobs } from '../email/store';
5
7
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, 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';
8
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
7
9
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
8
10
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
9
11
  import { cleanRrule } from '../calendar/rrule';
@@ -43,6 +45,11 @@ function parseDraft(raw: unknown): Record<string, unknown> | null {
43
45
  }
44
46
  }
45
47
 
48
+ // translations column: tolerate old rows / junk (never break a page render)
49
+ function parseTranslations(v: unknown) {
50
+ const parsed = DocTranslations.safeParse(json(v, {}));
51
+ return parsed.success ? parsed.data : {};
52
+ }
46
53
  function rowToPage(r: Row): Page {
47
54
  return Page.parse({
48
55
  id: r.id, slug: r.slug, title: r.title, status: r.status,
@@ -50,7 +57,7 @@ function rowToPage(r: Row): Page {
50
57
  blocks: JSON.parse(String(r.blocks ?? '[]')),
51
58
  bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
52
59
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
53
- template: r.template ?? '', draft: parseDraft(r.draft),
60
+ template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
54
61
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
55
62
  });
56
63
  }
@@ -62,7 +69,7 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
62
69
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
63
70
  featured: Boolean(r.featured), authorId: r.author_id ?? null,
64
71
  authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
65
- template: r.template ?? '', draft: parseDraft(r.draft),
72
+ template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
66
73
  publishedAt: r.published_at === null ? null : Number(r.published_at),
67
74
  scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
68
75
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -137,8 +144,8 @@ function rowToEvent(r: Row): CalEvent {
137
144
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
138
145
  rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
139
146
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
140
- fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
141
- startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft),
147
+ fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
148
+ startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
142
149
  publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
143
150
  scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
144
151
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -154,6 +161,14 @@ function rowToResource(r: Row): Resource {
154
161
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
155
162
  });
156
163
  }
164
+ // Callers that still speak the old on/off flag (REST, agent tools, older
165
+ // code) set autoConfirm without an approval mode: map it so "off" really
166
+ // holds bookings. On create, "on" follows the site default (which starts as
167
+ // automatic); on update it pins the link to automatic.
168
+ function legacyApproval<T extends { autoConfirm?: boolean; approval?: BookingLink['approval'] }>(data: T, update = false): T {
169
+ if (data.approval !== undefined || data.autoConfirm === undefined) return data;
170
+ return { ...data, approval: data.autoConfirm ? (update ? 'auto' : 'default') : 'manual' };
171
+ }
157
172
  function rowToLink2(r: Row): BookingLink {
158
173
  return BookingLink.parse({
159
174
  id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
@@ -161,6 +176,7 @@ function rowToLink2(r: Row): BookingLink {
161
176
  step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
162
177
  minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
163
178
  hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
179
+ approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
164
180
  askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
165
181
  redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
166
182
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -233,10 +249,14 @@ export class SqlRepository implements ContentRepository {
233
249
  private ready: Promise<void>;
234
250
  readonly email: EmailStore;
235
251
  readonly live: LiveStore;
252
+ readonly push: PushStore;
253
+ readonly editor: EditorStore;
236
254
 
237
255
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
238
256
  this.ready = this.init(driver);
239
257
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
258
+ this.push = new PushStore(async () => { await this.ready; return this.driver; });
259
+ this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
240
260
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
241
261
  }
242
262
 
@@ -466,6 +486,7 @@ export class SqlRepository implements ContentRepository {
466
486
  if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
467
487
  if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
468
488
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
489
+ if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
469
490
  if (data.blocks !== undefined) { sets.push('blocks = ?'); args.push(JSON.stringify(data.blocks)); }
470
491
  if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
471
492
  if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
@@ -598,6 +619,7 @@ export class SqlRepository implements ContentRepository {
598
619
  if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
599
620
  if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
600
621
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
622
+ if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
601
623
  // authorIds is the source of truth; author_id mirrors the first entry.
602
624
  // Main row, join rows and webhook notifications share one transaction.
603
625
  let authorJoin: string[] | null = null;
@@ -798,8 +820,8 @@ export class SqlRepository implements ContentRepository {
798
820
  await this.driver.run(
799
821
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
800
822
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
801
- published_at, scheduled_at, created_at, updated_at, rsvp)
802
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
823
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
824
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
803
825
  [
804
826
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
805
827
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -807,7 +829,7 @@ export class SqlRepository implements ContentRepository {
807
829
  data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
808
830
  JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
809
831
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
810
- JSON.stringify(data.rsvp ?? {}),
832
+ JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}),
811
833
  ]
812
834
  );
813
835
  await this.syncEventIndex(id);
@@ -832,7 +854,7 @@ export class SqlRepository implements ContentRepository {
832
854
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
833
855
  }
834
856
  const jsonCols: Record<string, string> = {
835
- tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
857
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
836
858
  };
837
859
  for (const [k, col] of Object.entries(jsonCols)) {
838
860
  const v = (data as Record<string, unknown>)[k];
@@ -923,10 +945,11 @@ export class SqlRepository implements ContentRepository {
923
945
  return {
924
946
  sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
925
947
  buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
926
- active, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
948
+ active, ord, created_at, updated_at, approval, approval_rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
927
949
  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,
928
950
  l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
929
- l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt],
951
+ l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
952
+ l.approval, JSON.stringify(l.approvalRules)],
930
953
  };
931
954
  }
932
955
  private bookingInsert(b: Booking): SqlStatement {
@@ -1004,7 +1027,7 @@ export class SqlRepository implements ContentRepository {
1004
1027
  await this.ready;
1005
1028
  const now = Date.now();
1006
1029
  const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM booking_links WHERE resource_id = ?', [data.resourceId]);
1007
- const l = BookingLink.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
1030
+ const l = BookingLink.parse({ ...legacyApproval(data), id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
1008
1031
  const st = this.linkInsert(l);
1009
1032
  await this.driver.run(st.sql, st.args);
1010
1033
  return l;
@@ -1013,14 +1036,14 @@ export class SqlRepository implements ContentRepository {
1013
1036
  await this.ready;
1014
1037
  const cur = await this.getBookingLink(id);
1015
1038
  if (!cur) return;
1016
- const l = BookingLink.parse({ ...cur, ...data, id, updatedAt: Date.now() });
1039
+ const l = BookingLink.parse({ ...cur, ...legacyApproval(data, true), id, updatedAt: Date.now() });
1017
1040
  await this.driver.run(
1018
1041
  `UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
1019
1042
  buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
1020
- ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ? WHERE id = ?`,
1043
+ ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ? WHERE id = ?`,
1021
1044
  [l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
1022
1045
  l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
1023
- JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, id]
1046
+ JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), id]
1024
1047
  );
1025
1048
  }
1026
1049
  async deleteBookingLink(id: string) {
@@ -1035,6 +1058,7 @@ export class SqlRepository implements ContentRepository {
1035
1058
  const args: unknown[] = [];
1036
1059
  if (filter.resourceId) { where.push('resource_id = ?'); args.push(filter.resourceId); }
1037
1060
  if (filter.eventId) { where.push('event_id = ?'); args.push(filter.eventId); }
1061
+ if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
1038
1062
  if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
1039
1063
  if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
1040
1064
  if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
@@ -1601,14 +1625,14 @@ export class SqlRepository implements ContentRepository {
1601
1625
  ...(content.events ?? []).map((e) => ({
1602
1626
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1603
1627
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1604
- draft, published_at, scheduled_at, created_at, updated_at, rsvp)
1605
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1628
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
1629
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1606
1630
  args: [
1607
1631
  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,
1608
1632
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1609
1633
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1610
1634
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1611
- JSON.stringify(e.rsvp ?? {}),
1635
+ JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
1612
1636
  ],
1613
1637
  })),
1614
1638
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -1633,6 +1657,10 @@ export class SqlRepository implements ContentRepository {
1633
1657
  args: [p.id, aid, i] as unknown[],
1634
1658
  }))
1635
1659
  ),
1660
+ // translations (v36): written after the rows so older backups need no change
1661
+ ...[['pages', content.pages], ['posts', content.posts], ['events', content.events ?? []]].flatMap(([table, rows]) =>
1662
+ (rows as { id: string; translations?: Record<string, unknown> }[]).filter((r) => r.translations && Object.keys(r.translations).length)
1663
+ .map((r) => ({ sql: `UPDATE ${table} SET translations = ? WHERE id = ?`, args: [JSON.stringify(r.translations), r.id] as unknown[] }))),
1636
1664
  ...content.media.map((m) => ({
1637
1665
  sql: 'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1638
1666
  args: [m.id, m.key, m.filename, m.mime, m.width, m.height, m.size, m.alt, JSON.stringify(m.variants), m.createdAt],
@@ -1818,6 +1846,8 @@ export class SqlRepository implements ContentRepository {
1818
1846
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
1819
1847
  ai: JSON.parse(String(r?.ai ?? '{}')),
1820
1848
  live: JSON.parse(String(r?.live ?? '{}')),
1849
+ push: JSON.parse(String(r?.push ?? '{}')),
1850
+ calendar: JSON.parse(String(r?.calendar ?? '{}')),
1821
1851
  agent: JSON.parse(String(r?.agent ?? '{}')),
1822
1852
  draftsApiKey: r?.drafts_api_key ?? '',
1823
1853
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1834,9 +1864,9 @@ export class SqlRepository implements ContentRepository {
1834
1864
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1835
1865
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1836
1866
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1837
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
1867
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
1838
1868
  };
1839
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
1869
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
1840
1870
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1841
1871
  if (!entries.length) return;
1842
1872
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -1901,6 +1931,21 @@ export class SqlRepository implements ContentRepository {
1901
1931
  } }));
1902
1932
  await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
1903
1933
  }
1934
+ async publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }) {
1935
+ const current = await this.getSettings();
1936
+ const liveNav = await this.listNavItems();
1937
+ const draft: Record<string, unknown> = { ...(current.draftDesign ?? {}) };
1938
+ if (staged) draft.sectionPages = staged; else delete draft.sectionPages;
1939
+ // a draft with nothing left to publish goes away entirely
1940
+ const remaining = Object.keys(pickDesignDraft(draft)).length > 0 || Array.isArray(draft.navItems);
1941
+ const statements: SqlStatement[] = [
1942
+ { 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))] },
1943
+ { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1944
+ { sql: 'UPDATE settings SET section_pages=?,draft_design=? WHERE id=1', args: [JSON.stringify(live), remaining ? JSON.stringify({ ...draft, savedAt: Date.now() }) : null] },
1945
+ webhookStatement('design.published', { design: { revision: current.designRevision + 1, activeTheme: current.activeTheme, navigationChanged: false, sectionPageIds: Object.keys(live) } }),
1946
+ ];
1947
+ await this.designWrite(opts.expectedRevision, opts.author ?? 'admin', 'publish', statements);
1948
+ }
1904
1949
  async discardDesignDraft() { await this.setDesignDraft(null); }
1905
1950
  async listDesignVersions(limit = 20) {
1906
1951
  await this.ready;
@@ -1,6 +1,8 @@
1
1
  import type { LiveStore } from '../live/store';
2
2
  import type { BookingRequest } from '../backup/content';
3
3
  import type { EmailStore } from '../email/store';
4
+ import type { PushStore } from '../push/store';
5
+ import type { EditorStore } from '../editor/store';
4
6
  export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
5
7
  export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
6
8
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
@@ -37,7 +39,7 @@ export type EventUpdate = Partial<
37
39
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
38
40
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
39
41
  | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
40
- | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
42
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
41
43
  | 'publishedAt' | 'scheduledAt'
42
44
  >
43
45
  >;
@@ -47,6 +49,8 @@ export type EventUpdate = Partial<
47
49
  export interface ContentRepository {
48
50
  readonly email: EmailStore;
49
51
  readonly live: LiveStore;
52
+ readonly push: PushStore;
53
+ readonly editor: EditorStore;
50
54
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
51
55
  listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
52
56
  exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
@@ -60,7 +64,7 @@ export interface ContentRepository {
60
64
  createPage(data: { title: string; slug: string }): Promise<Page>;
61
65
  updatePage(
62
66
  id: string,
63
- data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template'>> & {
67
+ data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template' | 'translations'>> & {
64
68
  blocks?: WidgetInstance[];
65
69
  }
66
70
  ): Promise<void>;
@@ -90,7 +94,7 @@ export interface ContentRepository {
90
94
  | 'title' | 'slug' | 'collection' | 'body' | 'bodyJson' | 'bodyHtml' | 'excerpt'
91
95
  | 'tags' | 'cover' | 'coverAlt' | 'seoTitle' | 'seoDesc'
92
96
  | 'status' | 'featured' | 'authorId' | 'authorIds' | 'template'
93
- | 'publishedAt' | 'scheduledAt'
97
+ | 'publishedAt' | 'scheduledAt' | 'translations'
94
98
  >
95
99
  >
96
100
  ): Promise<void>;
@@ -148,7 +152,7 @@ export interface ContentRepository {
148
152
  // write that can fail on a race: it inserts atomically only if the held
149
153
  // range is still free (resource bookings) or seats remain (RSVPs), and
150
154
  // returns null when it lost.
151
- listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
155
+ listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
152
156
  getBooking(id: string): Promise<Booking | null>;
153
157
  getBookingByToken(token: string): Promise<Booking | null>;
154
158
  createBooking(
@@ -298,6 +302,8 @@ export interface ContentRepository {
298
302
  // publish: snapshot the outgoing live design into design_versions (last 20
299
303
  // kept), apply the draft's settings + staged navigation, clear the draft
300
304
  applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
305
+ /** publish section layouts only (one section's edits); the rest of the draft stays staged */
306
+ publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }): Promise<void>;
301
307
  discardDesignDraft(): Promise<void>;
302
308
  listDesignVersions(limit?: number): Promise<DesignVersion[]>;
303
309
  getDesignVersion(id: string): Promise<DesignVersion | null>;
@@ -0,0 +1,5 @@
1
+ import type { Role } from './schema';
2
+
3
+ export const roleLabels: Record<Role, string> = {
4
+ contributor: 'Contributor', author: 'Author', editor: 'Editor', administrator: 'Administrator', owner: 'Owner',
5
+ };
@@ -2,7 +2,8 @@ import { syncDueLive } from './live/service';
2
2
  import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
- import { sendDueReminders } from './booking/service';
5
+ import { decideOverdue, sendDueReminders } from './booking/service';
6
+ import { runPushJobs } from './push/service';
6
7
 
7
8
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
8
9
  // this heartbeat: monitoring must prove the independent scheduler ran.
@@ -13,8 +14,12 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
13
14
  publish: () => repo.publishDuePosts(startedAt),
14
15
  webhooks: () => processDueDeliveries(repo),
15
16
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
17
+ // pending bookings past their approval deadline (Calendar settings → Bookings)
18
+ approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
16
19
  email: () => dispatchEmail(repo, locals, 10),
17
20
  live: () => syncDueLive(repo, locals),
21
+ // queued first-publish notices, starting-soon reminders, receipts, retention
22
+ push: () => runPushJobs(repo, { locals, now: startedAt }),
18
23
  };
19
24
  const results: Record<string, { ok: boolean; count?: number }> = {};
20
25
  for (const [name, run] of Object.entries(jobs)) {
package/src/lib/schema.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LiveConfig } from './live/types';
2
+ import { PushSettings } from './push/types';
2
3
  import { SectionPages } from '../theme-sdk/contract.mjs';
3
4
  import { AiConfig, AgentConfig } from './ai-schema';
4
5
  import { z } from 'zod';
@@ -64,6 +65,26 @@ export function parseConfig<T extends WidgetType>(type: T, raw: unknown): z.infe
64
65
  export const Status = z.enum(['draft', 'scheduled', 'published', 'archived']);
65
66
  export type Status = z.infer<typeof Status>;
66
67
 
68
+ // Per-language words for a post, page or event (the facts stay shared):
69
+ // translations: { es: { title, excerpt, bodyHtml, seoTitle, seoDesc, slug,
70
+ // locationName, ctaLabel, meta: { title: { state, src } } } }
71
+ // state 'ai' = machine draft (shown, flagged for review), 'reviewed' = a person
72
+ // wrote/approved it; src = hash of the main-language text it was made from.
73
+ export const DocTranslation = z.object({
74
+ title: z.string().max(1000).default(''),
75
+ excerpt: z.string().max(5000).default(''),
76
+ bodyHtml: z.string().max(2_000_000).default(''),
77
+ seoTitle: z.string().max(1000).default(''),
78
+ seoDesc: z.string().max(5000).default(''),
79
+ slug: z.string().max(300).default(''),
80
+ locationName: z.string().max(500).default(''),
81
+ ctaLabel: z.string().max(300).default(''),
82
+ meta: z.record(z.string().max(40), z.object({ state: z.enum(['ai', 'reviewed']), src: z.string().max(20) })).default({}),
83
+ });
84
+ export type DocTranslation = z.infer<typeof DocTranslation>;
85
+ export const DocTranslations = z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), DocTranslation).default({});
86
+ export type DocTranslations = z.infer<typeof DocTranslations>;
87
+
67
88
  export const Page = z.object({
68
89
  id: z.string(),
69
90
  slug: z.string(),
@@ -83,6 +104,7 @@ export const Page = z.object({
83
104
  // theme template name (templates/page-<name>.astro); empty = default layout
84
105
  template: z.string().default(''),
85
106
  // pending edits on a live page (see lib/drafts.ts); null = none staged
107
+ translations: DocTranslations,
86
108
  draft: z.record(z.any()).nullable().default(null),
87
109
  createdAt: z.number(),
88
110
  updatedAt: z.number(),
@@ -117,6 +139,7 @@ export const Post = z.object({
117
139
  // theme template name (templates/post-<name>.astro); empty = default layout
118
140
  template: z.string().default(''),
119
141
  // pending edits on a live post (see lib/drafts.ts); null = none staged
142
+ translations: DocTranslations,
120
143
  draft: z.record(z.any()).nullable().default(null),
121
144
  publishedAt: z.number().nullable(),
122
145
  scheduledAt: z.number().nullable().default(null),
@@ -171,7 +194,7 @@ export const Snippet = z.object({
171
194
  });
172
195
  export type Snippet = z.infer<typeof Snippet>;
173
196
 
174
- export const Role = z.enum(['owner', 'editor']);
197
+ export const Role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
175
198
  export type Role = z.infer<typeof Role>;
176
199
 
177
200
  // Password hashes never leave the repo layer — User is the public shape.
@@ -291,6 +314,17 @@ export const EventCta = z.object({
291
314
  });
292
315
  export type EventCta = z.infer<typeof EventCta>;
293
316
 
317
+ export const RoomHold = z.object({
318
+ resourceIds: z.array(z.string()).default([]),
319
+ span: z.enum(['event', 'day']).default('event'),
320
+ before: z.number().int().min(0).max(1440).default(0), // setup minutes
321
+ after: z.number().int().min(0).max(1440).default(0), // cleanup minutes
322
+ whileDraft: z.boolean().default(false),
323
+ // bookings you chose to keep (share the room): no longer flagged as conflicts
324
+ keep: z.array(z.string()).default([]),
325
+ });
326
+ export type RoomHold = z.infer<typeof RoomHold>;
327
+
294
328
  export const CalEvent = z.object({
295
329
  id: z.string(),
296
330
  slug: z.string(),
@@ -332,8 +366,13 @@ export const CalEvent = z.object({
332
366
  autoConfirm: z.boolean().default(true),
333
367
  maxSeats: z.number().int().default(4),
334
368
  }).default({}),
369
+ // rooms/people this event uses: they can't be booked while it happens (or
370
+ // all day). Computed into availability, never stored as bookings. Drafts
371
+ // only hold when whileDraft is on; cancelled events never hold.
372
+ roomHold: RoomHold.default({}),
335
373
  startAt: z.number(),
336
374
  endAt: z.number(),
375
+ translations: DocTranslations,
337
376
  draft: z.record(z.any()).nullable().default(null),
338
377
  publishedAt: z.number().nullable().default(null),
339
378
  scheduledAt: z.number().nullable().default(null),
@@ -420,6 +459,36 @@ export type BookingQuestion = z.infer<typeof BookingQuestion>;
420
459
  export const BookingMode = z.enum(['slots', 'range', 'day']);
421
460
  export type BookingMode = z.infer<typeof BookingMode>;
422
461
 
462
+ // Booking approval: "auto" confirms at once, "manual" holds every booking for
463
+ // a decision, "rules" confirms unless one of the rules flags it. A link can
464
+ // follow the site default ("default") or pick its own. The deadline decides
465
+ // pending bookings nobody answered (0 hours = keep waiting).
466
+ export const ApprovalMode = z.enum(['auto', 'rules', 'manual']);
467
+ export type ApprovalMode = z.infer<typeof ApprovalMode>;
468
+ export const ApprovalRules = z.object({
469
+ // 0 = rule off
470
+ longerThan: z.number().int().min(0).default(0), // minutes
471
+ firstTime: z.boolean().default(false),
472
+ moreSeats: z.number().int().min(0).default(0),
473
+ deadlineHours: z.number().int().min(0).max(24 * 60).default(0),
474
+ deadlineAction: z.enum(['approve', 'decline']).default('approve'),
475
+ });
476
+ export type ApprovalRules = z.infer<typeof ApprovalRules>;
477
+
478
+ // Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
479
+ export const CalendarSettings = z.object({
480
+ // first column of every month grid: 0 = Sunday, 1 = Monday
481
+ weekStart: z.union([z.literal(0), z.literal(1)]).default(0),
482
+ // calendar new events land in (quick add, New event); '' = none
483
+ defaultCalendarId: z.string().default(''),
484
+ // '' = not chosen in the admin yet → acms({ calendar.bookings.approval }) → auto
485
+ approval: z.union([ApprovalMode, z.literal('')]).default(''),
486
+ approvalRules: ApprovalRules.default({}),
487
+ // show bookings on the Schedule grid
488
+ showBookings: z.boolean().default(true),
489
+ });
490
+ export type CalendarSettings = z.infer<typeof CalendarSettings>;
491
+
423
492
  export const BookingLink = z.object({
424
493
  id: z.string(),
425
494
  resourceId: z.string(),
@@ -442,6 +511,9 @@ export const BookingLink = z.object({
442
511
  hours: WeeklyHours.nullable().default(null),
443
512
  // confirmed at once (and on the calendar) vs. held as pending for approval
444
513
  autoConfirm: z.boolean().default(true),
514
+ // supersedes autoConfirm: 'default' follows Calendar settings → Bookings
515
+ approval: z.union([ApprovalMode, z.literal('default')]).default('default'),
516
+ approvalRules: ApprovalRules.default({}),
445
517
  visibility: z.enum(['public', 'unlisted']).default('public'),
446
518
  askPhone: z.boolean().default(false),
447
519
  questions: z.array(BookingQuestion).default([]),
@@ -560,6 +632,9 @@ export function normalizeRedirectPath(input: string): string {
560
632
 
561
633
  export const Settings = z.object({
562
634
  live: LiveConfig.default({}),
635
+ calendar: CalendarSettings.default({}),
636
+ // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
637
+ push: PushSettings.default({}),
563
638
  siteTitle: z.string().default('My Site'),
564
639
  tagline: z.string().default(''),
565
640
  // site-wide footer, markdown allowed; empty falls back to "title — tagline"
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ import sanitizeHtml from 'sanitize-html';
3
+ import type { AiConfig } from './ai-schema';
4
+ import { completeText } from './ai';
5
+ import { ApiError } from './api';
6
+
7
+ // "Help me write" for section fields: rewrite one field's text with the
8
+ // site's configured AI provider. The owner reviews the suggestion before it
9
+ // replaces anything; nothing is saved here.
10
+
11
+ export const AssistInput = z.object({
12
+ action: z.enum(['shorter', 'friendlier', 'clearer', 'translate', 'draft']),
13
+ text: z.string().max(20_000).default(''),
14
+ label: z.string().max(100),
15
+ kind: z.enum(['text', 'textarea', 'richtext']).default('text'),
16
+ hint: z.string().max(300).optional(),
17
+ maxLength: z.number().int().min(1).max(20_000).optional(),
18
+ language: z.string().max(40).optional(),
19
+ // other filled-in fields of the same section, for context ("Heading: Join us")
20
+ context: z.string().max(4000).optional(),
21
+ }).strict();
22
+
23
+ const ACTIONS: Record<z.infer<typeof AssistInput>['action'], string> = {
24
+ shorter: 'Make it noticeably shorter while keeping every fact (names, days, times, places, prices).',
25
+ friendlier: 'Make it warmer and more welcoming, plain words, no hype or exclamation marks.',
26
+ clearer: 'Fix spelling and grammar and make it clearer. Change as little as possible.',
27
+ translate: 'Translate it into the requested language. Keep names of people and places as they are.',
28
+ draft: 'Write it from scratch for this field, using the other fields as context. Be concrete and plain.',
29
+ };
30
+
31
+ export async function assistField(config: AiConfig, raw: unknown): Promise<{ text: string }> {
32
+ const a = AssistInput.parse(raw);
33
+ if (a.action !== 'draft' && !a.text.trim()) throw new ApiError(400, 'Write something first, or use “Write a first draft”.');
34
+ if (a.action === 'translate' && !a.language) throw new ApiError(400, 'Choose a language.');
35
+ const rules = [
36
+ 'You edit short text for a section of a website (for example a church, club or small business homepage).',
37
+ `The field is “${a.label}”.`,
38
+ ACTIONS[a.action],
39
+ a.action === 'translate' ? `Target language: ${a.language}.` : 'Keep the original language.',
40
+ a.hint ? `The site owner's guidance for this field: ${a.hint}` : '',
41
+ a.maxLength ? `Stay under ${a.maxLength} characters.` : '',
42
+ a.kind === 'richtext' ? 'Return simple HTML using only <p>, <strong>, <em>, <a href>, <ul>, <ol>, <li>. Keep existing links.' : 'Return plain text only.',
43
+ a.kind === 'text' ? 'One line, no line breaks.' : '',
44
+ 'Return only the new text: no quotes, labels, notes or alternatives. Never invent facts, dates, prices or contact details.',
45
+ ].filter(Boolean).join('\n');
46
+ const input = [a.context ? `Other fields in this section:\n${a.context}` : '', a.text ? `Current text:\n${a.text}` : 'The field is empty.'].filter(Boolean).join('\n\n');
47
+ let out = (await completeText(config, rules, input)).replace(/^```\w*\n?|\n?```$/g, '').trim();
48
+ if (/^["“].*["”]$/s.test(out)) out = out.slice(1, -1).trim();
49
+ out = a.kind === 'richtext'
50
+ ? sanitizeHtml(out, { allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li'], allowedAttributes: { a: ['href'] }, allowedSchemes: ['http', 'https', 'mailto', 'tel'] })
51
+ : sanitizeHtml(out, { allowedTags: [], allowedAttributes: {} }).replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
52
+ if (a.kind === 'text') out = out.replace(/\s*\n+\s*/g, ' ');
53
+ if (!out) throw new ApiError(502, 'The AI returned nothing useful. Try again.');
54
+ return { text: out };
55
+ }
@@ -0,0 +1,62 @@
1
+ import type { ContentRepository } from './repo/types';
2
+ import { CONTENT, collectionUrl } from './content';
3
+ import { I18N, extraLocales, localeName } from './i18n';
4
+ import { translationEngine } from './translate';
5
+ import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl } from './calendar/config';
6
+
7
+ const NAMES: Record<string, string> = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ko: 'Korean', zh: 'Chinese', ja: 'Japanese', tl: 'Tagalog', vi: 'Vietnamese', ar: 'Arabic', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish' };
8
+ // the site's own languages first, then common ones
9
+ const languageNames = () => [...new Set([...(I18N.enabled ? I18N.locales : []).map((l) => NAMES[l.split('-')[0]] ?? l), 'English', 'Spanish', 'French', 'Portuguese', 'Korean', 'Chinese'])];
10
+
11
+ // Choices for the Design screen's section fields: what a link field can point
12
+ // at (stored as a ref so it follows slug changes) and what a source field can
13
+ // show. Published/public things only — this is what visitors can reach.
14
+
15
+ export interface LinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' | 'Booking' }
16
+ export interface SectionTargets {
17
+ links: LinkTarget[];
18
+ calendars: { slug: string; name: string }[];
19
+ collections: { key: string; label: string }[];
20
+ tags: string[];
21
+ resources: { slug: string; name: string }[];
22
+ features: { events: boolean; bookings: boolean };
23
+ /** AI help for text fields: provider configured + languages to translate into */
24
+ ai: { enabled: boolean; languages: string[] };
25
+ /** extra site languages to translate into, and whether machine drafts are available */
26
+ i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
27
+ }
28
+
29
+ export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
30
+ const bookings = bookingsEnabled();
31
+ const settings = await repo.getSettings();
32
+ const [pages, posts, events, calendars, tags, resources, links] = await Promise.all([
33
+ repo.listPages(),
34
+ repo.listPublishedPosts(),
35
+ CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
36
+ CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
37
+ repo.listTags(),
38
+ bookings ? repo.listResources() : Promise.resolve([]),
39
+ bookings ? repo.listBookingLinks() : Promise.resolve([]),
40
+ ]);
41
+ const publicResources = resources.filter((r) => r.visibility === 'public');
42
+ const bySlug = new Map(resources.map((r) => [r.id, r]));
43
+ const out: LinkTarget[] = [
44
+ ...pages.filter((p) => p.status === 'published').map((p): LinkTarget => ({ ref: `page:${p.id}`, label: p.title || p.slug, url: p.isHome ? '/' : `/${p.slug}`, group: 'Pages' })),
45
+ ...posts.slice(0, 200).map((p): LinkTarget => ({ ref: `post:${p.id}`, label: p.title, url: collectionUrl(p.collection, p.slug), group: 'Posts' })),
46
+ ...events.slice(0, 200).map((e): LinkTarget => ({ ref: `event:${e.id}`, label: e.title, url: eventUrl(e.slug), group: 'Events' })),
47
+ ...links.filter((l) => l.active && bySlug.has(l.resourceId)).map((l): LinkTarget => {
48
+ const r = bySlug.get(l.resourceId)!;
49
+ return { ref: `booking:${l.id}`, label: `${r.name} · ${l.name}`, url: bookingLinkUrl(r.slug, l.slug), group: 'Booking' };
50
+ }),
51
+ ];
52
+ return {
53
+ links: out,
54
+ calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
55
+ collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
56
+ tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
57
+ resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
58
+ features: { events: CALENDAR.enabled, bookings },
59
+ ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
60
+ i18n: { defaultLocale: I18N.defaultLocale, locales: extraLocales().map((code) => ({ code, name: localeName(code, true) })), drafts: translationEngine(settings.ai, locals) !== null },
61
+ };
62
+ }