@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.
- package/CHANGELOG.md +27 -0
- package/cli/main.mjs +1 -1
- package/cli/migrations.generated.mjs +35 -2
- package/docs/mobile.md +7 -3
- package/integration.mjs +49 -2
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/recipes/announcements/files/app/announcements/[id].tsx +43 -0
- package/mobile-template/recipes/announcements/files/app/announcements/index.tsx +155 -0
- package/mobile-template/recipes/announcements/files/components/AnnouncementBanners.tsx +47 -0
- package/mobile-template/recipes/announcements/files/components/AnnouncementIcon.tsx +21 -0
- package/mobile-template/recipes/announcements/recipe.json +19 -0
- package/package.json +1 -1
- package/src/components/Announcements.tsx +806 -0
- package/src/components/MobileApp.tsx +1 -1
- package/src/layouts/Admin.astro +4 -0
- package/src/layouts/Site.astro +42 -2
- package/src/lib/agent-discovery.ts +6 -2
- package/src/lib/agent.ts +5 -0
- package/src/lib/announcements/agent.ts +49 -0
- package/src/lib/announcements/api.ts +222 -0
- package/src/lib/announcements/config.ts +8 -0
- package/src/lib/announcements/migration.ts +21 -0
- package/src/lib/announcements/pages.ts +54 -0
- package/src/lib/announcements/service.ts +132 -0
- package/src/lib/announcements/store.ts +88 -0
- package/src/lib/announcements/types.ts +78 -0
- package/src/lib/announcements/words.ts +55 -0
- package/src/lib/backup/content.ts +5 -1
- package/src/lib/mobile/config.mjs +18 -2
- package/src/lib/mobile/locale.ts +2 -0
- package/src/lib/mobile/openapi.ts +6 -2
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/mobile/site.ts +8 -3
- package/src/lib/push/api.ts +12 -8
- package/src/lib/push/config.ts +3 -1
- package/src/lib/push/service.ts +40 -12
- package/src/lib/push/store.ts +20 -7
- package/src/lib/push/types.ts +5 -3
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +6 -0
- package/src/lib/repo/types.ts +5 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +1 -0
- package/src/pages/admin/announcements.astro +18 -0
- package/src/pages/admin/integrations.astro +2 -1
- package/src/pages/admin/settings.astro +2 -0
- package/src/pages/announcements/[id].astro +35 -0
- package/src/pages/announcements/index.astro +61 -0
- package/src/pages/api/announcements.ts +32 -0
- package/src/pages/api/v1/announcements/[id].ts +7 -0
- package/src/pages/api/v1/announcements/index.ts +8 -0
- package/src/pages/api/webhooks/docs.ts +2 -0
- package/src/styles/announcements-site.css +32 -0
- package/src/styles/announcements.css +210 -0
- package/src/styles/site.css +5 -0
- package/src/virtual.d.ts +2 -1
|
@@ -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
|
}
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
// words in each site language ({ en: 'Help needed', es: 'Se necesita ayuda' }), or one string for all
|
|
4
|
+
const words = (max) => z.union([z.string().trim().min(1).max(max), z.record(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/, 'Use language codes such as en or es-MX.'), z.string().trim().min(1).max(max))
|
|
5
|
+
.refine((o) => Object.keys(o).length > 0, 'Give at least one language.')]);
|
|
6
|
+
const RESERVED_TOPICS = new Set(['news', 'events', 'live']);
|
|
7
|
+
// a topic the site adds (registrations, help…): offered in the app, sent to from Admin → Mobile app
|
|
8
|
+
const SiteTopic = z.object({
|
|
9
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/, 'Use lowercase letters, numbers, - and _ (up to 40), e.g. help or registrations.')
|
|
10
|
+
.refine((id) => !RESERVED_TOPICS.has(id), 'news, events and live are built-in topics.'),
|
|
11
|
+
label: words(60),
|
|
12
|
+
description: words(200).optional(),
|
|
13
|
+
}).strict();
|
|
14
|
+
|
|
3
15
|
export const MobileConfig = z.object({
|
|
4
16
|
ios: z.object({
|
|
5
17
|
teamId: z.string().regex(/^[A-Z0-9]{10}$/, 'Use a 10-character Apple team ID.'),
|
|
@@ -15,6 +27,7 @@ export const MobileConfig = z.object({
|
|
|
15
27
|
push: z.union([z.boolean(), z.object({
|
|
16
28
|
enabled: z.boolean().default(true),
|
|
17
29
|
retentionDays: z.number().int().min(30).max(730).default(270),
|
|
30
|
+
topics: z.array(SiteTopic).max(20).refine((ts) => new Set(ts.map((t) => t.id)).size === ts.length, 'Each topic id once.').default([]),
|
|
18
31
|
}).strict()]).optional(),
|
|
19
32
|
// Editor apps (staff sign in through the browser, OAuth 2.0 + PKCE). Off
|
|
20
33
|
// unless redirect URIs are listed: only these exact URIs receive codes.
|
|
@@ -52,8 +65,11 @@ export function resolveMobile(value) {
|
|
|
52
65
|
const result = MobileConfig.safeParse(value === undefined ? {} : value);
|
|
53
66
|
if (!result.success) throw new Error(`acms: ${result.error.issues.map(i => `mobile.${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
54
67
|
const { push, editor, ...rest } = result.data;
|
|
55
|
-
const options = typeof push === 'object' ? push : { enabled: push === true, retentionDays: 270 };
|
|
56
|
-
|
|
68
|
+
const options = typeof push === 'object' ? push : { enabled: push === true, retentionDays: 270, topics: [] };
|
|
69
|
+
// one string = every language ('*')
|
|
70
|
+
const all = (w) => (w === undefined ? {} : typeof w === 'string' ? { '*': w } : w);
|
|
71
|
+
const topics = options.topics.map((t) => ({ id: t.id, label: all(t.label), description: all(t.description) }));
|
|
72
|
+
return { ...rest, push: { enabled: options.enabled, retentionDays: options.retentionDays, topics },
|
|
57
73
|
editor: editor ? { enabled: true, name: editor.name ?? '', redirectUris: [...new Set(editor.redirectUris)], accessTokenMinutes: editor.accessTokenMinutes, refreshTokenDays: editor.refreshTokenDays }
|
|
58
74
|
: { enabled: false, name: '', redirectUris: [], accessTokenMinutes: 60, refreshTokenDays: 90 } };
|
|
59
75
|
}
|
package/src/lib/mobile/locale.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { ApiError } from '../api';
|
|
|
3
3
|
import { I18N, localePath } from '../i18n';
|
|
4
4
|
|
|
5
5
|
export const localeQuery = z.string().min(1).max(35).optional();
|
|
6
|
+
/** routes whose only option is the language (/site, /push/topics) */
|
|
7
|
+
export const localeOnlyQuery = z.object({ locale: localeQuery }).strict();
|
|
6
8
|
export function mobileLocale(value?: string) {
|
|
7
9
|
if (value && !I18N.locales.includes(value)) throw new ApiError(400, 'Unknown locale.');
|
|
8
10
|
return value ?? (I18N.defaultLocale || null);
|
|
@@ -10,6 +10,8 @@ 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';
|
|
14
|
+
import { localeOnlyQuery } from './locale';
|
|
13
15
|
import { pageQuery, queryParams } from './http';
|
|
14
16
|
import * as schemas from './schemas';
|
|
15
17
|
import { editorPaths, editorSchemas, editorSecuritySchemes } from '../editor/openapi';
|
|
@@ -37,7 +39,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
37
39
|
empty.parse(queryParams(url));
|
|
38
40
|
const paths: Record<string, unknown> = {};
|
|
39
41
|
const reads: [string, string, z.AnyZodObject, unknown, boolean?][] = [
|
|
40
|
-
['/site', 'Site identity, navigation, languages and
|
|
42
|
+
['/site', 'Site identity, navigation, languages, features and legal links (locale: links in that language)', localeOnlyQuery, ref('Site')],
|
|
41
43
|
['/theme', 'Active Design tokens including overrides', empty, ref('Theme')],
|
|
42
44
|
['/pages/home', 'Published home page and resolved sections', pagesQuery, ref('Page')],
|
|
43
45
|
['/pages/{slug}', 'Published page and resolved sections (slug may contain slashes)', pagesQuery, ref('Page')],
|
|
@@ -48,7 +50,9 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
48
50
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
49
51
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
50
52
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
51
|
-
['/push/topics', 'Topics an app can offer as notification toggles; requires push',
|
|
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')],
|
|
52
56
|
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
53
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],
|
|
54
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
|
-
export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
|
|
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({
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -3,20 +3,23 @@ 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';
|
|
9
10
|
import { darkPalette, liveColor } from '../themeTokens';
|
|
10
11
|
import { safeUrl } from './content';
|
|
11
12
|
import { queryParams } from './http';
|
|
13
|
+
import { localeOnlyQuery, localizedUrl, mobileLocale } from './locale';
|
|
12
14
|
import { guestSignInReady } from './guest';
|
|
13
15
|
import { LEGAL } from '../legal/config';
|
|
14
16
|
|
|
15
17
|
export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
|
|
16
|
-
|
|
18
|
+
// locale: the legal links point at that language's pages (/es/privacy)
|
|
19
|
+
const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
17
20
|
const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
|
|
18
21
|
// the privacy notice and terms: the standard pages, or published pages at those slugs
|
|
19
|
-
const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? `${siteOrigin(settings, url)}/${slug}
|
|
22
|
+
const legalUrl = (slug: 'privacy' | 'terms') => (LEGAL.enabled || pages.some(p => p.status === 'published' && p.slug === slug) ? localizedUrl(`${siteOrigin(settings, url)}/${slug}`, siteOrigin(settings, url), locale) : null);
|
|
20
23
|
const origin = siteOrigin(settings, url);
|
|
21
24
|
const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
|
|
22
25
|
const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
|
|
@@ -40,8 +43,10 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
|
|
|
40
43
|
forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
|
|
41
44
|
// Integrations → Live video; GET /api/v1/live answers 404 while it's off
|
|
42
45
|
live: settings.live.mode !== 'off',
|
|
46
|
+
// acms({ announcements }): GET /api/v1/announcements
|
|
47
|
+
announcements: ANNOUNCEMENTS.enabled,
|
|
43
48
|
},
|
|
44
|
-
// in
|
|
49
|
+
// in the requested language (?locale=es → /es/privacy)
|
|
45
50
|
legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
|
|
46
51
|
apiVersion: 1,
|
|
47
52
|
};
|
package/src/lib/push/api.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CONTENT, collectionUrl } from '../content';
|
|
|
8
8
|
import { CALENDAR, eventUrl } from '../calendar/config';
|
|
9
9
|
import { eventIsListed } from '../calendar/events';
|
|
10
10
|
import { queryParams } from '../mobile/http';
|
|
11
|
+
import { localeOnlyQuery, mobileLocale } from '../mobile/locale';
|
|
11
12
|
import { EXPO_PUSH_BASE, PUSH, requirePush } from './config';
|
|
12
13
|
import { expoOptions } from './expo';
|
|
13
14
|
import { knownTopics, pushTopics, sendNotification, siteOriginOf, type PushRunOptions } from './service';
|
|
@@ -16,17 +17,20 @@ import { PushAuto, type PushDevice } from './types';
|
|
|
16
17
|
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
18
|
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
19
|
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.');
|
|
20
|
+
/** Android notification channels the app created, e.g. ['urgent'] (urgent announcements use it only then) */
|
|
21
|
+
const channelList = z.array(z.string().min(1).max(100).regex(/^[A-Za-z0-9._-]+$/, 'Use channel ids such as urgent.')).max(20);
|
|
19
22
|
export const deviceInput = z.object({
|
|
20
23
|
token: pushToken,
|
|
21
24
|
platform: z.enum(['ios', 'android']),
|
|
22
25
|
locale: locale.optional(),
|
|
23
26
|
/** Replaces the device's topics. Omit to keep them (new devices start with none). */
|
|
24
27
|
topics: topicList.optional(),
|
|
28
|
+
channels: channelList.optional(),
|
|
25
29
|
}).strict();
|
|
26
|
-
export const deviceUpdate = z.object({ locale: locale.optional(), topics: topicList.optional() }).strict();
|
|
30
|
+
export const deviceUpdate = z.object({ locale: locale.optional(), topics: topicList.optional(), channels: channelList.optional() }).strict();
|
|
27
31
|
|
|
28
32
|
export const deviceJson = (d: PushDevice) => ({
|
|
29
|
-
token: d.token, platform: d.platform, locale: d.locale || null, topics: d.topics,
|
|
33
|
+
token: d.token, platform: d.platform, locale: d.locale || null, topics: d.topics, channels: d.channels,
|
|
30
34
|
registeredAt: new Date(d.createdAt).toISOString(), lastSeenAt: new Date(d.lastSeenAt).toISOString(),
|
|
31
35
|
});
|
|
32
36
|
const deviceId = (token: string) => sha256Hex(`expo-push:${token}`);
|
|
@@ -51,15 +55,15 @@ export async function registerDevice(repo: ContentRepository, request: Request)
|
|
|
51
55
|
requirePush();
|
|
52
56
|
const body = deviceInput.parse(await readJson(request, 8_000));
|
|
53
57
|
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());
|
|
58
|
+
const { device, created } = await repo.push.upsertDevice({ id: await deviceId(body.token), token: body.token, platform: body.platform, locale: body.locale ?? '', topics, channels: body.channels && [...new Set(body.channels)] }, Date.now());
|
|
55
59
|
return { created, device: deviceJson(device) };
|
|
56
60
|
}
|
|
57
61
|
export async function updateDevice(repo: ContentRepository, request: Request, rawToken: string) {
|
|
58
62
|
requirePush();
|
|
59
63
|
const token = tokenParam(rawToken);
|
|
60
64
|
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
|
|
62
|
-
const device = await repo.push.updateDevice(await deviceId(token), { locale: body.locale, topics: await checkTopics(repo, body.topics) }, Date.now());
|
|
65
|
+
if (body.locale === undefined && body.topics === undefined && body.channels === undefined) throw new ApiError(400, 'Send topics, locale, channels or a mix.');
|
|
66
|
+
const device = await repo.push.updateDevice(await deviceId(token), { locale: body.locale, topics: await checkTopics(repo, body.topics), channels: body.channels && [...new Set(body.channels)] }, Date.now());
|
|
63
67
|
if (!device) throw new ApiError(404, 'This device is not registered. Register it with POST /api/v1/devices.');
|
|
64
68
|
return deviceJson(device);
|
|
65
69
|
}
|
|
@@ -72,14 +76,14 @@ export async function unregisterDevice(repo: ContentRepository, rawToken: string
|
|
|
72
76
|
}
|
|
73
77
|
export async function listPushTopics(repo: ContentRepository, url: URL) {
|
|
74
78
|
requirePush();
|
|
75
|
-
|
|
76
|
-
return { topics: await pushTopics(repo) };
|
|
79
|
+
const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
80
|
+
return { locale, topics: await pushTopics(repo, locale) };
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
// ---- admin -------------------------------------------------------------------
|
|
80
84
|
|
|
81
85
|
export interface PushLinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' }
|
|
82
|
-
async function linkTargets(repo: ContentRepository): Promise<PushLinkTarget[]> {
|
|
86
|
+
export async function linkTargets(repo: ContentRepository): Promise<PushLinkTarget[]> {
|
|
83
87
|
const [pages, posts, events, calendars] = await Promise.all([
|
|
84
88
|
repo.listPages(), repo.listPublishedPosts(),
|
|
85
89
|
CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
|
package/src/lib/push/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Push notifications config, set per project via the integration:
|
|
2
|
-
// acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270 } }
|
|
2
|
+
// acms({ mobile: { push: true } }) or { push: { enabled: true, retentionDays: 270, topics: [...] } }
|
|
3
3
|
// Off by default. When off, the device/topic endpoints and the admin Mobile
|
|
4
4
|
// app page are not registered at all (like other disabled modules), and the
|
|
5
5
|
// scheduler just marks any queued automatic notifications as skipped.
|
|
@@ -10,6 +10,8 @@ import { ApiError } from '../api';
|
|
|
10
10
|
export const PUSH = {
|
|
11
11
|
enabled: userConfig.mobile?.push?.enabled === true,
|
|
12
12
|
retentionDays: userConfig.mobile?.push?.retentionDays ?? 270,
|
|
13
|
+
/** topics the site adds (config mobile.push.topics); labels per language, '*' = every language */
|
|
14
|
+
topics: [...(userConfig.mobile?.push?.topics ?? [])] as Array<{ id: string; label: Record<string, string>; description: Record<string, string> }>,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export function requirePush() {
|