@acms-subir/core 0.3.32 → 0.3.34

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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/cli/main.mjs +1 -1
  3. package/cli/migrations.generated.mjs +35 -2
  4. package/docs/mobile.md +2 -0
  5. package/integration.mjs +46 -1
  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/cache/policy.ts +11 -3
  30. package/src/lib/legal/app-privacy.ts +1 -1
  31. package/src/lib/legal/facts.ts +4 -0
  32. package/src/lib/legal/text-en.ts +5 -3
  33. package/src/lib/legal/text-es.ts +5 -3
  34. package/src/lib/mobile/openapi.ts +3 -0
  35. package/src/lib/mobile/schemas.ts +9 -2
  36. package/src/lib/mobile/site.ts +3 -0
  37. package/src/lib/push/api.ts +9 -6
  38. package/src/lib/push/service.ts +8 -1
  39. package/src/lib/push/store.ts +20 -7
  40. package/src/lib/push/types.ts +4 -2
  41. package/src/lib/repo/migrations.ts +4 -0
  42. package/src/lib/repo/sql.ts +6 -0
  43. package/src/lib/repo/types.ts +5 -0
  44. package/src/lib/scheduler.ts +3 -0
  45. package/src/lib/schema.ts +1 -0
  46. package/src/pages/admin/announcements.astro +18 -0
  47. package/src/pages/admin/integrations.astro +2 -1
  48. package/src/pages/admin/settings.astro +2 -0
  49. package/src/pages/announcements/[id].astro +35 -0
  50. package/src/pages/announcements/index.astro +61 -0
  51. package/src/pages/api/announcements.ts +32 -0
  52. package/src/pages/api/v1/announcements/[id].ts +7 -0
  53. package/src/pages/api/v1/announcements/index.ts +8 -0
  54. package/src/pages/api/webhooks/docs.ts +2 -0
  55. package/src/styles/announcements-site.css +32 -0
  56. package/src/styles/announcements.css +210 -0
  57. package/src/styles/site.css +5 -0
  58. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,132 @@
