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

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 (251) hide show
  1. package/CHANGELOG.md +69 -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/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,144 @@
1
+ import type { SqlDriver } from '../repo/sql';
2
+ import type { PushDevice, PushNotification, PushQueueRow, PushStats, PushPlatform } from './types';
3
+
4
+ type Row = Record<string, unknown>;
5
+ const list = (raw: unknown): string[] => { try { const v = JSON.parse(String(raw ?? '[]')); return Array.isArray(v) ? v.map(String) : []; } catch { return []; } };
6
+ function device(r: Row): PushDevice {
7
+ return { id: String(r.id), token: String(r.token), platform: r.platform as PushPlatform, locale: String(r.locale ?? ''), topics: list(r.topics),
8
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at), lastSeenAt: Number(r.last_seen_at) };
9
+ }
10
+ function notification(r: Row): PushNotification {
11
+ return { id: String(r.id), title: String(r.title), body: String(r.body ?? ''), url: String(r.url ?? ''), topics: list(r.topics),
12
+ source: r.source as PushNotification['source'], sourceKey: String(r.source_key ?? ''), status: r.status as PushNotification['status'],
13
+ recipients: Number(r.recipients), sent: Number(r.sent), ok: Number(r.ok), failed: Number(r.failed), removed: Number(r.removed ?? 0),
14
+ error: String(r.error ?? ''), author: String(r.author ?? ''), createdAt: Number(r.created_at), finishedAt: r.finished_at == null ? null : Number(r.finished_at) };
15
+ }
16
+ // audience filter: ['*'] = every device, otherwise any shared topic
17
+ function audienceWhere(topics: string[]): { sql: string; args: unknown[] } {
18
+ if (topics.includes('*')) return { sql: '1', args: [] };
19
+ if (!topics.length) return { sql: '0', args: [] };
20
+ return { sql: `EXISTS (SELECT 1 FROM json_each(push_devices.topics) t WHERE t.value IN (${topics.map(() => '?').join(',')}))`, args: topics };
21
+ }
22
+
23
+ // Storage for devices, the delivery log, Expo tickets and the automatic
24
+ // notification queue. Shared by the D1 and libSQL adapters (plain SQLite).
25
+ export class PushStore {
26
+ constructor(private driver: () => Promise<SqlDriver>) {}
27
+ private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
28
+ private async run(sql: string, args: unknown[] = []) { return (await this.driver()).run(sql, args); }
29
+
30
+ // ---- devices ----
31
+ /** Register or refresh. `topics` undefined keeps a known device's topics (new devices start with none). */
32
+ async upsertDevice(d: { id: string; token: string; platform: PushPlatform; locale: string; topics?: string[] }, now: number): Promise<{ device: PushDevice; created: boolean }> {
33
+ const topics = d.topics === undefined ? null : JSON.stringify(d.topics);
34
+ // RETURNING also yields the previous row's created_at on the update branch,
35
+ // so "created" is exact: only the insert branch writes created_at = now.
36
+ const rows = await this.all(`INSERT INTO push_devices (id,token,platform,locale,topics,created_at,updated_at,last_seen_at) VALUES (?,?,?,?,COALESCE(?,'[]'),?,?,?)
37
+ ON CONFLICT(id) DO UPDATE SET platform=excluded.platform, locale=excluded.locale, topics=COALESCE(?,push_devices.topics),
38
+ updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at RETURNING *`, [d.id, d.token, d.platform, d.locale, topics, now, now, now, topics]);
39
+ const row = device(rows[0]);
40
+ return { device: row, created: row.createdAt === now };
41
+ }
42
+ async updateDevice(id: string, data: { locale?: string; topics?: string[] }, now: number): Promise<PushDevice | null> {
43
+ const rows = await this.all(`UPDATE push_devices SET locale=COALESCE(?,locale), topics=COALESCE(?,topics), updated_at=?, last_seen_at=? WHERE id=? RETURNING *`,
44
+ [data.locale ?? null, data.topics ? JSON.stringify(data.topics) : null, now, now, id]);
45
+ return rows[0] ? device(rows[0]) : null;
46
+ }
47
+ async getDevice(id: string) { const r = await this.all('SELECT * FROM push_devices WHERE id=?', [id]); return r[0] ? device(r[0]) : null; }
48
+ async deleteDevices(ids: string[]) {
49
+ for (let i = 0; i < ids.length; i += 90) {
50
+ const chunk = ids.slice(i, i + 90);
51
+ await this.run(`DELETE FROM push_devices WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
52
+ }
53
+ }
54
+ async audience(topics: string[]): Promise<PushDevice[]> {
55
+ const w = audienceWhere(topics);
56
+ return (await this.all(`SELECT * FROM push_devices WHERE ${w.sql} ORDER BY created_at`, w.args)).map(device);
57
+ }
58
+ async countAudience(topics: string[]): Promise<number> {
59
+ const w = audienceWhere(topics);
60
+ return Number((await this.all(`SELECT COUNT(*) AS n FROM push_devices WHERE ${w.sql}`, w.args))[0]?.n ?? 0);
61
+ }
62
+ async stats(now: number): Promise<PushStats> {
63
+ const [totals, topics] = await Promise.all([
64
+ this.all(`SELECT COUNT(*) AS n, SUM(platform='ios') AS ios, SUM(platform='android') AS android, SUM(last_seen_at>=?) AS active FROM push_devices`, [now - 30 * 86400000]),
65
+ this.all(`SELECT t.value AS topic, COUNT(*) AS n FROM push_devices, json_each(push_devices.topics) t GROUP BY t.value`),
66
+ ]);
67
+ const t = totals[0] ?? {};
68
+ return { devices: Number(t.n ?? 0), platforms: { ios: Number(t.ios ?? 0), android: Number(t.android ?? 0) }, active30: Number(t.active ?? 0),
69
+ topics: Object.fromEntries(topics.map((r) => [String(r.topic), Number(r.n)])) };
70
+ }
71
+
72
+ // ---- delivery log ----
73
+ async createNotification(n: PushNotification) {
74
+ await this.run(`INSERT INTO push_notifications (id,title,body,url,topics,source,source_key,status,recipients,sent,ok,failed,removed,error,author,created_at,finished_at)
75
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [n.id, n.title, n.body, n.url, JSON.stringify(n.topics), n.source, n.sourceKey, n.status,
76
+ n.recipients, n.sent, n.ok, n.failed, n.removed, n.error, n.author, n.createdAt, n.finishedAt]);
77
+ }
78
+ async finishNotification(id: string, d: Pick<PushNotification, 'status' | 'sent' | 'ok' | 'failed' | 'removed' | 'error' | 'finishedAt'>) {
79
+ await this.run('UPDATE push_notifications SET status=?,sent=?,ok=?,failed=?,removed=?,error=?,finished_at=? WHERE id=?',
80
+ [d.status, d.sent, d.ok, d.failed, d.removed, d.error, d.finishedAt, id]);
81
+ }
82
+ /** a later receipt turned an accepted ticket into a failure */
83
+ async receiptFailed(counts: Map<string, { failed: number; removed: number }>) {
84
+ for (const [id, c] of counts) await this.run('UPDATE push_notifications SET ok=MAX(ok-?,0), failed=failed+?, removed=removed+? WHERE id=?', [c.failed, c.failed, c.removed, id]);
85
+ }
86
+ async listNotifications(limit = 50) { return (await this.all('SELECT * FROM push_notifications ORDER BY created_at DESC LIMIT ?', [limit])).map(notification); }
87
+ async getNotification(id: string) { const r = await this.all('SELECT * FROM push_notifications WHERE id=?', [id]); return r[0] ? notification(r[0]) : null; }
88
+
89
+ // ---- Expo tickets awaiting receipts ----
90
+ async addTickets(rows: { id: string; notificationId: string; deviceId: string }[], now: number) {
91
+ // D1 allows 100 bound parameters per statement: 4 per row
92
+ for (let i = 0; i < rows.length; i += 25) {
93
+ const chunk = rows.slice(i, i + 25);
94
+ await this.run(`INSERT OR IGNORE INTO push_tickets (id,notification_id,device_id,created_at) VALUES ${chunk.map(() => '(?,?,?,?)').join(',')}`,
95
+ chunk.flatMap((t) => [t.id, t.notificationId, t.deviceId, now]));
96
+ }
97
+ }
98
+ async dueTickets(before: number, limit: number) {
99
+ return (await this.all('SELECT * FROM push_tickets WHERE created_at<=? ORDER BY created_at LIMIT ?', [before, limit]))
100
+ .map((r) => ({ id: String(r.id), notificationId: String(r.notification_id), deviceId: String(r.device_id), createdAt: Number(r.created_at) }));
101
+ }
102
+ async deleteTickets(ids: string[]) {
103
+ for (let i = 0; i < ids.length; i += 90) {
104
+ const chunk = ids.slice(i, i + 90);
105
+ await this.run(`DELETE FROM push_tickets WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
106
+ }
107
+ }
108
+
109
+ // ---- automatic notification queue ----
110
+ /** Claims up to `limit` pending rows (status → sending); rows queued before `staleBefore` are skipped instead. */
111
+ async claimPending(now: number, staleBefore: number, limit: number): Promise<PushQueueRow[]> {
112
+ await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND created_at<?`, [now, staleBefore]);
113
+ const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' ORDER BY created_at LIMIT ?`, [limit]);
114
+ const out: PushQueueRow[] = [];
115
+ for (const { key } of due) {
116
+ const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' RETURNING *`, [now, key]);
117
+ if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence), status: 'sending', createdAt: Number(r[0].created_at) });
118
+ }
119
+ return out;
120
+ }
121
+ /** Inserts a new key as `sending`; false when it already exists (dedupe for "starting soon"). */
122
+ async claimKey(key: string, kind: PushQueueRow['kind'], docId: string, occurrence: number | null, now: number): Promise<boolean> {
123
+ const r = await this.all(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
124
+ VALUES (?,?,?,?,'sending',NULL,?,?) ON CONFLICT(key) DO NOTHING RETURNING key`, [key, kind, docId, occurrence, now, now]);
125
+ return r.length > 0;
126
+ }
127
+ async markQueue(key: string, status: 'sent' | 'skipped' | 'failed', notificationId: string | null, now: number) {
128
+ await this.run('UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=?', [status, notificationId, now, key]);
129
+ }
130
+ async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
131
+ async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
132
+
133
+ // ---- retention ----
134
+ /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets and old occurrence keys. */
135
+ async purge(now: number, devicesBefore: number): Promise<number> {
136
+ const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
137
+ await (await this.driver()).batch([
138
+ { sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
139
+ { sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
140
+ { sql: `DELETE FROM push_queue WHERE kind='soon' AND updated_at<?`, args: [now - 30 * 86400000] },
141
+ ]);
142
+ return gone.length;
143
+ }
144
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+
3
+ // '*' in a calendars list = every public event
4
+ const keys = z.array(z.string().min(1).max(200)).max(100);
5
+ export const PushAuto = z.object({
6
+ /** collection keys whose first publish sends a notification */
7
+ newPost: z.object({ collections: keys.default([]) }).default({}),
8
+ /** calendar ids (or '*') whose events notify on first publish */
9
+ newEvent: z.object({ calendars: keys.default([]) }).default({}),
10
+ /** "starting soon" reminders, sent `minutes` before each occurrence */
11
+ startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
12
+ });
13
+ export type PushAuto = z.infer<typeof PushAuto>;
14
+ export const PushSettings = z.object({ auto: PushAuto.default({}) });
15
+ export type PushSettings = z.infer<typeof PushSettings>;
16
+
17
+ export type PushPlatform = 'ios' | 'android';
18
+ export interface PushDevice {
19
+ id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
20
+ createdAt: number; updatedAt: number; lastSeenAt: number;
21
+ }
22
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon';
23
+ export interface PushNotification {
24
+ id: string; title: string; body: string; url: string;
25
+ /** ['*'] = everyone */
26
+ topics: string[];
27
+ source: PushSource; sourceKey: string;
28
+ status: 'sending' | 'sent' | 'failed';
29
+ recipients: number; sent: number; ok: number; failed: number;
30
+ /** devices removed because Expo said they are no longer registered */
31
+ removed: number;
32
+ error: string; author: string; createdAt: number; finishedAt: number | null;
33
+ }
34
+ export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon'; docId: string; occurrence: number | null; status: string; createdAt: number }
35
+ export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
36
+ export interface PushStats {
37
+ devices: number;
38
+ platforms: Record<PushPlatform, number>;
39
+ /** devices per topic id */
40
+ topics: Record<string, number>;
41
+ /** devices whose app checked in during the last 30 days */
42
+ active30: number;
43
+ }
@@ -2,6 +2,8 @@ import { LIVE_MIGRATION } from '../live/migration';
2
2
  import { IMPORT_MIGRATION } from '../import/migration';
3
3
  import { EMAIL_MIGRATION } from '../email/migration';
4
4
  import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
+ import { PUSH_MIGRATION } from '../push/migration';
6
+ import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
5
7
  // Versioned schema migrations shared by the two SQLite-dialect adapters
6
8
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
7
9
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -501,6 +503,15 @@ export const MIGRATIONS: Migration[] = [
501
503
  WEBHOOK_MIGRATION,
502
504
  LIVE_MIGRATION,
503
505
  IMPORT_MIGRATION,
506
+ // translations: per-language words for posts, pages and events (facts stay shared)
507
+ { version: 36, statements: [
508
+ `ALTER TABLE posts ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
509
+ `ALTER TABLE pages ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
510
+ `ALTER TABLE events ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
511
+ ] },
512
+ PUSH_MIGRATION,
513
+ EDITOR_MIGRATION,
514
+ EDITOR_GRACE_MIGRATION,
504
515
  ];
