@acms-subir/core 0.3.31 → 0.3.33

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 (56) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/cli/main.mjs +1 -1
  3. package/cli/migrations.generated.mjs +35 -2
  4. package/docs/mobile.md +7 -3
  5. package/integration.mjs +49 -2
  6. package/mobile-template/client/AGENTS.md +1 -0
  7. package/mobile-template/recipes/announcements/files/app/announcements/[id].tsx +43 -0
  8. package/mobile-template/recipes/announcements/files/app/announcements/index.tsx +155 -0
  9. package/mobile-template/recipes/announcements/files/components/AnnouncementBanners.tsx +47 -0
  10. package/mobile-template/recipes/announcements/files/components/AnnouncementIcon.tsx +21 -0
  11. package/mobile-template/recipes/announcements/recipe.json +19 -0
  12. package/package.json +1 -1
  13. package/src/components/Announcements.tsx +806 -0
  14. package/src/components/MobileApp.tsx +1 -1
  15. package/src/layouts/Admin.astro +4 -0
  16. package/src/layouts/Site.astro +42 -2
  17. package/src/lib/agent-discovery.ts +6 -2
  18. package/src/lib/agent.ts +5 -0
  19. package/src/lib/announcements/agent.ts +49 -0
  20. package/src/lib/announcements/api.ts +222 -0
  21. package/src/lib/announcements/config.ts +8 -0
  22. package/src/lib/announcements/migration.ts +21 -0
  23. package/src/lib/announcements/pages.ts +54 -0
  24. package/src/lib/announcements/service.ts +132 -0
  25. package/src/lib/announcements/store.ts +88 -0
  26. package/src/lib/announcements/types.ts +78 -0
  27. package/src/lib/announcements/words.ts +55 -0
  28. package/src/lib/backup/content.ts +5 -1
  29. package/src/lib/mobile/config.mjs +18 -2
  30. package/src/lib/mobile/locale.ts +2 -0
  31. package/src/lib/mobile/openapi.ts +6 -2
  32. package/src/lib/mobile/schemas.ts +10 -3
  33. package/src/lib/mobile/site.ts +8 -3
  34. package/src/lib/push/api.ts +12 -8
  35. package/src/lib/push/config.ts +3 -1
  36. package/src/lib/push/service.ts +40 -12
  37. package/src/lib/push/store.ts +20 -7
  38. package/src/lib/push/types.ts +5 -3
  39. package/src/lib/repo/migrations.ts +4 -0
  40. package/src/lib/repo/sql.ts +6 -0
  41. package/src/lib/repo/types.ts +5 -0
  42. package/src/lib/scheduler.ts +3 -0
  43. package/src/lib/schema.ts +1 -0
  44. package/src/pages/admin/announcements.astro +18 -0
  45. package/src/pages/admin/integrations.astro +2 -1
  46. package/src/pages/admin/settings.astro +2 -0
  47. package/src/pages/announcements/[id].astro +35 -0
  48. package/src/pages/announcements/index.astro +61 -0
  49. package/src/pages/api/announcements.ts +32 -0
  50. package/src/pages/api/v1/announcements/[id].ts +7 -0
  51. package/src/pages/api/v1/announcements/index.ts +8 -0
  52. package/src/pages/api/webhooks/docs.ts +2 -0
  53. package/src/styles/announcements-site.css +32 -0
  54. package/src/styles/announcements.css +210 -0
  55. package/src/styles/site.css +5 -0
  56. package/src/virtual.d.ts +2 -1
@@ -22,30 +22,51 @@ export const RECEIPT_DELAY = 15 * MINUTE;
22
22
 
23
23
  // ---- topics ------------------------------------------------------------------
24
24
 
