@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
@@ -0,0 +1,302 @@
1
+ // Push notifications: topics, audiences, the Expo sender pipeline, automatic
2
+ // notices (first publish of a post/event, "starting soon") and receipts.
3
+ import type { ContentRepository } from '../repo/types';
4
+ import type { CalEvent, Calendar, Post, Settings } from '../schema';
5
+ import { CONTENT, collectionUrl } from '../content';
6
+ import { CALENDAR, eventUrl } from '../calendar/config';
7
+ import { eventIsListed, eventTimeZone, expandEvents } from '../calendar/events';
8
+ import { I18N } from '../i18n';
9
+ import { localizeDoc } from '../translations';
10
+ import { localizedUrl } from '../mobile/locale';
11
+ import { PUSH } from './config';
12
+ import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
13
+ import type { PushNotification, PushSource, PushTopic } from './types';
14
+
15
+ const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
16
+ /** automatic notices queued longer ago than this are skipped, never sent late */
17
+ export const STALE_AFTER = 6 * HOUR;
18
+ /** Expo recommends waiting before reading receipts */
19
+ export const RECEIPT_DELAY = 15 * MINUTE;
20
+
21
+ // ---- topics ------------------------------------------------------------------
22
+
23
+ /**
24
+ * Topics an app can offer as toggles. `news` = every new post, `events` = every
25
+ * new event. Per-collection and per-calendar topics appear once there is more
26
+ * than one of them (a single one would just repeat news/events).
27
+ */
28
+ export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]> {
29
+ const collections = Object.entries(CONTENT.collections);
30
+ const out: PushTopic[] = [{ id: 'news', label: 'News', kind: 'general' }];
31
+ if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
32
+ if (CALENDAR.enabled) {
33
+ out.push({ id: 'events', label: 'Events', kind: 'general' });
34
+ const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
35
+ if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Every topic a device may subscribe to (listed or not). */
41
+ export async function knownTopics(repo: ContentRepository): Promise<Set<string>> {
42
+ const set = new Set(['news', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
43
+ if (CALENDAR.enabled) {
44
+ set.add('events');
45
+ for (const c of await repo.listCalendars()) if (c.visibility === 'public') set.add(`calendar:${c.slug}`);
46
+ }
47
+ return set;
48
+ }
49
+
50
+ // ---- language ----------------------------------------------------------------
51
+
52
+ /** The site's extra locale for a device language ("es-MX" → "es"), or null for the main language. */
53
+ export function deviceLocale(raw: string): string | null {
54
+ if (!I18N.enabled || !raw) return null;
55
+ const want = raw.toLowerCase().replace('_', '-');
56
+ const match = I18N.locales.find((l) => l.toLowerCase() === want) ?? I18N.locales.find((l) => l.toLowerCase() === want.split('-')[0]);
57
+ return match && match !== I18N.defaultLocale ? match : null;
58
+ }
59
+
60
+ const STRINGS: Record<string, { startsIn: (m: number) => string; now: string }> = {
61
+ en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now' },
62
+ es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora' },
63
+ fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant' },
64
+ de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt' },
65
+ pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora' },
66
+ };
67
+ export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
68
+
69
+ const clip = (s: string, n: number) => {
70
+ const t = s.replace(/\s+/g, ' ').trim();
71
+ return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t;
72
+ };
73
+ function when(start: number, allDay: boolean, tz: string, locale: string, timeOnly = false) {
74
+ const opts: Intl.DateTimeFormatOptions = timeOnly ? { hour: 'numeric', minute: '2-digit' }
75
+ : allDay ? { weekday: 'short', month: 'short', day: 'numeric' } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
76
+ try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }).format(start); }
77
+ catch { return new Date(start).toISOString().slice(0, 16).replace('T', ' '); }
78
+ }
79
+
80
+ // ---- sending -----------------------------------------------------------------
81
+
82
+ export interface Composed { title: string; body: string; url: string }
83
+ export interface SendInput extends Composed {
84
+ /** topic ids, or ['*'] for every device */
85
+ audience: string[];
86
+ source: PushSource;
87
+ sourceKey?: string;
88
+ author?: string;
89
+ /** words for a device in an extra site language; null = use the main words */
90
+ localize?: (locale: string) => Composed | null;
91
+ data?: Record<string, unknown>;
92
+ }
93
+ export interface PushRunOptions {
94
+ /** site origin for absolute links; defaults to the Site URL setting */
95
+ origin?: string;
96
+ /** used when the Site URL setting is empty (e.g. the current request's origin) */
97
+ fallbackOrigin?: string;
98
+ now?: number;
99
+ locals?: unknown;
100
+ expo?: Partial<ExpoOptions>;
101
+ }
102
+
103
+ export function siteOriginOf(settings: Pick<Settings, 'siteUrl'>, fallback = ''): string {
104
+ const configured = settings.siteUrl.trim().replace(/\/+$/, '');
105
+ if (!configured) return fallback;
106
+ try { return new URL(configured.includes('://') ? configured : `https://${configured}`).origin; } catch { return fallback; }
107
+ }
108
+
109
+ /** Absolute URL (when the origin is known) and site path for a notification link. */
110
+ export function notificationLink(raw: string, origin: string, locale: string | null): { url: string; path: string } {
111
+ if (!raw) return { url: '', path: '' };
112
+ const base = origin || 'https://site.invalid';
113
+ let u: URL;
114
+ try { u = new URL(raw, base); } catch { return { url: '', path: '' }; }
115
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return { url: '', path: '' };
116
+ if (u.origin !== new URL(base).origin) return { url: u.href, path: '' };
117
+ if (locale) u = new URL(localizedUrl(u.href, base, locale));
118
+ const path = `${u.pathname}${u.search}${u.hash}`;
119
+ return { url: origin ? u.href : path, path };
120
+ }
121
+
122
+ export async function sendNotification(repo: ContentRepository, input: SendInput, opts: PushRunOptions = {}): Promise<PushNotification> {
123
+ const now = opts.now ?? Date.now();
124
+ const origin = opts.origin ?? siteOriginOf(await repo.getSettings(), opts.fallbackOrigin);
125
+ const devices = await repo.push.audience(input.audience);
126
+ const n: PushNotification = {
127
+ id: crypto.randomUUID(), title: input.title, body: input.body, url: input.url, topics: input.audience,
128
+ source: input.source, sourceKey: input.sourceKey ?? '', status: 'sending', recipients: devices.length,
129
+ sent: 0, ok: 0, failed: 0, removed: 0, error: '', author: input.author ?? '', createdAt: now, finishedAt: null,
130
+ };
131
+ await repo.push.createNotification(n);
132
+ const words = new Map<string, Composed>();
133
+ const messages: ExpoMessage[] = devices.map((d) => {
134
+ const locale = deviceLocale(d.locale);
135
+ let c: Composed = input;
136
+ if (locale) {
137
+ if (!words.has(locale)) words.set(locale, input.localize?.(locale) ?? input);
138
+ c = words.get(locale)!;
139
+ }
140
+ const link = notificationLink(c.url, origin, locale);
141
+ return {
142
+ to: d.token, title: clip(c.title, 120), body: clip(c.body, 240), sound: 'default', priority: 'high',
143
+ data: { ...input.data, notificationId: n.id, source: input.source, url: link.url, path: link.path, locale: locale ?? (I18N.defaultLocale || null) },
144
+ };
145
+ });
146
+ const tickets = messages.length ? await sendMessages(messages, expoOptions(opts.locals, opts.expo)) : [];
147
+ const accepted: { id: string; notificationId: string; deviceId: string }[] = [];
148
+ const gone: string[] = [];
149
+ let error = '';
150
+ tickets.forEach((t, i) => {
151
+ if (t.status === 'ok') accepted.push({ id: t.id, notificationId: n.id, deviceId: devices[i].id });
152
+ else {
153
+ // an uninstalled app is routine (counted as removed); never echo tokens into the log
154
+ if (isGone(t)) gone.push(devices[i].id);
155
+ else error ||= t.message.replace(/Expo(?:nent)?PushToken\[[^\]]*\]/g, 'a device');
156
+ }
157
+ });
158
+ await repo.push.addTickets(accepted, now);
159
+ if (gone.length) await repo.push.deleteDevices(gone);
160
+ const done: PushNotification = { ...n, status: messages.length && !accepted.length ? 'failed' : 'sent', sent: messages.length,
161
+ ok: accepted.length, failed: messages.length - accepted.length, removed: gone.length, error: clip(error, 300), finishedAt: Date.now() };
162
+ await repo.push.finishNotification(n.id, done);
163
+ return done;
164
+ }
165
+
166
+ // ---- automatic notices ---------------------------------------------------------
167
+
168
+ const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
169
+ const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
170
+ function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
171
+ return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
172
+ }
173
+ const displayLocale = (settings: Settings) => settings.locale || I18N.defaultLocale || 'en';
174
+
175
+ export function postNotice(post: Post, settings: Settings): SendInput {
176
+ const words = (p: Post): Composed => ({ title: clip(p.title, 120), body: clip(p.excerpt || p.seoDesc || settings.siteTitle, 240), url: collectionUrl(p.collection, p.slug) });
177
+ return { ...words(post), audience: ['news', `collection:${post.collection}`], source: 'post', sourceKey: `post:${post.id}`,
178
+ data: { kind: 'post', id: post.id, slug: post.slug, collection: post.collection }, localize: (l) => words(localizeDoc(post, l)) };
179
+ }
180
+
181
+ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>, now: number, soon?: { start: number; allDay: boolean }): SendInput {
182
+ const tz = eventTimeZone(e, settings);
183
+ const next = soon ?? (() => {
184
+ const o = expandEvents([e], settings, { displayTz: tz, now, windowStartMs: now, windowEndMs: now + 400 * DAY })[0];
185
+ return o ? { start: o.start, allDay: o.allDay } : { start: e.startAt, allDay: e.allDay };
186
+ })();
187
+ const words = (ev: CalEvent, locale: string): Composed => {
188
+ const place = ev.location.name ? ` · ${ev.location.name}` : '';
189
+ let body: string;
190
+ if (soon) {
191
+ const s = pushStrings(locale);
192
+ const mins = Math.max(0, Math.round((soon.start - now) / MINUTE));
193
+ body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale, true)}${place}`;
194
+ } else body = `${when(next.start, next.allDay, tz, locale)}${place}`;
195
+ return { title: clip(ev.title, 120), body: clip(body, 240), url: eventUrl(ev.slug) };
196
+ };
197
+ return { ...words(e, displayLocale(settings)), audience: eventTopics(e, calendars), source: soon ? 'soon' : 'event',
198
+ sourceKey: soon ? `soon:${e.id}:${soon.start}` : `event:${e.id}`,
199
+ data: { kind: 'event', id: e.id, slug: e.slug, ...(soon ? { start: new Date(soon.start).toISOString() } : {}) },
200
+ localize: (l) => words(localizeDoc(e, l), l) };
201
+ }
202
+
203
+ /** Sends (or skips) queued first-publish notices. Returns how many were sent. */
204
+ export async function processPushQueue(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
205
+ const now = opts.now ?? Date.now();
206
+ if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
207
+ const rows = await repo.push.claimPending(now, now - STALE_AFTER, 10);
208
+ if (!rows.length) return 0;
209
+ const settings = await repo.getSettings();
210
+ const auto = settings.push.auto;
211
+ const calendars = CALENDAR.enabled ? listedCalendars(await repo.listCalendars()) : new Map<string, Calendar>();
212
+ const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
213
+ let sent = 0;
214
+ for (const row of rows) {
215
+ try {
216
+ let notice: SendInput | null = null;
217
+ if (row.kind === 'post') {
218
+ const post = await repo.getPost(row.docId);
219
+ if (post && post.status === 'published' && CONTENT.collections[post.collection] && auto.newPost.collections.includes(post.collection)) notice = postNotice(post, settings);
220
+ } else if (row.kind === 'event' && CALENDAR.enabled) {
221
+ const e = await repo.getEvent(row.docId);
222
+ if (e && !e.cancelled && eventIsListed(e, calendars) && inRule(auto.newEvent.calendars, e)) notice = eventNotice(e, settings, calendars, now);
223
+ }
224
+ if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
225
+ const n = await sendNotification(repo, notice, run);
226
+ await repo.push.markQueue(row.key, 'sent', n.id, now);
227
+ sent++;
228
+ } catch (e) {
229
+ console.error('acms push: automatic notification failed', e);
230
+ await repo.push.markQueue(row.key, 'failed', null, now).catch(() => {});
231
+ }
232
+ }
233
+ return sent;
234
+ }
235
+
236
+ /** "Starting soon": one notice per occurrence, `minutes` before it starts. */
237
+ export async function sendStartingSoon(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
238
+ if (!PUSH.enabled || !CALENDAR.enabled) return 0;
239
+ const now = opts.now ?? Date.now();
240
+ const settings = await repo.getSettings();
241
+ const rule = settings.push.auto.startingSoon;
242
+ if (!rule.calendars.length) return 0;
243
+ const [all, events] = await Promise.all([repo.listCalendars(), repo.listPublishedEvents()]);
244
+ const calendars = listedCalendars(all);
245
+ const chosen = events.filter((e) => !e.cancelled && eventIsListed(e, calendars) && inRule(rule.calendars, e));
246
+ if (!chosen.length) return 0;
247
+ const lead = rule.minutes * MINUTE;
248
+ const occurrences = expandEvents(chosen, settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + lead + 1 })
249
+ .filter((o) => !o.allDay && o.start > now && o.start - lead <= now);
250
+ const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
251
+ let sent = 0;
252
+ for (const o of occurrences) {
253
+ const key = `soon:${o.event.id}:${o.start}`;
254
+ if (!(await repo.push.claimKey(key, 'soon', o.event.id, o.start, now))) continue;
255
+ try {
256
+ const n = await sendNotification(repo, eventNotice(o.event, settings, calendars, now, { start: o.start, allDay: false }), run);
257
+ await repo.push.markQueue(key, 'sent', n.id, now);
258
+ sent++;
259
+ } catch (e) {
260
+ console.error('acms push: starting-soon notification failed', e);
261
+ await repo.push.markQueue(key, 'failed', null, now).catch(() => {});
262
+ }
263
+ }
264
+ return sent;
265
+ }
266
+
267
+ /** Reads Expo receipts for tickets older than RECEIPT_DELAY; prunes devices Expo says are gone. */
268
+ export async function checkPushReceipts(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
269
+ const now = opts.now ?? Date.now();
270
+ const due = await repo.push.dueTickets(now - RECEIPT_DELAY, 1000);
271
+ if (!due.length) return 0;
272
+ const receipts = await fetchReceipts(due.map((t) => t.id), expoOptions(opts.locals, opts.expo));
273
+ const done: string[] = [];
274
+ const gone = new Set<string>();
275
+ const counts = new Map<string, { failed: number; removed: number }>();
276
+ for (const t of due) {
277
+ const r = receipts[t.id];
278
+ if (!r) { if (t.createdAt < now - DAY) done.push(t.id); continue; }
279
+ done.push(t.id);
280
+ if (r.status !== 'error') continue;
281
+ const c = counts.get(t.notificationId) ?? { failed: 0, removed: 0 };
282
+ c.failed++;
283
+ if (isGone(r)) { gone.add(t.deviceId); c.removed++; }
284
+ counts.set(t.notificationId, c);
285
+ }
286
+ if (gone.size) await repo.push.deleteDevices([...gone]);
287
+ if (counts.size) await repo.push.receiptFailed(counts);
288
+ await repo.push.deleteTickets(done);
289
+ return done.length;
290
+ }
291
+
292
+ /** Scheduler entry: queue, starting soon, receipts and retention. Disabled → queued notices are skipped. */
293
+ export async function runPushJobs(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
294
+ const now = opts.now ?? Date.now();
295
+ if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
296
+ const run = { ...opts, now };
297
+ let count = await processPushQueue(repo, run);
298
+ count += await sendStartingSoon(repo, run);
299
+ count += await checkPushReceipts(repo, run);
300
+ await repo.push.purge(now, now - PUSH.retentionDays * DAY);
301
+ return count;
302
+ }
@@ -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,9 @@ 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';
7
+ import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
5
8
  // Versioned schema migrations shared by the two SQLite-dialect adapters
6
9
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
7
10
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -501,6 +504,17 @@ export const MIGRATIONS: Migration[] = [
501
504
  WEBHOOK_MIGRATION,
502
505
  LIVE_MIGRATION,
503
506
  IMPORT_MIGRATION,
507
+ // translations: per-language words for posts, pages and events (facts stay shared)
508
+ { version: 36, statements: [
509
+ `ALTER TABLE posts ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
510
+ `ALTER TABLE pages ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
511
+ `ALTER TABLE events ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
512
+ ] },
513
+ PUSH_MIGRATION,
514
+ EDITOR_MIGRATION,
515
+ EDITOR_GRACE_MIGRATION,
516
+ CALENDAR_SETTINGS_MIGRATION,
517
+ ROOM_HOLD_MIGRATION,
504
518
  ];
505
519
 
506
520
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;