505
516
 
506
517
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -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),
@@ -138,7 +145,7 @@ function rowToEvent(r: Row): CalEvent {
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
147
  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),
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),
@@ -233,10 +240,14 @@ export class SqlRepository implements ContentRepository {
233
240
  private ready: Promise<void>;
234
241
  readonly email: EmailStore;
235
242
  readonly live: LiveStore;
243
+ readonly push: PushStore;
244
+ readonly editor: EditorStore;
236
245
 
237
246
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
238
247
  this.ready = this.init(driver);
239
248
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
249
+ this.push = new PushStore(async () => { await this.ready; return this.driver; });
250
+ this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
240
251
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
241
252
  }
242
253
 
@@ -466,6 +477,7 @@ export class SqlRepository implements ContentRepository {
466
477
  if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
467
478
  if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
468
479
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
480
+ if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
469
481
  if (data.blocks !== undefined) { sets.push('blocks = ?'); args.push(JSON.stringify(data.blocks)); }
470
482
  if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
471
483
  if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
@@ -598,6 +610,7 @@ export class SqlRepository implements ContentRepository {
598
610
  if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
599
611
  if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
600
612
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
613
+ if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
601
614
  // authorIds is the source of truth; author_id mirrors the first entry.
602
615
  // Main row, join rows and webhook notifications share one transaction.
603
616
  let authorJoin: string[] | null = null;
@@ -832,7 +845,7 @@ export class SqlRepository implements ContentRepository {
832
845
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
833
846
  }
834
847
  const jsonCols: Record<string, string> = {
835
- tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
848
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
836
849
  };
837
850
  for (const [k, col] of Object.entries(jsonCols)) {
838
851
  const v = (data as Record<string, unknown>)[k];
@@ -1633,6 +1646,10 @@ export class SqlRepository implements ContentRepository {
1633
1646
  args: [p.id, aid, i] as unknown[],
1634
1647
  }))
1635
1648
  ),