25
+ // names of the built-in topics; other languages show English
26
+ const TOPIC_WORDS: Record<string, { news: string; events: string; live: string }> = {
27
+ en: { news: 'News', events: 'Events', live: 'Live video' },
28
+ es: { news: 'Noticias', events: 'Eventos', live: 'Video en vivo' },
29
+ fr: { news: 'Actualités', events: 'Événements', live: 'Vidéo en direct' },
30
+ de: { news: 'Neuigkeiten', events: 'Veranstaltungen', live: 'Live-Video' },
31
+ pt: { news: 'Notícias', events: 'Eventos', live: 'Vídeo ao vivo' },
32
+ };
33
+ /** A site topic's words in a language: exact code, its base ("es-MX" → "es"), every language ('*'), the site's main language, then any. */
34
+ export function topicWords(words: Record<string, string>, locale: string): string {
35
+ const base = locale.toLowerCase().split('-')[0];
36
+ return words[locale] ?? words[base] ?? words['*'] ?? (I18N.defaultLocale ? words[I18N.defaultLocale] : undefined) ?? Object.values(words)[0] ?? '';
37
+ }
38
+
25
39
  /**
26
- * Topics an app can offer as toggles. `news` = every new post, `events` = every
27
- * new event, `live` = "we're live" (while live video isn't off). Per-collection
28
- * and per-calendar topics appear once there is more than one of them (a single
29
- * one would just repeat news/events). Devices only get the topics they (or the
30
- * app, at their first registration) choose: a new topic never reaches existing
31
- * devices on its own.
40
+ * Topics an app can offer as toggles, named in `locale` (default: the site's
41
+ * main language). `news` = every new post, `events` = every new event, `live` =
42
+ * "we're live" (while live video isn't off), then the site's own topics
43
+ * (acms({ mobile: { push: { topics } } })). Per-collection and per-calendar
44
+ * topics appear once there is more than one of them (a single one would just
45
+ * repeat news/events). Devices only get the topics they (or the app, at their
46
+ * first registration) choose: a new topic never reaches existing devices on its own.
32
47
  */
33
- export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]> {
48
+ export async function pushTopics(repo: ContentRepository, locale?: string | null): Promise<PushTopic[]> {
49
+ const lang = locale || I18N.defaultLocale || (await repo.getSettings()).locale || 'en';
50
+ const w = TOPIC_WORDS[lang.toLowerCase().split('-')[0]] ?? TOPIC_WORDS.en;
34
51
  const collections = Object.entries(CONTENT.collections);
35
- const out: PushTopic[] = [{ id: 'news', label: 'News', kind: 'general' }];
52
+ const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
36
53
  if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
37
54
  if (CALENDAR.enabled) {
38
- out.push({ id: 'events', label: 'Events', kind: 'general' });
55
+ out.push({ id: 'events', label: w.events, kind: 'general' });
39
56
  const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
40
57
  if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
41
58
  }
42
- if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: 'Live video', kind: 'general' });
59
+ if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
60
+ for (const t of PUSH.topics) {
61
+ const description = topicWords(t.description, lang);
62
+ out.push({ id: t.id, label: topicWords(t.label, lang), kind: 'site', ...(description ? { description } : {}) });
63
+ }
43
64
  return out;
44
65
  }
45
66
 
46
67
  /** Every topic a device may subscribe to (listed or not). `live` stays valid while live video is off, so a saved choice survives. */