1
+ // Announcements: words per language, what's up right now, publishing (with
2
+ // its push and webhook) and the public JSON the website and apps read.
3
+ import type { ContentRepository } from '../repo/types';
4
+ import { I18N } from '../i18n';
5
+ import { localizedUrl } from '../mobile/locale';
6
+ import { emitWebhook } from '../webhooks';
7
+ import { PUSH } from '../push/config';
8
+ import { sendNotification, siteOriginOf, STALE_AFTER, type Composed, type PushRunOptions, type SendInput } from '../push/service';
9
+ import type { PushNotification } from '../push/types';
10
+ import { ANNOUNCEMENTS } from './config';
11
+ import { announcementStrings } from './words';
12
+ import type { Announcement, AnnouncementWords } from './types';
13
+
14
+ /** The Android channel urgent announcements use, on phones whose app created it (see the native guide). */
15
+ export const URGENT_CHANNEL = 'urgent';
16
+
17
+ /** Each field in `locale`, falling back to the main words; `translated` = the language has its own title. */
18
+ export function localizeAnnouncement(a: Announcement, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
19
+ const main = { title: a.title, message: a.message, details: a.details, buttonLabel: a.buttonLabel };
20
+ if (!locale || locale === I18N.defaultLocale) return { ...main, translated: true };
21
+ const t = a.translations[locale] ?? a.translations[locale.toLowerCase().split('-')[0]];
22
+ if (!t) return { ...main, translated: false };
23
+ return {
24
+ title: t.title || main.title, message: t.message || main.message, details: t.details || main.details,
25
+ buttonLabel: t.buttonLabel || main.buttonLabel, translated: Boolean(t.title),
26
+ };
27
+ }
28
+
29
+ /** Up right now (published and not past its end). */
30
+ export const isLive = (a: Announcement, now: number) => a.status === 'published' && (a.showUntil === null || a.showUntil > now);
31
+
32
+ /** The built-in page for one announcement ('' when the site turned the pages off). */
33
+ export const announcementPath = (id: string) => (ANNOUNCEMENTS.pages ? `${ANNOUNCEMENTS.base}/${encodeURIComponent(id)}` : '');
34
+
35
+ /** Paragraphs of the details text (a blank line starts a new one). */
36
+ export const paragraphs = (text: string) => text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
37
+
38
+ export function announcementJson(a: Announcement, locale: string | null, origin: string) {
39
+ const w = localizeAnnouncement(a, locale);
40
+ const words = announcementStrings(locale || I18N.defaultLocale);
41
+ const abs = (path: string) => (path ? localizedUrl(new URL(path, origin).href, origin, locale) : null);
42
+ return {
43
+ id: a.id, kind: a.kind, kindLabel: words.kinds[a.kind],
44
+ title: w.title, message: w.message, details: w.details,
45
+ button: a.link ? { label: w.buttonLabel || words.more, url: abs(a.link)! } : null,
46
+ url: abs(announcementPath(a.id)),
47
+ pinned: a.pinned, urgent: a.kind === 'urgent',
48
+ publishedAt: new Date(a.publishedAt ?? a.updatedAt).toISOString(),
49
+ showUntil: a.showUntil === null ? null : new Date(a.showUntil).toISOString(),
50
+ translated: w.translated,
51
+ };
52
+ }
53
+ export type AnnouncementJson = ReturnType<typeof announcementJson>;
54
+
55
+ // ---- push ----------------------------------------------------------------------
56
+
57
+ /**
58
+ * The push for an announcement: its title and short message in each phone's
59
+ * language. A tap opens the announcement's page (the app routes data.announcementId
60
+ * to its own screen); without the built-in pages, its button link. The button's
61
+ * link also travels as data.buttonUrl/buttonPath.
62
+ */
63
+ export function announcementNotice(a: Announcement): SendInput {
64
+ const words = (locale: string | null): Composed => {
65
+ const w = localizeAnnouncement(a, locale);
66
+ return { title: w.title, body: w.message, url: announcementPath(a.id) || a.link };
67
+ };
68
+ return {
69
+ ...words(null), audience: a.kind === 'urgent' ? ['*'] : a.audience, source: 'announcement', sourceKey: `announcement:${a.id}`,
70
+ author: a.author, channel: a.kind === 'urgent' ? URGENT_CHANNEL : undefined, button: a.link || undefined,
71
+ data: { kind: 'announcement', id: a.id, announcementId: a.id, announcementKind: a.kind },
72
+ localize: (l) => words(l),
73
+ };
74
+ }
75
+
76
+ async function pushOnce(repo: ContentRepository, a: Announcement, run: PushRunOptions & { now: number }) {
77
+ const key = `announcement:${a.id}`;
78
+ if (!(await repo.push.claimKey(key, 'announcement', a.id, null, run.now))) return null;
79
+ // a scheduled push the scheduler reaches hours late (it was down) isn't sent late
80
+ if (a.publishAt !== null && run.now - a.publishAt > STALE_AFTER) {
81
+ await repo.push.markQueue(key, 'skipped', null, run.now);
82
+ return null;
83
+ }
84
+ try {
85
+ const n = await sendNotification(repo, announcementNotice(a), run);
86
+ await repo.push.markQueue(key, 'sent', n.id, run.now);
87
+ return n;
88
+ } catch (e) {
89
+ await repo.push.markQueue(key, 'failed', null, run.now).catch(() => {});
90
+ throw e;
91
+ }
92
+ }
93
+
94
+ export interface PublishOptions extends PushRunOptions { waitUntil?: (p: Promise<unknown>) => void }
95
+
96
+ /**
97
+ * Puts a draft or scheduled announcement up now: status published, the
98
+ * `announcement.published` webhook and, when chosen, its push (once ever, through
99
+ * the push ledger). Null when another request published it first.
100
+ */
101
+ export async function publishAnnouncement(repo: ContentRepository, a: Announcement, opts: PublishOptions = {}) {
102
+ const now = opts.now ?? Date.now();
103
+ const up = await repo.announcements.update(a.id, { status: a.showUntil !== null && a.showUntil <= now ? 'ended' : 'published', publishedAt: now, updatedAt: now }, ['draft', 'scheduled']);
104
+ if (!up) return null;
105
+ const settings = await repo.getSettings();
106
+ const origin = opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin);
107
+ let notification: PushNotification | null = null;
108
+ let pushError = '';
109
+ if (up.push && PUSH.enabled && up.status === 'published') {
110
+ try {
111
+ notification = await pushOnce(repo, up, { ...opts, now, origin });
112
+ if (notification) await repo.announcements.update(up.id, { notificationId: notification.id });
113
+ } catch (e) {
114
+ console.error('acms announcements: push failed', e);
115
+ pushError = e instanceof Error ? e.message : String(e);
116
+ }
117
+ }
118
+ await emitWebhook(repo, 'announcement.published', { announcement: { ...announcementJson(up, I18N.defaultLocale || null, origin), status: up.status } }, { waitUntil: opts.waitUntil }).catch(() => {});
119
+ return { announcement: { ...up, notificationId: notification?.id ?? up.notificationId }, notification, pushError };
120
+ }
121
+
122
+ /** Scheduler entry: scheduled announcements go up (and push), ended ones come down. */
123
+ export async function runAnnouncementJobs(repo: ContentRepository, opts: PublishOptions = {}): Promise<number> {
124
+ if (!ANNOUNCEMENTS.enabled) return 0;
125
+ const now = opts.now ?? Date.now();
126
+ let count = 0;
127
+ for (const a of await repo.announcements.due(now)) {
128
+ if (await publishAnnouncement(repo, a, { ...opts, now })) count++;
129
+ }
130
+ count += await repo.announcements.endExpired(now);
131
+ return count;
132
+ }
@@ -0,0 +1,88 @@
1
+ import type { SqlDriver } from '../repo/sql';
2
+ import type { Announcement, AnnouncementKind, AnnouncementStatus, AnnouncementWords } from './types';
3
+
4
+ type Row = Record<string, unknown>;
5
+ const json = <T>(raw: unknown, fallback: T): T => { try { const v = JSON.parse(String(raw ?? '')); return v && typeof v === 'object' ? v as T : fallback; } catch { return fallback; } };
6
+ const num = (v: unknown) => (v == null ? null : Number(v));
7
+ function announcement(r: Row): Announcement {
8
+ return {
9
+ id: String(r.id), kind: r.kind as AnnouncementKind, title: String(r.title ?? ''), message: String(r.message ?? ''), details: String(r.details ?? ''),
10
+ buttonLabel: String(r.button_label ?? ''), link: String(r.link ?? ''), translations: json<Record<string, AnnouncementWords>>(r.translations, {}),
11
+ listed: Number(r.listed) === 1, push: Number(r.push) === 1, pinned: Number(r.pinned) === 1,
12
+ audience: json<string[]>(r.audience, ['*']).map(String), status: r.status as AnnouncementStatus,
13
+ publishAt: num(r.publish_at), showUntil: num(r.show_until), publishedAt: num(r.published_at),
14
+ notificationId: r.notification_id == null ? null : String(r.notification_id), author: String(r.author ?? ''),
15
+ createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
16
+ };
17
+ }
18
+ const COLUMNS: [keyof Announcement, string][] = [
19
+ ['kind', 'kind'], ['title', 'title'], ['message', 'message'], ['details', 'details'], ['buttonLabel', 'button_label'], ['link', 'link'],
20
+ ['translations', 'translations'], ['listed', 'listed'], ['push', 'push'], ['pinned', 'pinned'], ['audience', 'audience'], ['status', 'status'],
21
+ ['publishAt', 'publish_at'], ['showUntil', 'show_until'], ['publishedAt', 'published_at'], ['notificationId', 'notification_id'],
22
+ ['author', 'author'], ['updatedAt', 'updated_at'],
23
+ ];
24
+ const value = (v: unknown) => (typeof v === 'boolean' ? (v ? 1 : 0) : v !== null && typeof v === 'object' ? JSON.stringify(v) : v ?? null);
25
+
26
+ /** The INSERT for one announcement (create and backup restore). */
27
+ export function announcementInsert(a: Announcement): { sql: string; args: unknown[] } {
28
+ const cols = ['id', 'created_at', ...COLUMNS.map(([, c]) => c)];
29
+ return { sql: `INSERT INTO announcements (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`, args: [a.id, a.createdAt, ...COLUMNS.map(([k]) => value(a[k]))] };
30
+ }
31
+
32
+ // what visitors see right now: published, in the list or pinned, not past its end
33
+ const LIVE = `status='published' AND (show_until IS NULL OR show_until>?)`;
34
+
35
+ export interface PublicQuery { kind?: AnnouncementKind; pinned?: boolean; limit: number; offset: number }
36
+
37
+ // Storage for announcements. Shared by the D1 and libSQL adapters (plain SQLite).
38
+ export class AnnouncementStore {
39
+ constructor(private driver: () => Promise<SqlDriver>) {}
40
+ private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
41
+
42
+ /** Newest first; `limit` -1 = all (backups). */
43
+ async list(limit = 500): Promise<Announcement[]> {
44
+ return (await this.all(`SELECT * FROM announcements ORDER BY COALESCE(published_at, publish_at, updated_at) DESC LIMIT ?`, [limit])).map(announcement);
45
+ }
46
+ async get(id: string): Promise<Announcement | null> {
47
+ const r = await this.all('SELECT * FROM announcements WHERE id=?', [id]);
48
+ return r[0] ? announcement(r[0]) : null;
49
+ }
50
+ async create(a: Announcement): Promise<Announcement> {
51
+ const insert = announcementInsert(a);
52
+ return announcement((await this.all(`${insert.sql} RETURNING *`, insert.args))[0]);
53
+ }
54
+ /** Writes the given fields; `onlyIf` guards a status change against a concurrent one (null when it no longer matches). */
55
+ async update(id: string, patch: Partial<Announcement>, onlyIf?: AnnouncementStatus[]): Promise<Announcement | null> {
56
+ const set = COLUMNS.filter(([k]) => k in patch);
57
+ if (!set.length) return this.get(id);
58
+ const guard = onlyIf?.length ? ` AND status IN (${onlyIf.map(() => '?').join(',')})` : '';
59
+ const rows = await this.all(`UPDATE announcements SET ${set.map(([, c]) => `${c}=?`).join(',')} WHERE id=?${guard} RETURNING *`,
60
+ [...set.map(([k]) => value(patch[k])), id, ...(onlyIf ?? [])]);
61
+ return rows[0] ? announcement(rows[0]) : null;
62
+ }
63
+ async delete(id: string): Promise<boolean> {
64
+ return (await this.all('DELETE FROM announcements WHERE id=? RETURNING id', [id])).length > 0;
65
+ }
66
+
67
+ /** Scheduled ones whose time has come. */
68
+ async due(now: number, limit = 10): Promise<Announcement[]> {
69
+ return (await this.all(`SELECT * FROM announcements WHERE status='scheduled' AND COALESCE(publish_at,0)<=? ORDER BY publish_at LIMIT ?`, [now, limit])).map(announcement);
70
+ }
71
+ /** Published ones past their end: they come down. */
72
+ async endExpired(now: number): Promise<number> {
73
+ return (await this.all(`UPDATE announcements SET status='ended', updated_at=? WHERE status='published' AND show_until IS NOT NULL AND show_until<=? RETURNING id`, [now, now])).length;
74
+ }
75
+ /** Up right now, newest first. `pinned` = only banners; otherwise only the ones in the list. */
76
+ async live(now: number, q: PublicQuery): Promise<Announcement[]> {
77
+ const where = [LIVE, q.pinned ? 'pinned=1' : 'listed=1'];
78
+ const args: unknown[] = [now];
79
+ if (q.kind) { where.push('kind=?'); args.push(q.kind); }
80
+ // banners: urgent first
81
+ const order = q.pinned ? `kind='urgent' DESC, published_at DESC, id` : 'published_at DESC, id';
82
+ return (await this.all(`SELECT * FROM announcements WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ? OFFSET ?`, [...args, q.limit, q.offset])).map(announcement);
83
+ }
84
+ /** Banners up right now: urgent first, then newest. */
85
+ async banners(now: number, limit = 5): Promise<Announcement[]> {
86
+ return (await this.all(`SELECT * FROM announcements WHERE ${LIVE} AND pinned=1 ORDER BY kind='urgent' DESC, published_at DESC LIMIT ?`, [now, limit])).map(announcement);
87
+ }
88
+ }
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+
3
+ // Announcements (Admin → Announcements): stored messages for members that
4
+ // feed the website list and banner, the apps' list and banner, and an
5
+ // optional push. The kind sets the icon and colour; urgent is the only one
6
+ // that reaches every device with notifications on.
7
+ export const ANNOUNCEMENT_KINDS = ['event', 'registration', 'help', 'info', 'urgent'] as const;
8
+ export const AnnouncementKind = z.enum(ANNOUNCEMENT_KINDS);
9
+ export type AnnouncementKind = z.infer<typeof AnnouncementKind>;
10
+ export type AnnouncementStatus = 'draft' | 'scheduled' | 'published' | 'ended' | 'archived';
11
+
12
+ export const LIMITS = { title: 80, message: 240, details: 5000, buttonLabel: 30 } as const;
13
+
14
+ /** The words of one language. Empty fields in a translation fall back to the main language. */
15
+ export const AnnouncementWords = z.object({
16
+ title: z.string().trim().max(LIMITS.title).default(''),
17
+ /** the push text and the line under the title */
18
+ message: z.string().trim().max(LIMITS.message).default(''),
19
+ /** shown in the app and on the website; blank lines start paragraphs */
20
+ details: z.string().trim().max(LIMITS.details).default(''),
21
+ buttonLabel: z.string().trim().max(LIMITS.buttonLabel).default(''),
22
+ });
23
+ export type AnnouncementWords = z.infer<typeof AnnouncementWords>;
24
+
25
+ export const announcementLink = z.string().trim().max(2000)
26
+ .refine((v) => !v || /^\/(?!\/)/.test(v) || /^https?:\/\//i.test(v), 'Use a site path such as /events or a full https:// address.');
27
+ const locale = z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i);
28
+
29
+ export interface Announcement extends AnnouncementWords {
30
+ id: string;
31
+ kind: AnnouncementKind;
32
+ /** site path or https URL the button opens; '' = no button */
33
+ link: string;
34
+ translations: Record<string, AnnouncementWords>;
35
+ /** in the announcements list (website and apps) */
36
+ listed: boolean;
37
+ /** pushed to phones when it goes up */
38
+ push: boolean;
39
+ /** shown as a banner while it's up */
40
+ pinned: boolean;
41
+ /** push audience: topic ids, or ['*'] for every device (always for urgent) */
42
+ audience: string[];
43
+ status: AnnouncementStatus;
44
+ /** when it goes up; null = as soon as it's scheduled */
45
+ publishAt: number | null;
46
+ /** when it comes down; null = until archived */
47
+ showUntil: number | null;
48
+ publishedAt: number | null;
49
+ /** the push sent when it went up */
50
+ notificationId: string | null;
51
+ author: string;
52
+ createdAt: number;
53
+ updatedAt: number;
54
+ }
55
+
56
+ export const AnnouncementInput = AnnouncementWords.extend({
57
+ kind: AnnouncementKind.default('info'),
58
+ link: announcementLink.default(''),
59
+ translations: z.record(locale, AnnouncementWords).default({}),
60
+ listed: z.boolean().default(true),
61
+ push: z.boolean().default(false),
62
+ pinned: z.boolean().default(false),
63
+ audience: z.array(z.string().min(1).max(200)).max(50).default(['*']),
64
+ publishAt: z.number().int().nonnegative().nullable().default(null),
65
+ showUntil: z.number().int().nonnegative().nullable().default(null),
66
+ }).strict();
67
+ export type AnnouncementInput = z.infer<typeof AnnouncementInput>;
68
+
69
+ /** An announcement as stored (backups). */
70
+ export const AnnouncementRecord = AnnouncementInput.extend({
71
+ id: z.string().min(1).max(100),
72
+ status: z.enum(['draft', 'scheduled', 'published', 'ended', 'archived']),
73
+ publishedAt: z.number().int().nullable(),
74
+ notificationId: z.string().nullable(),
75
+ author: z.string().max(200).default(''),
76
+ createdAt: z.number().int(),
77
+ updatedAt: z.number().int(),
78
+ });
@@ -0,0 +1,55 @@
1
+ import type { AnnouncementKind } from './types';
2
+
3
+ // Words the website pages, the API (kindLabel) and the default button use.
4
+ // Other languages fall back to English.
5
+ export interface AnnouncementStrings {
6
+ kinds: Record<AnnouncementKind, string>;
7
+ title: string;
8
+ intro: string;
9
+ all: string;
10
+ more: string;
11
+ empty: string;
12
+ emptyKind: string;
13
+ today: string;
14
+ thisWeek: string;
15
+ earlier: string;
16
+ until: (date: string) => string;
17
+ back: string;
18
+ dismiss: string;
19
+ }
20
+
21
+ const STRINGS: Record<string, AnnouncementStrings> = {
22
+ en: {
23
+ kinds: { event: 'Event', registration: 'Registration', help: 'Help needed', info: 'Good to know', urgent: 'Urgent' },
24
+ title: 'Announcements', intro: 'What’s happening and what we need this week.', all: 'All', more: 'Learn more',
25
+ empty: 'No announcements right now.', emptyKind: 'Nothing here right now.',
26
+ today: 'Today', thisWeek: 'This week', earlier: 'Earlier', until: (d) => `Until ${d}`, back: 'All announcements', dismiss: 'Dismiss announcement',
27
+ },
28
+ es: {
29
+ kinds: { event: 'Evento', registration: 'Inscripción', help: 'Se necesita ayuda', info: 'Para saber', urgent: 'Urgente' },
30
+ title: 'Anuncios', intro: 'Lo que está pasando y lo que necesitamos esta semana.', all: 'Todos', more: 'Más información',
31
+ empty: 'No hay anuncios por ahora.', emptyKind: 'Nada por aquí por ahora.',
32
+ today: 'Hoy', thisWeek: 'Esta semana', earlier: 'Antes', until: (d) => `Hasta el ${d}`, back: 'Todos los anuncios', dismiss: 'Cerrar anuncio',
33
+ },
34
+ fr: {
35
+ kinds: { event: 'Événement', registration: 'Inscription', help: 'Besoin d’aide', info: 'Bon à savoir', urgent: 'Urgent' },
36
+ title: 'Annonces', intro: 'Ce qui se passe et ce dont nous avons besoin cette semaine.', all: 'Toutes', more: 'En savoir plus',
37
+ empty: 'Aucune annonce pour le moment.', emptyKind: 'Rien ici pour le moment.',
38
+ today: 'Aujourd’hui', thisWeek: 'Cette semaine', earlier: 'Plus tôt', until: (d) => `Jusqu’au ${d}`, back: 'Toutes les annonces', dismiss: 'Fermer l’annonce',
39
+ },
40
+ de: {
41
+ kinds: { event: 'Veranstaltung', registration: 'Anmeldung', help: 'Hilfe gesucht', info: 'Gut zu wissen', urgent: 'Dringend' },
42
+ title: 'Ankündigungen', intro: 'Was los ist und was wir diese Woche brauchen.', all: 'Alle', more: 'Mehr erfahren',
43
+ empty: 'Gerade keine Ankündigungen.', emptyKind: 'Hier ist gerade nichts.',
44
+ today: 'Heute', thisWeek: 'Diese Woche', earlier: 'Früher', until: (d) => `Bis ${d}`, back: 'Alle Ankündigungen', dismiss: 'Ankündigung schließen',
45
+ },
46
+ pt: {
47
+ kinds: { event: 'Evento', registration: 'Inscrição', help: 'Precisamos de ajuda', info: 'Bom saber', urgent: 'Urgente' },
48
+ title: 'Avisos', intro: 'O que está acontecendo e do que precisamos esta semana.', all: 'Todos', more: 'Saiba mais',
49
+ empty: 'Nenhum aviso no momento.', emptyKind: 'Nada aqui no momento.',
50
+ today: 'Hoje', thisWeek: 'Esta semana', earlier: 'Antes', until: (d) => `Até ${d}`, back: 'Todos os avisos', dismiss: 'Fechar aviso',
51
+ },
52
+ };
53
+
54
+ export const announcementStrings = (locale: string | null | undefined): AnnouncementStrings =>
55
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { SavedForm, FormSubmission } from '../forms/schema';
3
+ import { AnnouncementRecord } from '../announcements/types';
3
4
  import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place } from '../schema';
