@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,158 @@
1
+ // Public device endpoints (/api/v1/devices, /api/v1/push/topics) and the
2
+ // owner-only admin state/actions behind Admin → Mobile app.
3
+ import { z } from 'zod';
4
+ import type { ContentRepository } from '../repo/types';
5
+ import { ApiError, readJson } from '../api';
6
+ import { sha256Hex } from '../api-keys';
7
+ import { CONTENT, collectionUrl } from '../content';
8
+ import { CALENDAR, eventUrl } from '../calendar/config';
9
+ import { eventIsListed } from '../calendar/events';
10
+ import { queryParams } from '../mobile/http';
11
+ import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
12
+ import { expoOptions } from './expo';
13
+ import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
14
+ import { PushAuto, type PushDevice } from './types';
15
+
16
+ export const pushToken = z.string().max(250).regex(/^Expo(?:nent)?PushToken\[[A-Za-z0-9_-]{8,200}\]$/, 'Use an Expo push token such as ExponentPushToken[…].');
17
+ const topicList = z.array(z.string().min(1).max(200).regex(/^[a-z0-9][a-z0-9:_-]*$/i, 'Use topic ids from GET /api/v1/push/topics.')).max(50);
18
+ const locale = z.string().max(35).regex(/^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{1,8})*$/, 'Use a language tag such as en or es-MX.');
19
+ export const deviceInput = z.object({
20
+ token: pushToken,
21
+ platform: z.enum(['ios', 'android']),
22
+ locale: locale.optional(),
23
+ /** Replaces the device's topics. Omit to keep them (new devices start with none). */
24
+ topics: topicList.optional(),
25
+ }).strict();
26
+ export const deviceUpdate = z.object({ locale: locale.optional(), topics: topicList.optional() }).strict();
27
+
28
+ export const deviceJson = (d: PushDevice) => ({
29
+ token: d.token, platform: d.platform, locale: d.locale || null, topics: d.topics,
30
+ registeredAt: new Date(d.createdAt).toISOString(), lastSeenAt: new Date(d.lastSeenAt).toISOString(),
31
+ });
32
+ const deviceId = (token: string) => sha256Hex(`expo-push:${token}`);
33
+
34
+ async function checkTopics(repo: ContentRepository, topics: string[] | undefined) {
35
+ if (!topics) return undefined;
36
+ const known = await knownTopics(repo);
37
+ const unique = [...new Set(topics)];
38
+ const unknown = unique.find((t) => !known.has(t));
39
+ if (unknown) throw new ApiError(400, `Unknown topic: ${unknown}. See GET /api/v1/push/topics.`);
40
+ return unique;
41
+ }
42
+ function tokenParam(raw: string) {
43
+ let value = raw;
44
+ try { if (/%[0-9a-f]{2}/i.test(raw)) value = decodeURIComponent(raw); } catch { /* keep raw */ }
45
+ const parsed = pushToken.safeParse(value);
46
+ if (!parsed.success) throw new ApiError(400, 'Use an Expo push token such as ExponentPushToken[…] in the path.');
47
+ return parsed.data;
48
+ }
49
+
50
+ export async function registerDevice(repo: ContentRepository, request: Request) {
51
+ requirePush();
52
+ const body = deviceInput.parse(await readJson(request, 8_000));
53
+ const topics = await checkTopics(repo, body.topics);
54
+ const { device, created } = await repo.push.upsertDevice({ id: await deviceId(body.token), token: body.token, platform: body.platform, locale: body.locale ?? '', topics }, Date.now());
55
+ return { created, device: deviceJson(device) };
56
+ }
57
+ export async function updateDevice(repo: ContentRepository, request: Request, rawToken: string) {
58
+ requirePush();
59
+ const token = tokenParam(rawToken);
60
+ const body = deviceUpdate.parse(await readJson(request, 8_000));
61
+ if (body.locale === undefined && body.topics === undefined) throw new ApiError(400, 'Send topics, locale or both.');
62
+ const device = await repo.push.updateDevice(await deviceId(token), { locale: body.locale, topics: await checkTopics(repo, body.topics) }, Date.now());
63
+ if (!device) throw new ApiError(404, 'This device is not registered. Register it with POST /api/v1/devices.');
64
+ return deviceJson(device);
65
+ }
66
+ export async function unregisterDevice(repo: ContentRepository, rawToken: string) {
67
+ requirePush();
68
+ const token = tokenParam(rawToken);
69
+ // idempotent: an unknown token is already opted out
70
+ await repo.push.deleteDevices([await deviceId(token)]);
71
+ return { token, deleted: true };
72
+ }
73
+ export async function listPushTopics(repo: ContentRepository, url: URL) {
74
+ requirePush();
75
+ z.object({}).strict().parse(queryParams(url));
76
+ return { topics: await pushTopics(repo) };
77
+ }
78
+
79
+ // ---- admin -------------------------------------------------------------------
80
+
81
+ export interface PushLinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' }
82
+ async function linkTargets(repo: ContentRepository): Promise<PushLinkTarget[]> {
83
+ const [pages, posts, events, calendars] = await Promise.all([
84
+ repo.listPages(), repo.listPublishedPosts(),
85
+ CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
86
+ CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
87
+ ]);
88
+ const cals = new Map(calendars.map((c) => [c.id, c]));
89
+ return [
90
+ ...pages.filter((p) => p.status === 'published').map((p): PushLinkTarget => ({ ref: `page:${p.id}`, label: p.title || p.slug, url: p.isHome ? '/' : `/${p.slug}`, group: 'Pages' })),
91
+ ...posts.filter((p) => CONTENT.collections[p.collection]).slice(0, 200).map((p): PushLinkTarget => ({ ref: `post:${p.id}`, label: p.title, url: collectionUrl(p.collection, p.slug), group: 'Posts' })),
92
+ ...events.filter((e) => eventIsListed(e, cals)).slice(0, 200).map((e): PushLinkTarget => ({ ref: `event:${e.id}`, label: e.title, url: eventUrl(e.slug), group: 'Events' })),
93
+ ];
94
+ }
95
+
96
+ export async function adminPushState(repo: ContentRepository, url: URL, locals?: unknown) {
97
+ const now = Date.now();
98
+ const settings = await repo.getSettings();
99
+ const expo = expoOptions(locals);
100
+ const [stats, topics, calendars, links, log] = await Promise.all([
101
+ repo.push.stats(now), pushTopics(repo),
102
+ CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
103
+ linkTargets(repo), repo.push.listNotifications(50),
104
+ ]);
105
+ return {
106
+ enabled: PUSH.enabled, retentionDays: PUSH.retentionDays,
107
+ site: { name: settings.siteTitle, icon: settings.siteFavicon, origin: siteOriginOf(settings, url.origin) },
108
+ stats, topics, auto: settings.push.auto, links, log,
109
+ collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
110
+ calendarEnabled: CALENDAR.enabled,
111
+ calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
112
+ expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
113
+ };
114
+ }
115
+ export type AdminPushState = Awaited<ReturnType<typeof adminPushState>>;
116
+
117
+ const audience = z.array(z.string().min(1).max(200)).min(1, 'Choose who gets it.').max(50);
118
+ const link = z.string().trim().max(2000).refine((v) => !v || (/^\/(?!\/)/.test(v) || /^https?:\/\//i.test(v)), 'Use a site path such as /events or a full https:// address.');
119
+ export const AdminPushInput = z.discriminatedUnion('action', [
120
+ z.object({ action: z.literal('count'), audience }).strict(),
121
+ z.object({ action: z.literal('send'), title: z.string().trim().min(1, 'Add a title.').max(80), body: z.string().trim().max(240).default(''), url: link.default(''), audience }).strict(),
122
+ z.object({ action: z.literal('saveAuto'), auto: PushAuto }).strict(),
123
+ ]);
124
+
125
+ async function checkAudience(repo: ContentRepository, list: string[]) {
126
+ if (list.includes('*')) return ['*'];
127
+ const known = await knownTopics(repo);
128
+ const bad = list.find((t) => !known.has(t));
129
+ if (bad) throw new ApiError(400, `Unknown topic: ${bad}.`);
130
+ return [...new Set(list)];
131
+ }
132
+
133
+ export async function adminPushAction(repo: ContentRepository, input: z.infer<typeof AdminPushInput>, ctx: { url: URL; locals?: unknown; author: string; run?: PushRunOptions }) {
134
+ switch (input.action) {
135
+ case 'count':
136
+ return { recipients: await repo.push.countAudience(await checkAudience(repo, input.audience)) };
137
+ case 'send': {
138
+ const settings = await repo.getSettings();
139
+ const notification = await sendNotification(repo, {
140
+ title: input.title, body: input.body, url: input.url, audience: await checkAudience(repo, input.audience),
141
+ source: 'manual', author: ctx.author,
142
+ }, { locals: ctx.locals, origin: siteOriginOf(settings, ctx.url.origin), ...ctx.run });
143
+ return { notification };
144
+ }
145
+ case 'saveAuto': {
146
+ const collections = new Set(Object.keys(CONTENT.collections));
147
+ const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
148
+ const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
149
+ const auto = {
150
+ newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
151
+ newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
152
+ startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
153
+ };
154
+ await repo.updateSettings({ push: { auto } });
155
+ return { auto };
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,20 @@
1
+ // Push notifications config, set per project via the integration:
2
+ // acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270 } }
3
+ // Off by default. When off, the device/topic endpoints and the admin Mobile
4
+ // app page are not registered at all (like other disabled modules), and the
5
+ // scheduler just marks any queued automatic notifications as skipped.
6
+ import { userConfig } from 'virtual:acms-config';
7
+ import { ApiError } from '../api';
8
+
9
+ // mutable so tests can exercise the disabled paths
10
+ export const PUSH = {
11
+ enabled: userConfig.mobile?.push?.enabled === true,
12
+ retentionDays: userConfig.mobile?.push?.retentionDays ?? 270,
13
+ };
14
+
15
+ export function requirePush() {
16
+ if (!PUSH.enabled) throw new ApiError(404, 'This feature is disabled.');
17
+ }
18
+
19
+ /** Expo's push service. ACMS_EXPO_PUSH_URL overrides it (a local stub in tests/QA). */
20
+ export const EXPO_PUSH_BASE = 'https://exp.host/--/api/v2/push';
@@ -0,0 +1,76 @@
1
+ // Expo push service client: https://docs.expo.dev/push-notifications/sending-notifications/
2
+ // send: POST <base>/send with up to 100 messages → one ticket per message, in order.
3
+ // receipts: POST <base>/getReceipts with up to 1000 ticket ids, some minutes later.
4
+ // An Expo access token (EXPO_ACCESS_TOKEN) is only needed when "enhanced push
5
+ // security" is on for the Expo project. ACMS_EXPO_PUSH_URL points the sender at
6
+ // a stub for tests and local QA.
7
+ import { runtimeValue } from '../email/config';
8
+ import { EXPO_PUSH_BASE } from './config';
9
+
10
+ export interface ExpoMessage {
11
+ to: string; title: string; body: string; data: Record<string, unknown>;
12
+ sound?: 'default' | null; priority?: 'default' | 'normal' | 'high'; channelId?: string;
13
+ }
14
+ export type ExpoTicket = { status: 'ok'; id: string } | { status: 'error'; message: string; details?: { error?: string } };
15
+ export type ExpoReceipt = { status: 'ok' } | { status: 'error'; message: string; details?: { error?: string } };
16
+ export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
17
+ export interface ExpoOptions { base: string; accessToken: string; fetcher?: Fetcher; retryDelayMs?: number }
18
+
19
+ export const SEND_BATCH = 100;
20
+ export const RECEIPT_BATCH = 1000;
21
+
22
+ export function expoOptions(locals?: unknown, extra: Partial<ExpoOptions> = {}): ExpoOptions {
23
+ const base = (runtimeValue('ACMS_EXPO_PUSH_URL', locals) || EXPO_PUSH_BASE).replace(/\/+$/, '');
24
+ return { base, accessToken: runtimeValue('EXPO_ACCESS_TOKEN', locals), ...extra };
25
+ }
26
+
27
+ async function post(opts: ExpoOptions, path: string, body: unknown): Promise<{ ok: true; json: any } | { ok: false; message: string }> {
28
+ const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
29
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'acms-push/1.0' };
30
+ if (opts.accessToken) headers.authorization = `Bearer ${opts.accessToken}`;
31
+ let last = 'Push service unreachable.';
32
+ // one retry for rate limits, server errors and network failures
33
+ for (let attempt = 0; attempt < 2; attempt++) {
34
+ if (attempt) await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 1500));
35
+ try {
36
+ const res = await fetcher(`${opts.base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
37
+ const json = await res.json().catch(() => null);
38
+ if (res.ok && json && typeof json === 'object') return { ok: true, json };
39
+ last = json?.errors?.[0]?.message ?? `Push service answered ${res.status}.`;
40
+ if (res.status !== 429 && res.status < 500) break;
41
+ } catch (e) {
42
+ last = e instanceof Error && e.name === 'TimeoutError' ? 'Push service timed out.' : 'Push service unreachable.';
43
+ }
44
+ }
45
+ return { ok: false, message: last };
46
+ }
47
+
48
+ /** One ticket per message, aligned by index. A failed batch yields error tickets for each of its messages. */
49
+ export async function sendMessages(messages: ExpoMessage[], opts: ExpoOptions): Promise<ExpoTicket[]> {
50
+ const tickets: ExpoTicket[] = [];
51
+ for (let i = 0; i < messages.length; i += SEND_BATCH) {
52
+ const batch = messages.slice(i, i + SEND_BATCH);
53
+ const res = await post(opts, '/send', batch);
54
+ const data = res.ok && Array.isArray(res.json.data) ? res.json.data : null;
55
+ for (let j = 0; j < batch.length; j++) {
56
+ const t = data?.[j];
57
+ if (t?.status === 'ok' && typeof t.id === 'string') tickets.push({ status: 'ok', id: t.id });
58
+ else if (t?.status === 'error') tickets.push({ status: 'error', message: String(t.message ?? 'Rejected.'), details: t.details });
59
+ else tickets.push({ status: 'error', message: res.ok ? 'No ticket returned.' : res.message, details: { error: 'RequestFailed' } });
60
+ }
61
+ }
62
+ return tickets;
63
+ }
64
+
65
+ /** Receipts by ticket id. Ids Expo doesn't know yet (or anymore) are simply missing. */
66
+ export async function fetchReceipts(ids: string[], opts: ExpoOptions): Promise<Record<string, ExpoReceipt>> {
67
+ const out: Record<string, ExpoReceipt> = {};
68
+ for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {
69
+ const res = await post(opts, '/getReceipts', { ids: ids.slice(i, i + RECEIPT_BATCH) });
70
+ if (!res.ok) throw new Error(res.message);
71
+ Object.assign(out, res.json.data ?? {});
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export const isGone = (x: { status: string; details?: { error?: string } }) => x.status === 'error' && x.details?.error === 'DeviceNotRegistered';
@@ -0,0 +1,60 @@
1
+ // v37: push notifications. Devices hold the Expo push token (needed to send)
2
+ // and nothing personal. push_queue doubles as the "already announced" ledger:
3
+ // one row per post/event ever published (key post:<id> / event:<id>) and per
4
+ // "starting soon" occurrence (soon:<id>:<start ms>), so automatic notices go
5
+ // out on the FIRST publish only, never for edits or re-publishing.
6
+ //
7
+ // Rows are written by triggers, so every publish path (editor, scheduled
8
+ // publishing, API, agent) is covered without touching each caller. Imports and
9
+ // restores run with webhook_control.suppressed=1 and are recorded as skipped:
10
+ // migrating old posts is not news. The scheduler decides whether a pending row
11
+ // is actually sent (push enabled, matching automatic rule, still published).
12
+ const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
13
+ const status = "CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 THEN 'skipped' ELSE 'pending' END";
14
+ function enqueue(kind: 'post' | 'event') {
15
+ // Plain INSERT … SELECT … WHERE NOT EXISTS: no conflict clause, so an outer
16
+ // INSERT OR REPLACE (restore) can't turn it into a replace of a sent row.
17
+ return `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
18
+ SELECT '${kind}:'||NEW.id,'${kind}',NEW.id,NULL,${status},NULL,${now},${now}
19
+ WHERE NOT EXISTS (SELECT 1 FROM push_queue WHERE key='${kind}:'||NEW.id);`;
20
+ }
21
+ function triggers(kind: 'post' | 'event') {
22
+ const table = `${kind}s`;
23
+ return [
24
+ `CREATE TRIGGER push_${kind}_insert AFTER INSERT ON ${table} WHEN NEW.status='published' BEGIN ${enqueue(kind)} END`,
25
+ `CREATE TRIGGER push_${kind}_publish AFTER UPDATE OF status ON ${table}
26
+ WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${enqueue(kind)} END`,
27
+ ];
28
+ }
29
+
30
+ export const PUSH_MIGRATION = { version: 37, statements: [
31
+ `ALTER TABLE settings ADD COLUMN push TEXT NOT NULL DEFAULT '{}'`,
32
+ `CREATE TABLE push_devices (
33
+ id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE, platform TEXT NOT NULL,
34
+ locale TEXT NOT NULL DEFAULT '', topics TEXT NOT NULL DEFAULT '[]',
35
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
36
+ )`,
37
+ `CREATE INDEX push_devices_seen ON push_devices (last_seen_at)`,
38
+ `CREATE TABLE push_notifications (
39
+ id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '',
40
+ topics TEXT NOT NULL DEFAULT '[]', source TEXT NOT NULL DEFAULT 'manual', source_key TEXT NOT NULL DEFAULT '',
41
+ status TEXT NOT NULL DEFAULT 'sending', recipients INTEGER NOT NULL DEFAULT 0, sent INTEGER NOT NULL DEFAULT 0,
42
+ ok INTEGER NOT NULL DEFAULT 0, failed INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0,
43
+ error TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, finished_at INTEGER
44
+ )`,
45
+ `CREATE INDEX push_notifications_created ON push_notifications (created_at)`,
46
+ // Expo push tickets awaiting a receipt check (receipts expire after 24h)
47
+ `CREATE TABLE push_tickets (id TEXT PRIMARY KEY, notification_id TEXT NOT NULL, device_id TEXT NOT NULL, created_at INTEGER NOT NULL)`,
48
+ `CREATE INDEX push_tickets_created ON push_tickets (created_at)`,
49
+ `CREATE TABLE push_queue (
50
+ key TEXT PRIMARY KEY, kind TEXT NOT NULL, doc_id TEXT NOT NULL, occurrence INTEGER,
51
+ status TEXT NOT NULL, notification_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
52
+ )`,
53
+ `CREATE INDEX push_queue_status ON push_queue (status, created_at)`,
54
+ // everything already live counts as announced
55
+ `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
56
+ SELECT 'post:'||id,'post',id,NULL,'seen',NULL,${now},${now} FROM posts WHERE status='published'`,
57
+ `INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
58
+ SELECT 'event:'||id,'event',id,NULL,'seen',NULL,${now},${now} FROM events WHERE status='published'`,
59
+ ...triggers('post'), ...triggers('event'),
60
+ ] };
@@ -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
+ }