47
68
  export async function knownTopics(repo: ContentRepository): Promise<Set<string>> {
48
- const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
69
+ const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`), ...PUSH.topics.map((t) => t.id)]);
49
70
  if (CALENDAR.enabled) {
50
71
  set.add('events');
51
72
  for (const c of await repo.listCalendars()) if (c.visibility === 'public') set.add(`calendar:${c.slug}`);
@@ -94,6 +115,10 @@ export interface SendInput extends Composed {
94
115
  author?: string;
95
116
  /** words for a device in an extra site language; null = use the main words */
96
117
  localize?: (locale: string) => Composed | null;
118
+ /** Android channel, used only on devices whose app reported creating it */
119
+ channel?: string;
120
+ /** a second link (an announcement's button), sent as data.buttonUrl/buttonPath in each phone's language */
121
+ button?: string;
97
122
  data?: Record<string, unknown>;
98
123
  }
99
124
  export interface PushRunOptions {
@@ -144,9 +169,12 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
144
169
  c = words.get(locale)!;
145
170
  }
146
171
  const link = notificationLink(c.url, origin, locale);
172
+ const button = input.button ? notificationLink(input.button, origin, locale) : null;
147
173
  return {
148
174
  to: d.token, title: clip(c.title, 120), body: clip(c.body, 240), sound: 'default', priority: 'high',
149
- data: { ...input.data, notificationId: n.id, source: input.source, url: link.url, path: link.path, locale: locale ?? (I18N.defaultLocale || null) },
175
+ ...(input.channel && d.platform === 'android' && d.channels.includes(input.channel) ? { channelId: input.channel } : {}),
176
+ data: { ...input.data, notificationId: n.id, source: input.source, url: link.url, path: link.path, locale: locale ?? (I18N.defaultLocale || null),
177
+ ...(button?.url ? { buttonUrl: button.url, buttonPath: button.path } : {}) },
150
178
  };
151
179
  });
152
180
  const tickets = messages.length ? await sendMessages(messages, expoOptions(opts.locals, opts.expo)) : [];
@@ -5,7 +5,7 @@ type Row = Record<string, unknown>;
5
5
  const list = (raw: unknown): string[] => { try { const v = JSON.parse(String(raw ?? '[]')); return Array.isArray(v) ? v.map(String) : []; } catch { return []; } };
6
6
  function device(r: Row): PushDevice {
7
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) };
8
+ channels: list(r.channels), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at), lastSeenAt: Number(r.last_seen_at) };
9
9
  }
10
10
  function notification(r: Row): PushNotification {
11
11
  return { id: String(r.id), title: String(r.title), body: String(r.body ?? ''), url: String(r.url ?? ''), topics: list(r.topics),
@@ -29,19 +29,21 @@ export class PushStore {
29
29
 
30
30
  // ---- devices ----
31
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 }> {
32
+ async upsertDevice(d: { id: string; token: string; platform: PushPlatform; locale: string; topics?: string[]; channels?: string[] }, now: number): Promise<{ device: PushDevice; created: boolean }> {
33
33
  const topics = d.topics === undefined ? null : JSON.stringify(d.topics);
34
+ const channels = d.channels === undefined ? null : JSON.stringify(d.channels);
34
35
  // RETURNING also yields the previous row's created_at on the update branch,
35
36
  // 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
+ const rows = await this.all(`INSERT INTO push_devices (id,token,platform,locale,topics,channels,created_at,updated_at,last_seen_at) VALUES (?,?,?,?,COALESCE(?,'[]'),COALESCE(?,'[]'),?,?,?)
37
38
  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
+ channels=COALESCE(?,push_devices.channels), updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at RETURNING *`,
40
+ [d.id, d.token, d.platform, d.locale, topics, channels, now, now, now, topics, channels]);
39
41
  const row = device(rows[0]);
40
42
  return { device: row, created: row.createdAt === now };
41
43
  }
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]);
44
+ async updateDevice(id: string, data: { locale?: string; topics?: string[]; channels?: string[] }, now: number): Promise<PushDevice | null> {
45
+ const rows = await this.all(`UPDATE push_devices SET locale=COALESCE(?,locale), topics=COALESCE(?,topics), channels=COALESCE(?,channels), updated_at=?, last_seen_at=? WHERE id=? RETURNING *`,
46
+ [data.locale ?? null, data.topics ? JSON.stringify(data.topics) : null, data.channels ? JSON.stringify(data.channels) : null, now, now, id]);
45
47
  return rows[0] ? device(rows[0]) : null;
46
48
  }
47
49
  async getDevice(id: string) { const r = await this.all('SELECT * FROM push_devices WHERE id=?', [id]); return r[0] ? device(r[0]) : null; }
@@ -69,6 +71,17 @@ export class PushStore {
69
71
  topics: Object.fromEntries(topics.map((r) => [String(r.topic), Number(r.n)])) };
70
72
  }
71
73
 
74
+ /** Devices in an audience per raw device language ('' = none sent). */
75
+ async audienceLocales(topics: string[]): Promise<Record<string, number>> {
76
+ const w = audienceWhere(topics);
77
+ const rows = await this.all(`SELECT locale, COUNT(*) AS n FROM push_devices WHERE ${w.sql} GROUP BY locale`, w.args);
78
+ return Object.fromEntries(rows.map((r) => [String(r.locale ?? ''), Number(r.n)]));
79
+ }
80
+ /** Notifications from these sources created since `since` (pacing: "sent this week"). */
81
+ async countSent(sources: PushNotification['source'][], since: number): Promise<number> {
82
+ return Number((await this.all(`SELECT COUNT(*) AS n FROM push_notifications WHERE created_at>=? AND status!='failed' AND source IN (${sources.map(() => '?').join(',')})`, [since, ...sources]))[0]?.n ?? 0);
83
+ }
84
+
72
85
  // ---- delivery log ----
73
86
  async createNotification(n: PushNotification) {
74
87
  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)
@@ -19,9 +19,11 @@ export type PushSettings = z.infer<typeof PushSettings>;
19
19
  export type PushPlatform = 'ios' | 'android';
20
20
  export interface PushDevice {
21
21
  id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
22
+ /** Android notification channels the app created (beyond its default one) */
23
+ channels: string[];
22
24
  createdAt: number; updatedAt: number; lastSeenAt: number;
23
25
  }
24
- export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live';
26
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement';
25
27
  export interface PushNotification {
26
28
  id: string; title: string; body: string; url: string;
27
29
  /** ['*'] = everyone */
@@ -33,8 +35,8 @@ export interface PushNotification {
33
35
  removed: number;
34
36
  error: string; author: string; createdAt: number; finishedAt: number | null;
35
37
  }
36
- export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live'; docId: string; occurrence: number | null; status: string; createdAt: number }
37
- export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
38
+ export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live' | 'announcement'; docId: string; occurrence: number | null; status: string; createdAt: number }
39
+ export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' | 'site'; description?: string }
38
40
  export interface PushStats {
39
41
  devices: number;
40
42
  platforms: Record<PushPlatform, number>;
@@ -8,6 +8,7 @@ import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/mi
8
8
  import { PLACES_MIGRATION } from '../maps/migration';
9
9
  import { ROLES_MIGRATION } from '../permissions/migration';
10
10
  import { SYNC_MIGRATION } from '../sync/migration';
11
+ import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
11
12
  // Versioned schema migrations shared by the two SQLite-dialect adapters
12
13
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
13
14
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -676,6 +677,8 @@ export const MIGRATIONS: Migration[] = [
676
677
  `DROP TRIGGER content_version_settings_update`,
677
678
  ...contentVersionTriggers('settings', [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal']),
678
679
  ] },
680
+ // announcements show on public pages, the banner and the app API
681
+ { version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers('announcements', ANNOUNCEMENT_VISIBLE_COLUMNS)] },
679
682
  ];
680
683
 
681
684
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -683,6 +686,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
683
686
  ...CONTENT_VERSION_COLUMNS,
684
687
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
685
688
  events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates'],
689
+ announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
686
690
  };
687
691
 
688
692
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -5,6 +5,7 @@ import { FormStore, formInsert, submissionInsert } from '../forms/store';
5
5
  import { GuestStore } from '../mobile/guest-store';
6
6
  import { LiveStore } from '../live/store';
7
7
  import { PushStore } from '../push/store';
8
+ import { AnnouncementStore, announcementInsert } from '../announcements/store';
8
9
  import { EditorStore } from '../editor/store';
9
10
  import { PermissionStore } from '../permissions/store';
10
11
  import { BackupStore } from '../backup/store';
@@ -276,6 +277,7 @@ export class SqlRepository implements ContentRepository {
276
277
  readonly guests: GuestStore;
277
278
  readonly live: LiveStore;
278
279
  readonly push: PushStore;
280
+ readonly announcements: AnnouncementStore;
279
281
  readonly editor: EditorStore;
280
282
  readonly backups: BackupStore;
281
283
  readonly permissions: PermissionStore;
@@ -287,6 +289,7 @@ export class SqlRepository implements ContentRepository {
287
289
  this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
288
290
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
289
291
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
292
+ this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
290
293
  this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
291
294
  this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
292
295
  this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
@@ -1823,6 +1826,9 @@ export class SqlRepository implements ContentRepository {
1823
1826
  JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
1824
1827
  ],
1825
1828
  })),
1829
+ // announcements (a backup made before them leaves the current ones). Restored
1830
+ // rows never push again here: the push ledger keeps announcement:<id>
1831
+ ...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
1826
1832
  ...(content.forms ?? []).map(formInsert),
1827
1833
  ...(content.formSubmissions ?? []).map(submissionInsert),
1828
1834
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -8,6 +8,8 @@ import type { LiveStore } from '../live/store';
8
8
  import type { BookingRequest } from '../backup/content';
9
9
  import type { EmailStore } from '../email/store';
10
10
  import type { PushStore } from '../push/store';
11
+ import type { AnnouncementStore } from '../announcements/store';
12
+ import type { Announcement } from '../announcements/types';
11
13
  import type { EditorStore } from '../editor/store';
12
14
  import type { PermissionStore } from '../permissions/store';
13
15
  export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
@@ -62,6 +64,7 @@ export interface ContentRepository {
62
64
  readonly guests: GuestStore;
63
65
  readonly live: LiveStore;
64
66
  readonly push: PushStore;
67
+ readonly announcements: AnnouncementStore;
65
68
  readonly editor: EditorStore;
66
69
  readonly permissions: PermissionStore;
67
70
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
@@ -319,6 +322,8 @@ export interface ContentRepository {
319
322
  places?: Place[];
320
323
  forms?: SavedForm[];
321
324
  formSubmissions?: FormSubmission[];
325
+ // added with announcements; a backup without them leaves the current ones
326
+ announcements?: Announcement[];
322
327
  }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
323
328
 
324
329
  // imports (lib/import/apply.ts drives these). writeImported is the
@@ -4,6 +4,7 @@ import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
5
  import { decideOverdue, sendDueReminders } from './booking/service';
6
6
  import { runPushJobs } from './push/service';
7
+ import { runAnnouncementJobs } from './announcements/service';
7
8
  import { GUEST_IDLE_MS } from './mobile/guest-store';
8
9
  import { pushHeartbeat, pushUrlFor } from './monitoring';
9
10
  import { syncCachePurge } from './cache/purge';
@@ -22,6 +23,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
22
23
  approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
23
24
  email: () => dispatchEmail(repo, locals, 10),
24
25
  live: () => syncDueLive(repo, locals),
26
+ // scheduled announcements go up (with their push) and ended ones come down
27
+ announcements: () => runAnnouncementJobs(repo, { locals, now: startedAt }),
25
28
  // queued first-publish notices, starting-soon reminders, receipts, retention
26
29
  push: () => runPushJobs(repo, { locals, now: startedAt }),
27
30
  // expired guest sign-in codes and app sessions unused for 90 days
package/src/lib/schema.ts CHANGED
@@ -710,6 +710,7 @@ export const WEBHOOK_EVENTS = [
710
710
  'design.published', 'media.created', 'media.updated', 'media.deleted', 'site.restored',
711
711
  'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
712
712
  'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
713
+ 'announcement.published',
713
714
  ] as const;
714
715
  export type WebhookEventType = (typeof WEBHOOK_EVENTS)[number];
715
716
 
@@ -0,0 +1,18 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import Announcements from '../../components/Announcements';
4
+ import { getRepo } from '../../lib/repo';
5
+ import { forbiddenUnless } from '../../lib/permissions/access';
6
+ const repo = await getRepo(Astro.locals);
7
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'services.manage');
8
+ if (denied) return denied;
9
+ ---
10
+ <Admin title="Announcements" section="announcements">
11
+ <div class="page-head">
12
+ <div>
13
+ <h1>Announcements</h1>
14
+ <p class="sub">Write it once: it goes on the announcements list, as a banner and, if you choose, as a push to the app.</p>
15
+ </div>
16
+ </div>
17
+ <Announcements client:only="react" />
18
+ </Admin>
@@ -5,6 +5,7 @@ import { getRepo } from '../../lib/repo';
5
5
  import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
6
6
  import { accessFor } from '../../lib/permissions/access';
7
7
  import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
8
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
8
9
  import { issueApiKey } from '../../lib/api-keys';
9
10
  import { sessionRole } from '../../lib/api';
10
11
  import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
@@ -243,7 +244,7 @@ MAPS_API_KEY=…`}</pre>
243
244
  <input type="hidden" name="op" value="hook" />