4
5
  import type { ContentRepository } from '../repo/types';
5
6
 
@@ -18,6 +19,8 @@ export const BackupContent = z.object({
18
19
  bookingLinks: z.array(z.preprocess(legacyApproval, BookingLink)).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
19
20
  places: z.array(Place).default([]),
20
21
  forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
22
+ // optional (no version bump): older cores restore such a backup and skip them
23
+ announcements: z.array(AnnouncementRecord).optional(),
21
24
  }).superRefine((data, ctx) => {
22
25
  if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
23
26
  if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
@@ -36,11 +39,12 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
36
39
  repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
37
40
  repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
38
41
  ]);
42
+ const announcements = await repo.announcements.list(-1);
39
43
  return {
40
44
  format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
41
45
  settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
42
46
  pages, posts, media, links, tags, navItems, users, redirects,
43
47
  calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
44
- resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions,
48
+ resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements,
45
49
  };
46
50
  }
@@ -8,6 +8,12 @@ import { I18N } from '../i18n';
8
8
 
9
9
  export const PRIVATE_CACHE_CONTROL = 'private, no-store';
10
10
  export const publicCacheControl = () => `public, s-maxage=${CACHE.maxAge}, stale-while-revalidate=${CACHE.staleWhileRevalidate}`;