1649
+ // translations (v36): written after the rows so older backups need no change
1650
+ ...[['pages', content.pages], ['posts', content.posts], ['events', content.events ?? []]].flatMap(([table, rows]) =>
1651
+ (rows as { id: string; translations?: Record<string, unknown> }[]).filter((r) => r.translations && Object.keys(r.translations).length)
1652
+ .map((r) => ({ sql: `UPDATE ${table} SET translations = ? WHERE id = ?`, args: [JSON.stringify(r.translations), r.id] as unknown[] }))),
1636
1653
  ...content.media.map((m) => ({
1637
1654
  sql: 'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1638
1655
  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 +1835,7 @@ export class SqlRepository implements ContentRepository {
1818
1835
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
1819
1836
  ai: JSON.parse(String(r?.ai ?? '{}')),
1820
1837
  live: JSON.parse(String(r?.live ?? '{}')),
1838
+ push: JSON.parse(String(r?.push ?? '{}')),
1821
1839
  agent: JSON.parse(String(r?.agent ?? '{}')),
1822
1840
  draftsApiKey: r?.drafts_api_key ?? '',
1823
1841
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1834,9 +1852,9 @@ export class SqlRepository implements ContentRepository {
1834
1852
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1835
1853
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1836
1854
  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',
1855
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
1838
1856
  };
1839
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
1857
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
1840
1858
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1841
1859
  if (!entries.length) return;
1842
1860
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -1901,6 +1919,21 @@ export class SqlRepository implements ContentRepository {
1901
1919
  } }));
1902
1920
  await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
1903
1921
  }