244
245
  <label class="field"><span>Endpoint URL</span><input type="url" class="mono" name="url" placeholder="https://hooks.zapier.com/…" required /></label>
245
246
  <div class="field"><span>Send these events</span>
246
- {WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
247
+ {WEBHOOK_EVENTS.filter((e) => (CALENDAR.enabled || !e.startsWith('event.')) && (bookingsEnabled() || !e.startsWith('booking.')) && (ANNOUNCEMENTS.enabled || !e.startsWith('announcement.'))).map((e) => <label class="check-row"><input type="checkbox" name="events" value={e} checked={e === 'post.published' || e === 'page.published'} /> <code>{e}</code></label>)}
247
248
  </div>
248
249
  <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add endpoint</button></div>
249
250
  <p class="hint modal-note">Deliveries are signed and retried 3 times over about an hour.</p>
@@ -4,6 +4,7 @@ import AiSettings from '../../components/AiSettings';
4
4
  import MonitoringSettings from '../../components/MonitoringSettings';
5
5
  import LegalSettings from '../../components/LegalSettings';
6
6
  import { LEGAL } from '../../lib/legal/config';
7
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
7
8
  import { getRepo } from '../../lib/repo';
8
9
  import { accessFor } from '../../lib/permissions/access';
9
10
  import type { Permission } from '../../lib/permissions/catalog';
@@ -381,6 +382,7 @@ const groups = [
381
382
  <label class="field"><span>Link (optional)</span><input type="text" name="annLink" value={settings.announcement.link} placeholder={`${CONTENT.posts.base}/announcement`} /></label>
382
383
  </div>
383
384
  <label class="check-row"><input type="checkbox" name="annDismissible" checked={settings.announcement.dismissible} /><span>Visitors can dismiss it <span class="hint">(remembered per browser until the text changes)</span></span></label>
385
+ {ANNOUNCEMENTS.enabled && <p class="hint">A pinned announcement (<a href="/admin/announcements">Announcements</a>) shows here instead while it’s up, in each language and on a schedule.</p>}
384
386
  </form>
385
387
  </section>
386
388
 
@@ -0,0 +1,35 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { requestLocale, localePath } from '../../lib/i18n';
5
+ import { siteLang } from '../../lib/dates';
6
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
7
+ import { isLive } from '../../lib/announcements/service';
8
+ import { announcementStrings } from '../../lib/announcements/words';
9
+ import { announcementView } from '../../lib/announcements/pages';
10
+ import '../../styles/announcements-site.css';
11
+
12
+ // One announcement while it's up (404 before it goes up and after it comes
13
+ // down). Pushes without a button open this page.
14
+ if (!ANNOUNCEMENTS.enabled || !ANNOUNCEMENTS.pages) return new Response(null, { status: 404, statusText: 'Not found' });
15
+ const repo = await getRepo(Astro.locals);
16
+ const id = Astro.params.id ?? '';
17
+ const now = Date.now();
18
+ const a = id.length <= 100 ? await repo.announcements.get(id) : null;
19
+ if (!a || !isLive(a, now) || !(a.listed || a.pinned)) return new Response(null, { status: 404, statusText: 'Not found' });
20
+ const settings = await repo.getSettings();
21
+ const locale = requestLocale(Astro.url, Astro.locals);
22
+ const lang = locale || siteLang(settings);
23
+ const t = announcementStrings(lang);
24
+ const v = announcementView(a, locale, lang, settings.timezone || 'UTC', now);
25
+ ---
26
+ <Site title={v.title} description={v.message}>
27
+ <article class="ann-page ann-detail" data-kind={v.kind}>
28
+ <p class="ann-back"><a href={localePath(ANNOUNCEMENTS.base, locale)}>← {t.back}</a></p>
29
+ <p class="ann-meta"><span class="ann-kind">{v.kindLabel}</span> · <time datetime={v.iso}>{v.date}</time>{v.until && <> · {v.until}</>}</p>
30
+ <h1>{v.title}</h1>
31
+ {v.message && <p class="ann-lead">{v.message}</p>}
32
+ {v.details.map((p) => <p>{p}</p>)}
33
+ {v.button && <p><a class="ann-button" href={v.button.href} rel={v.button.external ? 'noopener' : undefined}>{v.button.label}</a></p>}
34
+ </article>
35
+ </Site>
@@ -0,0 +1,61 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { requestLocale, localePath } from '../../lib/i18n';
5
+ import { siteLang } from '../../lib/dates';
6
+ import { ANNOUNCEMENTS } from '../../lib/announcements/config';
7
+ import { ANNOUNCEMENT_KINDS, type AnnouncementKind } from '../../lib/announcements/types';
8
+ import { announcementStrings } from '../../lib/announcements/words';
9
+ import { announcementView, groupByWhen } from '../../lib/announcements/pages';
10
+ import '../../styles/announcements-site.css';
11
+
12
+ // The website's announcements list (acms({ announcements })): what's up
13
+ // right now, newest first, with a tab per kind. A site file at this path
14
+ // replaces it; GET /api/v1/announcements has the same data.
15
+ if (!ANNOUNCEMENTS.enabled || !ANNOUNCEMENTS.pages) return new Response(null, { status: 404, statusText: 'Not found' });
16
+ const repo = await getRepo(Astro.locals);
17
+ const settings = await repo.getSettings();
18
+ const locale = requestLocale(Astro.url, Astro.locals);
19
+ const lang = locale || siteLang(settings);
20
+ const t = announcementStrings(lang);
21
+ const tz = settings.timezone || 'UTC';
22
+ const now = Date.now();
23
+ const asked = Astro.url.searchParams.get('kind') ?? '';
24
+ const kind = (ANNOUNCEMENT_KINDS as readonly string[]).includes(asked) ? asked as AnnouncementKind : undefined;
25
+ const live = await repo.announcements.live(now, { limit: 200, offset: 0 });
26
+ // tabs only for kinds that have something up (urgent shows under All)
27
+ const kinds = ANNOUNCEMENT_KINDS.filter((k) => k !== 'urgent' && live.some((a) => a.kind === k));
28
+ const shown = live.filter((a) => !kind || a.kind === kind).map((a) => announcementView(a, locale, lang, tz, now));
29
+ const groups = groupByWhen(shown, lang, tz, now);
30
+ const base = localePath(ANNOUNCEMENTS.base, locale);
31
+ const tabs = kinds.length ? [{ href: base, label: t.all, current: !kind }, ...kinds.map((k) => ({ href: base + '?kind=' + k, label: t.kinds[k], current: kind === k }))] : [];
32
+ ---
33
+ <Site title={t.title} description={t.intro}>
34
+ <section class="ann-page">
35
+ <header class="ann-head">
36
+ <h1>{t.title}</h1>
37
+ <p>{t.intro}</p>
38
+ </header>
39
+ {tabs.length > 0 && (
40
+ <nav class="ann-tabs" aria-label={t.title}>
41
+ {tabs.map((tab) => <a href={tab.href} aria-current={tab.current ? 'page' : undefined}>{tab.label}</a>)}
42
+ </nav>
43
+ )}
44
+ {groups.length === 0 && <p class="ann-empty">{kind ? t.emptyKind : t.empty}</p>}
45
+ {groups.map((g) => (
46
+ <section class="ann-group" aria-labelledby={'ann-' + g.key}>
47
+ <h2 id={'ann-' + g.key}>{g.label}</h2>
48
+ <ul class="ann-list">
49
+ {g.items.map((a) => (
50
+ <li class="ann-item" data-kind={a.kind}>
51
+ <p class="ann-meta"><span class="ann-kind">{a.kindLabel}</span> · <time datetime={a.iso}>{a.date}</time></p>
52
+ <h3><a href={a.href}>{a.title}</a></h3>
53
+ {a.message && <p class="ann-message">{a.message}</p>}
54
+ {a.button && <a class="ann-action" href={a.button.href} rel={a.button.external ? 'noopener' : undefined}>{a.button.label} →</a>}
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ </section>
59
+ ))}
60
+ </section>
61
+ </Site>
@@ -0,0 +1,32 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+ import { ApiError, apiError, json, readJson } from '../../lib/api';
4
+ import { OWNER_ENV } from '../../lib/auth';
5
+ import { validateRequestSession } from '../../lib/auth/request';
6
+ import { requirePermission } from '../../lib/permissions/access';
7
+ import { waitUntilFrom } from '../../lib/webhooks';
8
+ import { AdminAnnouncementAction, adminAnnouncementAction, adminAnnouncementsState, requireAnnouncements } from '../../lib/announcements/api';
9
+
10
+ // Admin → Announcements: the people who can send pushes (Connected services).
11
+ export const GET: APIRoute = async ({ request, cookies, locals, url }) => {
12
+ try {
13
+ requireAnnouncements();
14
+ const repo = await getRepo(locals);
15
+ await requirePermission(request, cookies, repo, 'services.manage');
16
+ return json(await adminAnnouncementsState(repo, url));
17
+ } catch (e) { return apiError(e); }
18
+ };
19
+ export const POST: APIRoute = async ({ request, cookies, locals, url }) => {
20
+ try {
21
+ requireAnnouncements();
22
+ const repo = await getRepo(locals);
23
+ await requirePermission(request, cookies, repo, 'services.manage');
24
+ // publishing can reach every installed app: only from this site's admin
25
+ if (request.headers.get('origin') !== url.origin) throw new ApiError(403, 'Change this from this site’s admin.');
26
+ const input = AdminAnnouncementAction.parse(await readJson(request, 100_000));
27
+ const id = await validateRequestSession(request, cookies, repo);
28
+ const author = id === OWNER_ENV ? 'Owner' : (await repo.getUser(id ?? ''))?.name ?? '';
29
+ const result = await adminAnnouncementAction(repo, input, { url, author, locals, waitUntil: waitUntilFrom(locals), fallbackOrigin: url.origin });
30
+ return json({ ...result, state: input.action === 'count' ? undefined : await adminAnnouncementsState(repo, url) });
31
+ } catch (e) { return apiError(e); }
32
+ };
@@ -0,0 +1,7 @@
1
+ import { getAnnouncement } from '../../../../lib/announcements/api';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const GET = readRoute(getAnnouncement);
6
+ export const HEAD = GET;
7
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,8 @@
1
+ import { listAnnouncements } from '../../../../lib/announcements/api';
2
+ import { readRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // announcements go up and come down with the clock: not timeless
6
+ export const GET = readRoute(listAnnouncements);
7
+ export const HEAD = GET;
8
+ export const ALL = () => methodNotAllowed();
@@ -23,6 +23,7 @@ design.published: a design draft is published, including its navigation and sect
23
23
  Restoring design history only stages a draft; publishing it emits this event.
24
24
  media.created/updated/deleted: a media record is added, its alt text changes, or it is removed.
25
25
  site.restored: a content backup is restored; resync instead of expecting individual record events.
26
+ announcement.published: an announcement goes up (acms({ announcements }); scheduled ones when their time comes).
26
27
  Existing event.* and booking.* payloads are described at /api/calendar/docs.
27
28
 
28
29
  ## Payloads
@@ -34,6 +35,7 @@ page.* data: { "page": { "id", "slug", "title", "status", "locale", "isHome" },
34
35
  design.published data: { "design": { "revision", "activeTheme", "navigationChanged", "sectionPageIds" } }
35
36
  media.* data: { "media": { "id", "key", "filename", "mime", "width", "height", "size", "alt" } }
36
37
  site.restored data: { "counts": { "posts", "pages", "media" } }
38
+ announcement.published data: { "announcement": { the GET /api/v1/announcements/<id> item in the main language, plus "status" } }
37
39
 
38
40
  Content notifications carry metadata, not document bodies or pending drafts.
39
41
  On publication/update, fetch current public content from /api/v1/content/<id>?kind=post (or kind=page).
@@ -0,0 +1,32 @@
1
+ /* Built-in /announcements pages: on the page background (no card), the
2
+ kind in words first, urgent marked by its label rather than colour alone. */
3
+ .ann-page { max-width: 720px; margin: 0 auto; padding: 40px 20px 72px; }
4
+ .ann-head h1, .ann-detail h1 { font-size: clamp(28px, 4vw, 36px); letter-spacing: -0.03em; margin: 0 0 6px; }
5
+ .ann-head p { color: var(--muted); margin: 0 0 24px; }
6
+ .ann-tabs { display: flex; flex-wrap: wrap; gap: 4px 18px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
7
+ .ann-tabs a { color: var(--muted); text-decoration: none; padding: 10px 0; min-height: 44px; display: inline-flex; align-items: center; border-bottom: 2px solid transparent; margin-bottom: -1px; }
8
+ .ann-tabs a:hover { color: var(--ink); }
9
+ .ann-tabs a[aria-current='page'] { color: var(--ink); border-bottom-color: var(--ink); font-weight: 600; }
10
+ .ann-group h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 28px 0 4px; }
11
+ .ann-list { list-style: none; margin: 0; padding: 0; }
12
+ .ann-item { padding: 16px 0; border-bottom: 1px solid var(--line); }
13
+ .ann-item:last-child { border-bottom: 0; }
14
+ .ann-meta { font-size: 13px; color: var(--muted); margin: 0 0 4px; }
15
+ .ann-kind { font-weight: 600; color: var(--ink); }
16
+ [data-kind='urgent'] .ann-kind { text-transform: uppercase; letter-spacing: 0.04em; }
17
+ .ann-item h3 { font-size: 18px; margin: 0 0 4px; letter-spacing: -0.01em; }
18
+ .ann-item h3 a { color: inherit; text-decoration: none; }
19
+ .ann-item h3 a:hover { text-decoration: underline; text-underline-offset: 3px; }
20
+ .ann-message { margin: 0 0 6px; }
21
+ .ann-action { font-weight: 600; color: var(--accent-text, var(--accent)); text-decoration: none; display: inline-block; padding: 4px 0; }
22
+ .ann-action:hover { text-decoration: underline; text-underline-offset: 3px; }
23
+ .ann-empty { color: var(--muted); padding: 32px 0; }
24
+ .ann-back { margin: 0 0 20px; }
25
+ .ann-back a { color: var(--muted); text-decoration: none; }
26
+ .ann-back a:hover { color: var(--ink); }
27
+ .ann-lead { font-size: 18px; }
28
+ .ann-button {
29
+ display: inline-flex; align-items: center; min-height: 44px; padding: 0 20px; border-radius: var(--r-sm);
30
+ background: var(--btn); color: var(--btn-ink); text-decoration: none; font-weight: 600;
31
+ }
32
+ .ann-button:hover { opacity: 0.92; }