11
+ // The public app API: browsers (Expo web, web apps) revalidate by ETag every
12
+ // time, since stale-while-revalidate would hand them an old /site without a
13
+ // feature the app just got. CDNs that read CDN-Cache-Control (RFC 9213, e.g.
14
+ // Cloudflare) still serve it stale while they refresh, like pages; others fall
15
+ // back to s-maxage.
16
+ export const apiCacheControl = () => `public, max-age=0, s-maxage=${CACHE.maxAge}`;
11
17
  // publicResponse's own header: browsers/apps always revalidate by ETag. The
12
18
  // policy upgrades it with s-maxage so a CDN can share it.
13
19
  export const API_REVALIDATE = 'public, max-age=0, must-revalidate';
@@ -64,12 +70,13 @@ export function applyCachePolicy(request: Request, response: Response, pathname
64
70
  if (!CACHE.enabled) return response;
65
71
  if (OWN_HEADERS.test(pathname)) return response;
66
72
  const current = response.headers.get('cache-control');
67
- const set = (value: string) => {
68
- try { response.headers.set('Cache-Control', value); return response; }
73
+ const set = (value: string, cdn?: string) => {
74
+ const write = (h: Headers) => { h.set('Cache-Control', value); if (cdn) h.set('CDN-Cache-Control', cdn); };
75
+ try { write(response.headers); return response; }
69
76
  catch {
70
77
  // immutable headers (a proxied/fetched response): copy it
71
78
  const copy = new Response(response.body, response);
72
- copy.headers.set('Cache-Control', value);
79
+ write(copy.headers);
73
80
  return copy;
74
81
  }
75
82
  };
@@ -85,5 +92,6 @@ export function applyCachePolicy(request: Request, response: Response, pathname
85
92
  warnCookie(pathname);
86
93
  return set(PRIVATE_CACHE_CONTROL);
87
94
  }
95
+ if (pathname.startsWith('/api/v1/')) return set(apiCacheControl(), publicCacheControl());
88
96
  return set(publicCacheControl());
89
97
  }
@@ -29,7 +29,7 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
29
29
  google: 'Personal info → Email address. Collected, not shared. Purpose: App functionality, Account management. Optional.',
30
30
  });