1922
+ async publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }) {
1923
+ const current = await this.getSettings();
1924
+ const liveNav = await this.listNavItems();
1925
+ const draft: Record<string, unknown> = { ...(current.draftDesign ?? {}) };
1926
+ if (staged) draft.sectionPages = staged; else delete draft.sectionPages;
1927
+ // a draft with nothing left to publish goes away entirely
1928
+ const remaining = Object.keys(pickDesignDraft(draft)).length > 0 || Array.isArray(draft.navItems);
1929
+ const statements: SqlStatement[] = [
1930
+ { 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))] },
1931
+ { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1932
+ { sql: 'UPDATE settings SET section_pages=?,draft_design=? WHERE id=1', args: [JSON.stringify(live), remaining ? JSON.stringify({ ...draft, savedAt: Date.now() }) : null] },
1933
+ webhookStatement('design.published', { design: { revision: current.designRevision + 1, activeTheme: current.activeTheme, navigationChanged: false, sectionPageIds: Object.keys(live) } }),
1934
+ ];
1935
+ await this.designWrite(opts.expectedRevision, opts.author ?? 'admin', 'publish', statements);
1936
+ }
1904
1937
  async discardDesignDraft() { await this.setDesignDraft(null); }
1905
1938
  async listDesignVersions(limit = 20) {
1906
1939
  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'
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>;
@@ -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
+ };
@@ -3,6 +3,7 @@ import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
5
  import { 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.
@@ -15,6 +16,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
15
16
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
16
17
  email: () => dispatchEmail(repo, locals, 10),
17
18
  live: () => syncDueLive(repo, locals),
19
+ // queued first-publish notices, starting-soon reminders, receipts, retention
20
+ push: () => runPushJobs(repo, { locals, now: startedAt }),
18
21
  };
19
22
  const results: Record<string, { ok: boolean; count?: number }> = {};
20
23
  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.
@@ -334,6 +357,7 @@ export const CalEvent = z.object({
334
357
  }).default({}),
335
358
  startAt: z.number(),
336
359
  endAt: z.number(),
360
+ translations: DocTranslations,
337
361
  draft: z.record(z.any()).nullable().default(null),
338
362
  publishedAt: z.number().nullable().default(null),
339
363
  scheduledAt: z.number().nullable().default(null),
@@ -560,6 +584,8 @@ export function normalizeRedirectPath(input: string): string {
560
584
 
561
585
  export const Settings = z.object({
562
586
  live: LiveConfig.default({}),
587
+ // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
588
+ push: PushSettings.default({}),
563
589
  siteTitle: z.string().default('My Site'),
564
590
  tagline: z.string().default(''),
565
591
  // 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
+ }