@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
|
@@ -35,7 +35,7 @@ function ago(ms: number, now: number) {
|
|
|
35
35
|
: d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live' };
|
|
38
|
+
const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by' };
|
|
39
39
|
|
|
40
40
|
export default function MobileApp() {
|
|
41
41
|
const [state, setState] = useState<AdminPushState | null>(null);
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -4,6 +4,7 @@ import { getRepo } from '../lib/repo';
|
|
|
4
4
|
import { BRAND } from '../lib/brand';
|
|
5
5
|
import { CONTENT } from '../lib/content';
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
|
+
import { ANNOUNCEMENTS } from '../lib/announcements/config';
|
|
7
8
|
import { OWNER_ENV } from '../lib/auth';
|
|
8
9
|
import { validateRequestSession } from '../lib/auth/request';
|
|
9
10
|
import { accessFor } from '../lib/permissions/access';
|
|
@@ -78,6 +79,7 @@ const icons: Record<string, string> = {
|
|
|
78
79
|
resources: 'M3 21V9l9-5 9 5v12M9 21v-6h6v6',
|
|
79
80
|
plug: 'M9 7V3M15 7V3M6 7h12v4a6 6 0 0 1-12 0zM12 17v4',
|
|
80
81
|
phone: 'M7 2.5h10a1.5 1.5 0 0 1 1.5 1.5v16a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 20V4A1.5 1.5 0 0 1 7 2.5zM10.5 18.5h3',
|
|
82
|
+
megaphone: 'M3.5 10v4a1 1 0 0 0 1 1H7l7 4.5v-15L7 9H4.5a1 1 0 0 0-1 1zM17.5 9a4 4 0 0 1 0 6M7 15l1.2 5h2.3L9.5 15.6',
|
|
81
83
|
};
|
|
82
84
|
|
|
83
85
|
// one sidebar entry per configured collection, in config order
|
|
@@ -137,6 +139,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
137
139
|
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
138
140
|
{ key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
|
|
139
141
|
// app access switches (+ push notifications when acms({ mobile: { push } }) is on)
|
|
142
|
+
// stored announcements: list, banner and push (acms({ announcements }))
|
|
143
|
+
...(ANNOUNCEMENTS.enabled && can('services.manage') ? [{ key: 'announcements', label: 'Announcements', href: '/admin/announcements', icon: icons.megaphone, count: null }] : []),
|
|
140
144
|
...(can('services.manage') ? [{ key: 'mobile', label: 'Mobile app', href: '/admin/mobile', icon: icons.phone, count: null }] : []),
|
|
141
145
|
],
|
|
142
146
|
},
|
package/src/layouts/Site.astro
CHANGED
|
@@ -7,6 +7,9 @@ import { siteLang } from '../lib/dates';
|
|
|
7
7
|
import { CONTENT } from '../lib/content';
|
|
8
8
|
import { I18N, localeFromPath, localeHomeUrl, localePath, requestLocale, stripLocale } from '../lib/i18n';
|
|
9
9
|
import { LEGAL } from '../lib/legal';
|
|
10
|
+
import { ANNOUNCEMENTS } from '../lib/announcements/config';
|
|
11
|
+
import { announcementPath, localizeAnnouncement } from '../lib/announcements/service';
|
|
12
|
+
import { announcementStrings } from '../lib/announcements/words';
|
|
10
13
|
import { SEARCH } from '../lib/search-config';
|
|
11
14
|
import { searchStrings } from '../lib/search-i18n';
|
|
12
15
|
import { effectiveSettings, effectiveNav, resolvePreview } from '../lib/designPreview';
|
|
@@ -127,6 +130,15 @@ const barePath = stripLocale(current);
|
|
|
127
130
|
const inLang = reqLocale && reqLocale !== I18N.defaultLocale ? reqLocale : '';
|
|
128
131
|
const lnk = (url: string) => (inLang && /^\/(?!\/|media\/|rss\.xml|sitemap|admin|api|_)/.test(url) ? localePath(url, inLang) : url);
|
|
129
132
|
const htmlLang = reqLocale || siteLang(settings);
|
|
133
|
+
// pinned announcements (Admin → Announcements) take the bar's place while
|
|
134
|
+
// they're up, urgent first; each one is closed on its own (remembered per id)
|
|
135
|
+
const annWords = announcementStrings(htmlLang);
|
|
136
|
+
const pinnedBars = !bare && ANNOUNCEMENTS.enabled ? (await repo.announcements.banners(Date.now(), 3)).map((a) => {
|
|
137
|
+
const w = localizeAnnouncement(a, inLang || null);
|
|
138
|
+
const target = a.link || announcementPath(a.id);
|
|
139
|
+
return { id: a.id, urgent: a.kind === 'urgent', kind: annWords.kinds[a.kind], title: w.title, label: a.link ? w.buttonLabel || annWords.more : '', href: target ? lnk(target) : '' };
|
|
140
|
+
}) : [];
|
|
141
|
+
const showSettingsBar = showAnnouncement && !pinnedBars.length;
|
|
130
142
|
// search modal chrome: strings follow the document language, the form's
|
|
131
143
|
// no-JS fallback goes to the (locale-prefixed) /search page
|
|
132
144
|
const showSearch = !bare && SEARCH.enabled;
|
|
@@ -253,7 +265,35 @@ const homeJsonLd =
|
|
|
253
265
|
<button type="button" class="pc-live-x" aria-label="Dismiss live notice">✕</button>
|
|
254
266
|
</div>
|
|
255
267
|
)}
|
|
256
|
-
{
|
|
268
|
+
{pinnedBars.map((b) => (
|
|
269
|
+
<div class="announcement-bar ann-pinned" data-ann-id={b.id} hidden>
|
|
270
|
+
{b.urgent && <strong class="ann-urgent">{b.kind}</strong>}
|
|
271
|
+
{b.href ? <a href={b.href}>{b.title}{b.label && <span class="ann-cta"> · {b.label} →</span>}</a> : <span>{b.title}</span>}
|
|
272
|
+
<button type="button" class="announcement-x" aria-label={annWords.dismiss}>✕</button>
|
|
273
|
+
</div>
|
|
274
|
+
))}
|
|
275
|
+
{pinnedBars.length > 0 && (
|
|
276
|
+
<script is:inline>
|
|
277
|
+
(() => {
|
|
278
|
+
// show the first pinned announcement this browser hasn't closed; closing it shows the next
|
|
279
|
+
const closed = (bar) => { try { return localStorage.getItem('acms-ann:' + bar.dataset.annId) === '1'; } catch { return false; } };
|
|
280
|
+
const next = () => {
|
|
281
|
+
for (const bar of document.querySelectorAll('.ann-pinned')) {
|
|
282
|
+
if (closed(bar)) { bar.remove(); continue; }
|
|
283
|
+
bar.hidden = false;
|
|
284
|
+
bar.querySelector('.announcement-x')?.addEventListener('click', () => {
|
|
285
|
+
try { localStorage.setItem('acms-ann:' + bar.dataset.annId, '1'); } catch { /* private mode */ }
|
|
286
|
+
bar.remove();
|
|
287
|
+
next();
|
|
288
|
+
}, { once: true });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
next();
|
|
293
|
+
})();
|
|
294
|
+
</script>
|
|
295
|
+
)}
|
|
296
|
+
{showSettingsBar && (
|
|
257
297
|
<div class="announcement-bar" data-ann-key={ann.dismissible ? annKey : undefined} hidden={ann.dismissible || undefined}>
|
|
258
298
|
{annLink ? <a href={annLink}>{ann.text}</a> : <span>{ann.text}</span>}
|
|
259
299
|
{ann.dismissible && (
|
|
@@ -261,7 +301,7 @@ const homeJsonLd =
|
|
|
261
301
|
)}
|
|
262
302
|
</div>
|
|
263
303
|
)}
|
|
264
|
-
{
|
|
304
|
+
{showSettingsBar && ann.dismissible && (
|
|
265
305
|
<script is:inline define:vars={{ annKey }}>
|
|
266
306
|
(() => {
|
|
267
307
|
const bar = document.querySelector('.announcement-bar');
|
|
@@ -5,6 +5,8 @@ import { CALENDAR } from './calendar/config';
|
|
|
5
5
|
import { EVENT_TOOL_DESCRIPTIONS, EVENT_TOOL_NAMES } from './calendar/agent-schema';
|
|
6
6
|
import { BOOKING_TOOL_DESCRIPTIONS, BOOKING_TOOL_NAMES, BOOKING_WRITE_TOOLS } from './booking/agent';
|
|
7
7
|
import { SECTION_TOOL_DESCRIPTIONS } from './sections-agent';
|
|
8
|
+
import { ANNOUNCEMENT_TOOL_DESCRIPTIONS, ANNOUNCEMENT_TOOL_NAMES } from './announcements/agent';
|
|
9
|
+
import { ANNOUNCEMENTS } from './announcements/config';
|
|
8
10
|
|
|
9
11
|
const descriptions: Record<string, string> = {
|
|
10
12
|
list_content: 'List posts or pages, including unpublished content. Supports pagination and title/slug search.',
|
|
@@ -15,8 +17,9 @@ const descriptions: Record<string, string> = {
|
|
|
15
17
|
...EVENT_TOOL_DESCRIPTIONS,
|
|
16
18
|
...BOOKING_TOOL_DESCRIPTIONS,
|
|
17
19
|
...SECTION_TOOL_DESCRIPTIONS,
|
|
20
|
+
...ANNOUNCEMENT_TOOL_DESCRIPTIONS,
|
|
18
21
|
};
|
|
19
|
-
const READ_ONLY = ['list_content', 'get_content', 'list_events', 'get_event', 'list_calendars', 'list_resources', 'check_availability', 'list_bookings', 'list_section_types', 'get_page_layout'];
|
|
22
|
+
const READ_ONLY = ['list_content', 'get_content', 'list_events', 'get_event', 'list_calendars', 'list_resources', 'check_availability', 'list_bookings', 'list_section_types', 'get_page_layout', 'list_announcements'];
|
|
20
23
|
const DESTRUCTIVE = ['update_content', 'publish_content', 'update_event', 'publish_event', 'update_booking', 'set_page_layout', 'update_section_data'];
|
|
21
24
|
const PUBLISH = ['publish_content', 'publish_event', ...BOOKING_WRITE_TOOLS];
|
|
22
25
|
const SECTION_WRITES = ['set_page_layout', 'update_section_data'];
|
|
@@ -28,7 +31,8 @@ export function agentTools(allowPublish: boolean, allowSections = false) {
|
|
|
28
31
|
.filter((tool) => allowPublish || !PUBLISH.includes(tool.name))
|
|
29
32
|
.filter((tool) => allowSections || !SECTION_WRITES.includes(tool.name))
|
|
30
33
|
.filter((tool) => CALENDAR.enabled || !(EVENT_TOOL_NAMES as readonly string[]).includes(tool.name))
|
|
31
|
-
.filter((tool) => (CALENDAR.enabled && CALENDAR.bookings) || !(BOOKING_TOOL_NAMES as readonly string[]).includes(tool.name))
|
|
34
|
+
.filter((tool) => (CALENDAR.enabled && CALENDAR.bookings) || !(BOOKING_TOOL_NAMES as readonly string[]).includes(tool.name))
|
|
35
|
+
.filter((tool) => ANNOUNCEMENTS.enabled || !(ANNOUNCEMENT_TOOL_NAMES as readonly string[]).includes(tool.name));
|
|
32
36
|
}
|
|
33
37
|
export function agentDiscovery(origin: string, allowPublish: boolean, allowSections = false) {
|
|
34
38
|
return {
|
package/src/lib/agent.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { EventToolInputs } from './calendar/agent-schema';
|
|
|
11
11
|
import { runEventTool, type AgentContext, type EventToolInput } from './calendar/agent';
|
|
12
12
|
import { BookingToolInputs, BOOKING_TOOL_NAMES, runBookingTool } from './booking/agent';
|
|
13
13
|
import { SectionToolInputs, SECTION_TOOL_NAMES, runSectionTool } from './sections-agent';
|
|
14
|
+
import { AnnouncementToolInputs, ANNOUNCEMENT_TOOL_NAMES, runAnnouncementTool } from './announcements/agent';
|
|
14
15
|
|
|
15
16
|
const Kind = z.enum(['posts', 'pages']);
|
|
16
17
|
const fields = {
|
|
@@ -35,6 +36,7 @@ export const AgentInput = z.discriminatedUnion('tool', [
|
|
|
35
36
|
...EventToolInputs,
|
|
36
37
|
...BookingToolInputs,
|
|
37
38
|
...SectionToolInputs,
|
|
39
|
+
...AnnouncementToolInputs,
|
|
38
40
|
]);
|
|
39
41
|
export function renderAgentMarkdown(markdown: string): string {
|
|
40
42
|
const html = sanitizeHtml(renderMarkdown(markdown), {
|
|
@@ -53,6 +55,9 @@ export async function runAgentTool(repo: ContentRepository, raw: unknown, allowP
|
|
|
53
55
|
if ((SECTION_TOOL_NAMES as readonly string[]).includes(parsed.tool)) {
|
|
54
56
|
return runSectionTool(repo, parsed as never, allowPublish);
|
|
55
57
|
}
|
|
58
|
+
if ((ANNOUNCEMENT_TOOL_NAMES as readonly string[]).includes(parsed.tool)) {
|
|
59
|
+
return runAnnouncementTool(repo, parsed as never);
|
|
60
|
+
}
|
|
56
61
|
if ((BOOKING_TOOL_NAMES as readonly string[]).includes(parsed.tool)) {
|
|
57
62
|
return runBookingTool(repo, parsed as never, allowPublish, ctx);
|
|
58
63
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Agent / MCP tools for announcements: read them, and draft one for a person
|
|
2
|
+
// to review. Agents never schedule or push: putting one up stays in the admin.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { ContentRepository } from '../repo/types';
|
|
5
|
+
import { cleanTranslations, requireAnnouncements } from './api';
|
|
6
|
+
import { AnnouncementKind, AnnouncementWords, announcementLink, type Announcement } from './types';
|
|
7
|
+
|
|
8
|
+
export const AnnouncementToolInputs = [
|
|
9
|
+
z.object({ tool: z.literal('list_announcements'), arguments: z.object({
|
|
10
|
+
status: z.enum(['draft', 'scheduled', 'published', 'ended', 'archived']).optional(),
|
|
11
|
+
limit: z.number().int().min(1).max(100).default(25),
|
|
12
|
+
}).strict() }).strict(),
|
|
13
|
+
z.object({ tool: z.literal('draft_announcement'), arguments: AnnouncementWords.extend({
|
|
14
|
+
kind: AnnouncementKind.default('info'),
|
|
15
|
+
title: z.string().trim().min(1).max(80),
|
|
16
|
+
link: announcementLink.optional(),
|
|
17
|
+
// words in another site language, e.g. { es: { title, message, details, buttonLabel } }
|
|
18
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i), AnnouncementWords).optional(),
|
|
19
|
+
}).strict() }).strict(),
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const ANNOUNCEMENT_TOOL_NAMES = ['list_announcements', 'draft_announcement'] as const;
|
|
23
|
+
export const ANNOUNCEMENT_TOOL_DESCRIPTIONS: Record<(typeof ANNOUNCEMENT_TOOL_NAMES)[number], string> = {
|
|
24
|
+
list_announcements: 'List announcements (newest first) with their status, kind, where they show and when they go up and come down.',
|
|
25
|
+
draft_announcement: 'Draft an announcement (kind: event, registration, help, info or urgent; title ≤ 80, message ≤ 240 = the push text, optional details, button label and link, translations per language). Saved as a draft for staff to review, schedule and push in Admin → Announcements. Never publishes or pushes.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Input = z.infer<(typeof AnnouncementToolInputs)[number]>;
|
|
29
|
+
const summary = (a: Announcement) => ({
|
|
30
|
+
id: a.id, status: a.status, kind: a.kind, title: a.title, message: a.message, listed: a.listed, push: a.push, pinned: a.pinned,
|
|
31
|
+
publishAt: a.publishAt && new Date(a.publishAt).toISOString(), showUntil: a.showUntil && new Date(a.showUntil).toISOString(),
|
|
32
|
+
publishedAt: a.publishedAt && new Date(a.publishedAt).toISOString(), languages: Object.keys(a.translations), editUrl: '/admin/announcements',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export async function runAnnouncementTool(repo: ContentRepository, input: Input) {
|
|
36
|
+
requireAnnouncements();
|
|
37
|
+
if (input.tool === 'list_announcements') {
|
|
38
|
+
const all = await repo.announcements.list();
|
|
39
|
+
return { items: all.filter((a) => !input.arguments.status || a.status === input.arguments.status).slice(0, input.arguments.limit).map(summary) };
|
|
40
|
+
}
|
|
41
|
+
const a = input.arguments;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const draft = await repo.announcements.create({
|
|
44
|
+
id: crypto.randomUUID(), kind: a.kind, title: a.title, message: a.message, details: a.details, buttonLabel: a.buttonLabel, link: a.link ?? '',
|
|
45
|
+
translations: cleanTranslations(a.translations ?? {}), listed: true, push: false, pinned: a.kind === 'urgent', audience: ['*'], status: 'draft',
|
|
46
|
+
publishAt: null, showUntil: null, publishedAt: null, notificationId: null, author: 'Agent', createdAt: now, updatedAt: now,
|
|
47
|
+
});
|
|
48
|
+
return { announcement: summary(draft), next: 'A person reviews it in Admin → Announcements, chooses push and timing, then schedules it.' };
|
|
49
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Public reads (/api/v1/announcements) and the admin state/actions behind
|
|
2
|
+
// Admin → Announcements (/api/announcements).
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { ContentRepository } from '../repo/types';
|
|
5
|
+
import { ApiError } from '../api';
|
|
6
|
+
import { I18N, localeName } from '../i18n';
|
|
7
|
+
import { siteOrigin } from '../seo';
|
|
8
|
+
import { pageQuery, queryParams } from '../mobile/http';
|
|
9
|
+
import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
|
|
10
|
+
import { PUSH } from '../push/config';
|
|
11
|
+
import { deviceLocale, knownTopics, pushTopics, siteOriginOf } from '../push/service';
|
|
12
|
+
import { linkTargets } from '../push/api';
|
|
13
|
+
import { ANNOUNCEMENTS } from './config';
|
|
14
|
+
import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
|
|
15
|
+
import { announcementStrings } from './words';
|
|
16
|
+
import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
|
|
17
|
+
|
|
18
|
+
const DAY = 86_400_000;
|
|
19
|
+
export function requireAnnouncements() {
|
|
20
|
+
if (!ANNOUNCEMENTS.enabled) throw new ApiError(404, 'Announcements are off on this site.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- public ------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export const announcementsQuery = z.object({
|
|
26
|
+
locale: localeQuery,
|
|
27
|
+
kind: AnnouncementKind.optional(),
|
|
28
|
+
/** true = only the banners (pinned), urgent first */
|
|
29
|
+
pinned: z.enum(['true']).optional(),
|
|
30
|
+
...pageQuery,
|
|
31
|
+
}).strict();
|
|
32
|
+
|
|
33
|
+
export async function listAnnouncements(repo: ContentRepository, url: URL) {
|
|
34
|
+
requireAnnouncements();
|
|
35
|
+
const q = announcementsQuery.parse(queryParams(url));
|
|
36
|
+
const locale = mobileLocale(q.locale);
|
|
37
|
+
const origin = siteOrigin(await repo.getSettings(), url);
|
|
38
|
+
const rows = await repo.announcements.live(Date.now(), { kind: q.kind, pinned: q.pinned === 'true', limit: q.limit + 1, offset: (q.page - 1) * q.limit });
|
|
39
|
+
return {
|
|
40
|
+
locale, announcements: rows.slice(0, q.limit).map((a) => announcementJson(a, locale, origin)),
|
|
41
|
+
pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getAnnouncement(repo: ContentRepository, url: URL, id: string) {
|
|
46
|
+
requireAnnouncements();
|
|
47
|
+
const locale = mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
48
|
+
const a = id && id.length <= 100 ? await repo.announcements.get(id) : null;
|
|
49
|
+
if (!a || !isLive(a, Date.now()) || !(a.listed || a.pinned)) throw new ApiError(404, 'Announcement not found.');
|
|
50
|
+
return { ...announcementJson(a, locale, siteOrigin(await repo.getSettings(), url)), locale };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- admin -------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const extraLanguages = () => (I18N.enabled ? I18N.locales.filter((l) => l !== I18N.defaultLocale) : []);
|
|
56
|
+
|
|
57
|
+
// a topic named after the kind, when the site has one
|
|
58
|
+
const CONVENTION: Record<Exclude<AnnouncementKind, 'urgent'>, string[]> = {
|
|
59
|
+
event: ['events'], registration: ['registrations', 'registration'], help: ['help'], info: ['info'],
|
|
60
|
+
};
|
|
61
|
+
/** The audience each kind starts with in the composer: acms({ announcements: { topics } }), a topic named after the kind, else everyone. */
|
|
62
|
+
export function defaultAudience(known: Set<string>): Record<AnnouncementKind, string[]> {
|
|
63
|
+
const pick = (kind: Exclude<AnnouncementKind, 'urgent'>) => {
|
|
64
|
+
const id = [ANNOUNCEMENTS.topics[kind], ...CONVENTION[kind]].find((t) => t && known.has(t));
|
|
65
|
+
return id ? [id] : ['*'];
|
|
66
|
+
};
|
|
67
|
+
return { event: pick('event'), registration: pick('registration'), help: pick('help'), info: pick('info'), urgent: ['*'] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function adminAnnouncementsState(repo: ContentRepository, url: URL) {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const settings = await repo.getSettings();
|
|
73
|
+
const [announcements, topics, known, stats, links, sentThisWeek] = await Promise.all([
|
|
74
|
+
repo.announcements.list(),
|
|
75
|
+
PUSH.enabled ? pushTopics(repo) : Promise.resolve([]),
|
|
76
|
+
PUSH.enabled ? knownTopics(repo) : Promise.resolve(new Set<string>()),
|
|
77
|
+
PUSH.enabled ? repo.push.stats(now) : Promise.resolve(null),
|
|
78
|
+
linkTargets(repo),
|
|
79
|
+
PUSH.enabled ? repo.push.countSent(['manual', 'announcement'], now - 7 * DAY) : Promise.resolve(0),
|
|
80
|
+
]);
|
|
81
|
+
const main = I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en');
|
|
82
|
+
return {
|
|
83
|
+
now,
|
|
84
|
+
site: { name: settings.siteTitle, icon: settings.siteFavicon, origin: siteOriginOf(settings, url.origin), timezone: settings.timezone || 'UTC' },
|
|
85
|
+
languages: { main: { code: main, name: localeName(main) }, extra: extraLanguages().map((code) => ({ code, name: localeName(code) })) },
|
|
86
|
+
kinds: ANNOUNCEMENT_KINDS.map((id) => ({ id, label: announcementStrings('en').kinds[id] })),
|
|
87
|
+
limits: LIMITS,
|
|
88
|
+
// the built-in pages ('' = acms({ announcements: { pages: false } }))
|
|
89
|
+
base: ANNOUNCEMENTS.pages ? ANNOUNCEMENTS.base : '',
|
|
90
|
+
// Settings → Announcement bar: a pinned announcement shows instead while it's up
|
|
91
|
+
bar: settings.announcement.enabled && settings.announcement.text.trim().length > 0,
|
|
92
|
+
push: {
|
|
93
|
+
enabled: PUSH.enabled, devices: stats?.devices ?? 0, topicCounts: stats?.topics ?? {},
|
|
94
|
+
topics: topics.map((t) => ({ id: t.id, label: t.label, kind: t.kind })),
|
|
95
|
+
// manual and announcement pushes in the last 7 days
|
|
96
|
+
sentThisWeek,
|
|
97
|
+
},
|
|
98
|
+
// the push audience each kind starts with in the composer (['*'] = everyone)
|
|
99
|
+
defaultAudience: defaultAudience(known),
|
|
100
|
+
links,
|
|
101
|
+
announcements,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export type AdminAnnouncementsState = Awaited<ReturnType<typeof adminAnnouncementsState>>;
|
|
105
|
+
|
|
106
|
+
const id = z.string().min(1).max(100);
|
|
107
|
+
const audience = z.array(z.string().min(1).max(200)).min(1, 'Choose who gets the push.').max(50);
|
|
108
|
+
export const AdminAnnouncementAction = z.discriminatedUnion('action', [
|
|
109
|
+
z.object({ action: z.literal('save'), id: id.optional(), intent: z.enum(['draft', 'schedule']), announcement: AnnouncementInput }).strict(),
|
|
110
|
+
z.object({ action: z.literal('count'), audience, kind: AnnouncementKind.optional() }).strict(),
|
|
111
|
+
z.object({ action: z.literal('archive'), id }).strict(),
|
|
112
|
+
z.object({ action: z.literal('restore'), id }).strict(),
|
|
113
|
+
z.object({ action: z.literal('delete'), id }).strict(),
|
|
114
|
+
]);
|
|
115
|
+
export type AdminAnnouncementAction = z.infer<typeof AdminAnnouncementAction>;
|
|
116
|
+
|
|
117
|
+
/** Phones in an audience, per site language (each phone's language mapped as pushes are). */
|
|
118
|
+
export async function countRecipients(repo: ContentRepository, list: string[]) {
|
|
119
|
+
const settings = await repo.getSettings();
|
|
120
|
+
const main = I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en');
|
|
121
|
+
const raw = await repo.push.audienceLocales(list);
|
|
122
|
+
const counts = new Map<string, number>([[main, 0], ...extraLanguages().map((l): [string, number] => [l, 0])]);
|
|
123
|
+
for (const [locale, n] of Object.entries(raw)) {
|
|
124
|
+
const code = deviceLocale(locale) ?? main;
|
|
125
|
+
counts.set(code, (counts.get(code) ?? 0) + n);
|
|
126
|
+
}
|
|
127
|
+
const languages = [...counts].map(([code, count]) => ({ code, name: localeName(code), count }));
|
|
128
|
+
return { total: languages.reduce((sum, l) => sum + l.count, 0), languages };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function checkAudience(repo: ContentRepository, list: string[]) {
|
|
132
|
+
if (list.includes('*')) return ['*'];
|
|
133
|
+
const known = await knownTopics(repo);
|
|
134
|
+
const bad = list.find((t) => !known.has(t));
|
|
135
|
+
if (bad) throw new ApiError(400, `Unknown topic: ${bad}.`);
|
|
136
|
+
return [...new Set(list)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const blank = (w: AnnouncementWords) => !w.title && !w.message && !w.details && !w.buttonLabel;
|
|
140
|
+
/** Only the site's extra languages, and only ones with words. */
|
|
141
|
+
export function cleanTranslations(input: Record<string, AnnouncementWords>) {
|
|
142
|
+
const allowed = new Set(extraLanguages());
|
|
143
|
+
return Object.fromEntries(Object.entries(input).filter(([code, w]) => allowed.has(code) && !blank(w)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What stops an announcement from going up (null = ready). */
|
|
147
|
+
export function scheduleProblem(a: Pick<Announcement, 'title' | 'message' | 'listed' | 'push' | 'pinned' | 'publishAt' | 'showUntil'>, now: number): string | null {
|
|
148
|
+
if (!a.title) return 'Add a title.';
|
|
149
|
+
if (a.push && !a.message) return 'Add the short message: it’s the text of the push.';
|
|
150
|
+
if (!a.listed && !a.push && !a.pinned) return 'Choose where it shows: the list, a push or the banner.';
|
|
151
|
+
if (a.showUntil !== null && a.showUntil <= Math.max(now, a.publishAt ?? 0)) return 'The end has to be after the start.';
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface AdminContext extends PublishOptions { url: URL; author: string }
|
|
156
|
+
|
|
157
|
+
export async function adminAnnouncementAction(repo: ContentRepository, input: AdminAnnouncementAction, ctx: AdminContext) {
|
|
158
|
+
requireAnnouncements();
|
|
159
|
+
const now = ctx.now ?? Date.now();
|
|
160
|
+
if (input.action === 'count') {
|
|
161
|
+
if (!PUSH.enabled) return { recipients: { total: 0, languages: [] } };
|
|
162
|
+
return { recipients: await countRecipients(repo, input.kind === 'urgent' ? ['*'] : await checkAudience(repo, input.audience)) };
|
|
163
|
+
}
|
|
164
|
+
if (input.action === 'save') return save(repo, input, ctx, now);
|
|
165
|
+
const a = await repo.announcements.get(input.id);
|
|
166
|
+
if (!a) throw new ApiError(404, 'Announcement not found.');
|
|
167
|
+
if (input.action === 'archive') {
|
|
168
|
+
if (a.status === 'archived') return { announcement: a };
|
|
169
|
+
// a scheduled one is cancelled: its push never goes out
|
|
170
|
+
return { announcement: await repo.announcements.update(a.id, { status: 'archived', updatedAt: now }) };
|
|
171
|
+
}
|
|
172
|
+
if (input.action === 'restore') {
|
|
173
|
+
if (a.status !== 'archived') return { announcement: a };
|
|
174
|
+
// back up without a new push (the push ledger already has it); never published = back to drafts
|
|
175
|
+
const status = a.publishedAt === null ? 'draft' : isLive({ ...a, status: 'published' }, now) ? 'published' : 'ended';
|
|
176
|
+
return { announcement: await repo.announcements.update(a.id, { status, updatedAt: now }) };
|
|
177
|
+
}
|
|
178
|
+
if (a.status === 'published' || a.status === 'scheduled') throw new ApiError(409, 'Take it down (archive) before deleting it.');
|
|
179
|
+
await repo.announcements.delete(a.id);
|
|
180
|
+
return { deleted: a.id };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function save(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'save' }>, ctx: AdminContext, now: number) {
|
|
184
|
+
const body = input.announcement;
|
|
185
|
+
const existing = input.id ? await repo.announcements.get(input.id) : null;
|
|
186
|
+
if (input.id && !existing) throw new ApiError(404, 'Announcement not found.');
|
|
187
|
+
const wentUp = existing !== null && existing.publishedAt !== null;
|
|
188
|
+
if (body.push && !PUSH.enabled && !wentUp) throw new ApiError(400, 'Push notifications are off on this site (acms({ mobile: { push } })).');
|
|
189
|
+
const fields = {
|
|
190
|
+
kind: body.kind, title: body.title, message: body.message, details: body.details, buttonLabel: body.buttonLabel, link: body.link,
|
|
191
|
+
translations: cleanTranslations(body.translations), listed: body.listed, pinned: body.pinned, showUntil: body.showUntil,
|
|
192
|
+
};
|
|
193
|
+
if (wentUp) {
|
|
194
|
+
// already went up: words, link, placement and the end can change; the push went out once and stays as it was
|
|
195
|
+
const next = { ...existing, ...fields, kind: existing.kind === 'urgent' || fields.kind === 'urgent' ? existing.kind : fields.kind };
|
|
196
|
+
const problem = scheduleProblem({ ...next, push: false, publishAt: null, showUntil: next.showUntil !== null && next.showUntil <= now ? null : next.showUntil }, now);
|
|
197
|
+
if (problem && problem !== 'Choose where it shows: the list, a push or the banner.') throw new ApiError(400, problem);
|
|
198
|
+
const status = existing.status === 'archived' ? 'archived' : isLive({ ...next, status: 'published' }, now) ? 'published' : 'ended';
|
|
199
|
+
return { announcement: await repo.announcements.update(existing.id, { ...fields, kind: next.kind, status, updatedAt: now }) };
|
|
200
|
+
}
|
|
201
|
+
const pushAudience = body.kind === 'urgent' ? ['*'] : body.push && PUSH.enabled ? await checkAudience(repo, body.audience) : body.audience.includes('*') ? ['*'] : [...new Set(body.audience)];
|
|
202
|
+
const draft: Announcement = {
|
|
203
|
+
id: existing?.id ?? crypto.randomUUID(), ...fields, push: body.push, audience: pushAudience,
|
|
204
|
+
status: input.intent === 'draft' ? 'draft' : 'scheduled',
|
|
205
|
+
// "now" = whenever it's scheduled
|
|
206
|
+
publishAt: body.publishAt !== null && body.publishAt > now ? body.publishAt : null,
|
|
207
|
+
publishedAt: null, notificationId: null, author: existing?.author || ctx.author,
|
|
208
|
+
createdAt: existing?.createdAt ?? now, updatedAt: now,
|
|
209
|
+
};
|
|
210
|
+
if (existing?.status === 'archived') draft.status = 'archived';
|
|
211
|
+
if (draft.status === 'scheduled') {
|
|
212
|
+
const problem = scheduleProblem(draft, now);
|
|
213
|
+
if (problem) throw new ApiError(400, problem);
|
|
214
|
+
}
|
|
215
|
+
const saved = existing ? await repo.announcements.update(existing.id, draft) : await repo.announcements.create(draft);
|
|
216
|
+
if (!saved) throw new ApiError(409, 'It changed while you were editing. Reload and try again.');
|
|
217
|
+
if (saved.status === 'scheduled' && saved.publishAt === null) {
|
|
218
|
+
const result = await publishAnnouncement(repo, saved, { ...ctx, now, origin: ctx.origin ?? siteOriginOf(await repo.getSettings(), ctx.url.origin) });
|
|
219
|
+
if (result) return result;
|
|
220
|
+
}
|
|
221
|
+
return { announcement: saved };
|
|
222
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// acms({ announcements }) config (integration.mjs resolves it): off unless a
|
|
4
|
+
// site turns it on. `pages: false` keeps the API and the admin but drops the
|
|
5
|
+
// built-in /announcements pages, so a site can build its own. `topics` maps a
|
|
6
|
+
// kind to the push topic it starts with in the composer.
|
|
7
|
+
export const ANNOUNCEMENTS: { enabled: boolean; base: string; pages: boolean; topics: Partial<Record<'event' | 'registration' | 'help' | 'info', string>> } =
|
|
8
|
+
{ enabled: false, base: '/announcements', pages: true, topics: {}, ...userConfig.announcements };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// v58: announcements (Admin → Announcements), and the Android channels an app
|
|
2
|
+
// created (push_devices.channels), so urgent announcements use a
|
|
3
|
+
// high-importance channel only on phones that have one: Android drops a push
|
|
4
|
+
// sent to a channel the app never created.
|
|
5
|
+
export const ANNOUNCEMENT_TABLE = [
|
|
6
|
+
`CREATE TABLE announcements (
|
|
7
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL DEFAULT 'info',
|
|
8
|
+
title TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '', details TEXT NOT NULL DEFAULT '',
|
|
9
|
+
button_label TEXT NOT NULL DEFAULT '', link TEXT NOT NULL DEFAULT '', translations TEXT NOT NULL DEFAULT '{}',
|
|
10
|
+
listed INTEGER NOT NULL DEFAULT 1, push INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
audience TEXT NOT NULL DEFAULT '["*"]', status TEXT NOT NULL DEFAULT 'draft',
|
|
12
|
+
publish_at INTEGER, show_until INTEGER, published_at INTEGER, notification_id TEXT,
|
|
13
|
+
author TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
14
|
+
)`,
|
|
15
|
+
`CREATE INDEX announcements_status ON announcements (status, published_at)`,
|
|
16
|
+
`ALTER TABLE push_devices ADD COLUMN channels TEXT NOT NULL DEFAULT '[]'`,
|
|
17
|
+
];
|
|
18
|
+
// what visitors see: the words, the kind, where it shows and when it's up.
|
|
19
|
+
// Left out: the push choices, the author and bookkeeping.
|
|
20
|
+
export const ANNOUNCEMENT_VISIBLE_COLUMNS = ['id', 'kind', 'title', 'message', 'details', 'button_label', 'link', 'translations',
|
|
21
|
+
'listed', 'pinned', 'status', 'show_until', 'published_at'];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What the built-in /announcements pages show: each announcement in the
|
|
2
|
+
// visitor's language with its links, grouped Today / This week / Earlier.
|
|
3
|
+
import { I18N, localePath } from '../i18n';
|
|
4
|
+
import { announcementPath, localizeAnnouncement, paragraphs } from './service';
|
|
5
|
+
import { announcementStrings } from './words';
|
|
6
|
+
import type { Announcement } from './types';
|
|
7
|
+
|
|
8
|
+
const DAY = 86_400_000;
|
|
9
|
+
// "2026-10-19" in a timezone (Intl falls back to UTC for an unknown zone)
|
|
10
|
+
function dayIn(ms: number, timeZone: string) {
|
|
11
|
+
try { return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms); }
|
|
12
|
+
catch { return new Date(ms).toISOString().slice(0, 10); }
|
|
13
|
+
}
|
|
14
|
+
function dateLabel(ms: number, lang: string, timeZone: string, withYear: boolean) {
|
|
15
|
+
const opts: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) };
|
|
16
|
+
try { return new Intl.DateTimeFormat(lang, { ...opts, timeZone }).format(ms); }
|
|
17
|
+
catch { return new Date(ms).toISOString().slice(0, 10); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One announcement as the website shows it. `locale` = the request's language ('' or the main one = main words). */
|
|
21
|
+
export function announcementView(a: Announcement, locale: string, lang: string, timeZone: string, now: number) {
|
|
22
|
+
const extra = locale && locale !== I18N.defaultLocale ? locale : null;
|
|
23
|
+
const w = localizeAnnouncement(a, extra);
|
|
24
|
+
const t = announcementStrings(lang);
|
|
25
|
+
const at = a.publishedAt ?? a.updatedAt;
|
|
26
|
+
const local = (href: string) => (href.startsWith('/') ? localePath(href, locale) : href);
|
|
27
|
+
return {
|
|
28
|
+
id: a.id, kind: a.kind, kindLabel: t.kinds[a.kind], urgent: a.kind === 'urgent', pinned: a.pinned,
|
|
29
|
+
title: w.title, message: w.message, details: paragraphs(w.details),
|
|
30
|
+
button: a.link ? { label: w.buttonLabel || t.more, href: local(a.link), external: !a.link.startsWith('/') } : null,
|
|
31
|
+
href: local(announcementPath(a.id)),
|
|
32
|
+
date: dateLabel(at, lang, timeZone, new Date(at).getUTCFullYear() !== new Date(now).getUTCFullYear()),
|
|
33
|
+
iso: new Date(at).toISOString(),
|
|
34
|
+
until: a.showUntil ? t.until(dateLabel(a.showUntil, lang, timeZone, false)) : '',
|
|
35
|
+
publishedAt: at,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export type AnnouncementView = ReturnType<typeof announcementView>;
|
|
39
|
+
|
|
40
|
+
/** Today, this week (the last 7 days), earlier; empty groups left out. */
|
|
41
|
+
export function groupByWhen(list: AnnouncementView[], lang: string, timeZone: string, now: number) {
|
|
42
|
+
const t = announcementStrings(lang);
|
|
43
|
+
const today = dayIn(now, timeZone);
|
|
44
|
+
const groups = [
|
|
45
|
+
{ key: 'today', label: t.today, items: [] as AnnouncementView[] },
|
|
46
|
+
{ key: 'week', label: t.thisWeek, items: [] as AnnouncementView[] },
|
|
47
|
+
{ key: 'earlier', label: t.earlier, items: [] as AnnouncementView[] },
|
|
48
|
+
];
|
|
49
|
+
for (const v of list) {
|
|
50
|
+
const i = dayIn(v.publishedAt, timeZone) === today ? 0 : now - v.publishedAt < 7 * DAY ? 1 : 2;
|
|
51
|
+
groups[i].items.push(v);
|
|
52
|
+
}
|
|
53
|
+
return groups.filter((g) => g.items.length);
|
|
54
|
+
}
|