31
31
  if (f.push.enabled) rows.push({
32
- data: 'Push token, platform, language and chosen topics', when: 'When someone turns on notifications',
32
+ data: 'Push token, platform, language, chosen topics and Android notification channels', when: 'When someone turns on notifications',
33
33
  apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
34
34
  google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
35
35
  });
@@ -3,6 +3,7 @@ import type { ContentRepository } from '../repo/types';
3
3
  import type { Settings } from '../schema';
4
4
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
5
5
  import { PUSH } from '../push/config';
6
+ import { ANNOUNCEMENTS } from '../announcements/config';
6
7
  import { AUTH } from '../auth/config';
7
8
  import { EDITOR } from '../editor/config';
8
9
  import { SITE_ROLES } from '../roles';
@@ -39,6 +40,8 @@ export interface LegalFacts {
39
40
  /** maps provider name, or '' */
40
41
  maps: string;
41
42
  live: boolean;
43
+ /** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
44
+ announcements: boolean;
42
45
  analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
43
46
  ai: boolean;
44
47
  offsiteBackups: boolean;
@@ -79,6 +82,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
79
82
  email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
80
83
  maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
81
84
  live: settings.live.mode !== 'off',
85
+ announcements: ANNOUNCEMENTS.enabled,
82
86
  analytics: {
83
87
  umami: a.umami.enabled && !!a.umami.websiteId,
84
88
  replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
@@ -52,8 +52,8 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
52
52
  'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
53
53
  ] });
54
54
  if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
55
- 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language and the topics you choose. We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
56
- `You choose the topics in the app and can change them at any time; we never add a topic without asking you. To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
55
+ 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
56
+ `You choose the topics in the app and can change them at any time; we never add a topic without asking you. ${f.announcements ? 'Urgent announcements, such as cancellations, go to every device with notifications turned on, whatever topics it chose.' : 'Important notices, such as cancellations, may go to every device with notifications turned on, whatever topics it chose.'} To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
57
57
  ] });
58
58
  if (f.email) s.push({ id: 'email', title: 'Email', text: [
59
59
  `We send email through ${f.email}, which processes your email address and the message. We keep a delivery record (recipient and status) for 30 days, and keep addresses that bounce or report our email as spam on a list so we stop writing to them.`,
@@ -79,7 +79,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
79
79
  ] });
80
80
  s.push({ id: 'cookies', title: 'Cookies and storage on your device', text: [
81
81
  `${f.auth === 'clerk' ? 'Clerk’s sign-in cookies' : 'A sign-in cookie (up to seven days)'} for people who sign in; a preview cookie only for staff previews and design share links (up to 30 days); and, in your browser, your light or dark mode choice, the announcements you close and, for one visit, the live video notices you close.${pixels ? ` ${pixelNames} ${f.analytics.meta && f.analytics.tiktok ? 'set their' : 'sets its'} own advertising cookies.` : ' We don’t use cookies for advertising.'}`,
82
- ...(app ? ['Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
82
+ ...(app ? [f.announcements
83
+ ? 'Our app may keep the content it last loaded on your phone so it works offline, and remembers there your notification choice and which announcements you’ve read or closed. That stays on your phone and is never sent to us.'
84
+ : 'Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
83
85
  ] });
84
86
  s.push({ id: 'sharing', title: 'Who receives your information', text: [
85
87
  `We don’t sell your personal information${pixels ? '' : ' or share it for advertising'}. These service providers process it on our behalf and only for the purposes above: ${list(processors(f))}. They must protect it at least as this notice describes. Some of them may store it outside your country, including in the United States.`,
@@ -53,8 +53,8 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
53
53
  'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
54
54
  ] });
55
55
  if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
56
- 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma y los temas que elija. Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
57
- `Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
56
+ 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
57
+ `Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. ${f.announcements ? 'Los anuncios urgentes, como una cancelación, llegan a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.' : 'Los avisos importantes, como una cancelación, pueden llegar a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.'} Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
58
58
  ] });
59
59
  if (f.email) s.push({ id: 'email', title: 'Correo electrónico', text: [
60
60
  `Enviamos correos a través de ${f.email}, que trata su dirección de correo y el mensaje. Guardamos un registro de entrega (destinatario y estado) durante 30 días, y mantenemos en una lista las direcciones que rebotan o marcan nuestros correos como spam para dejar de escribirles.`,
@@ -80,7 +80,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
80
80
  ] });
81
81
  s.push({ id: 'cookies', title: 'Cookies y almacenamiento en su dispositivo', text: [
82
82
  `${f.auth === 'clerk' ? 'Las cookies de inicio de sesión de Clerk' : 'Una cookie de inicio de sesión (hasta siete días)'} para quienes inician sesión; una cookie de vista previa solo para las vistas previas del personal y los enlaces para compartir diseños (hasta 30 días); y, en su navegador, su preferencia de modo claro u oscuro, los anuncios que cierra y, durante una visita, los avisos de video en vivo que cierra.${pixels ? ` ${pixelNames} ${both ? 'guardan sus propias' : 'guarda sus propias'} cookies de publicidad.` : ' No usamos cookies con fines publicitarios.'}`,
83
- ...(app ? ['Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
83
+ ...(app ? [f.announcements
84
+ ? 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones y qué anuncios leyó o cerró. Eso se queda en su teléfono y nunca se nos envía.'
85
+ : 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
84
86
  ] });
85
87
  s.push({ id: 'sharing', title: 'Quién recibe sus datos', text: [
86
88
  `No vendemos sus datos personales${pixels ? '' : ' ni los compartimos con fines publicitarios'}. Estos proveedores los tratan por cuenta nuestra y solo para los fines descritos: ${list(processors(f))}. Deben protegerlos al menos como lo describe este aviso. Algunos pueden guardarlos fuera de su país, incluido Estados Unidos.`,
@@ -10,6 +10,7 @@ import { manageAvailabilityQuery, rescheduleInput } from './manage';
10
10
  import { formResponseInput } from './forms';
11
11
  import { codeInput, tokenInput, myBookingsQuery } from './guest';
12
12
  import { deviceInput, deviceUpdate } from '../push/api';
13
+ import { announcementsQuery } from '../announcements/api';
13
14
  import { localeOnlyQuery } from './locale';
14
15
  import { pageQuery, queryParams } from './http';
15
16
  import * as schemas from './schemas';
@@ -50,6 +51,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
50
51
  ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
51
52
  ['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
52
53
  ['/push/topics', 'Topics an app can offer as notification toggles, named in the requested language; requires push', localeOnlyQuery, ref('PushTopics')],
54
+ ['/announcements', 'Announcements up right now, newest first, in the requested language; pinned=true lists only the banners, urgent first; requires announcements', announcementsQuery, ref('Announcements')],
55
+ ['/announcements/{id}', 'One announcement that is up, in the requested language; requires announcements', localeOnlyQuery, ref('AnnouncementDetail')],
53
56
  ['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
54
57
  ['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
55
58
  ['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
12
12
  legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
13
13
  });
14
14
  export const Theme = z.object({
@@ -101,11 +101,18 @@ export const BookingResult = z.object({ manageToken: str, booking: Booking.pick(
101
101
  id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, formResponse: true, source: true,
102
102
  }).extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date }) });
103
103
  export const Device = z.object({
104
- token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, registeredAt: date, lastSeenAt: date,
104
+ token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, channels: strings, registeredAt: date, lastSeenAt: date,
105
105
  });
106
106
  export const DeviceRegistration = z.object({ created: bool, device: Device });
107
107
  export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
108
108
  export const PushTopics = z.object({ locale, topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar', 'site']), description: str.optional() })) });
109
+ export const Announcement = z.object({
110
+ id: str, kind: z.enum(['event', 'registration', 'help', 'info', 'urgent']), kindLabel: str,
111
+ title: str, message: str, details: str, button: z.object({ label: str, url: str }).nullable(), url: str.nullable(),
112
+ pinned: bool, urgent: bool, publishedAt: date, showUntil: date.nullable(), translated: bool,
113
+ });
114
+ export const Announcements = z.object({ locale, announcements: z.array(Announcement), pagination: Pagination });
115
+ export const AnnouncementDetail = Announcement.extend({ locale });
109
116
  // times are Unix milliseconds (as on the website); compare them with serverNow, not the device clock
110
117
  const ms = z.number().int();
111
118
  export const LiveSnapshot = z.object({
@@ -3,6 +3,7 @@ import type { ContentRepository } from '../repo/types';
3
3
  import { CONTENT, legacyBases } from '../content';
4
4
  import { I18N, localeName } from '../i18n';
5
5
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
6
+ import { ANNOUNCEMENTS } from '../announcements/config';
6
7
  import { SEARCH } from '../search-config';
7
8
  import { siteOrigin } from '../seo';
8
9
  import { resolveDesign } from '../theme';
@@ -42,6 +43,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
42
43
  forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
43
44
  // Integrations → Live video; GET /api/v1/live answers 404 while it's off
44
45
  live: settings.live.mode !== 'off',
46
+ // acms({ announcements }): GET /api/v1/announcements
47
+ announcements: ANNOUNCEMENTS.enabled,
45
48
  },
46
49
  // in the requested language (?locale=es → /es/privacy)
47
50
  legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },