1
0
Fork 0

feat(announcements): add announcements system with roleDocs targeting and richText

- announcements collection: info/success/warning/error variants, banner/modal/dialog/toast display types, up to 3 action buttons, dismissible flag, activeFrom/activeUntil windows, audience targeting (all/users/roles) and path include/exclude matching copied from the Shims pattern
- pure client-safe evaluation lib (enabled, time window, audience via roleDoc IDs, path match) plus per-browser localStorage dismissals
- AnnouncementHost mounted in the (frontend) layout: banners render inline, toasts via sonner custom markup, modals and confirmation dialogs queue one at a time
- permissions announcements:create/read/update/delete + admin:announcements:manage
- description is Lexical richText rendered via RichText/lexical-content on all surfaces; role targeting uses the dynamic Roles collection
- hand-written migrations with snapshot sidecars (collection, roleDocs targeting, description richtext conversion) + integration tests (12 passing)
This commit is contained in:
Jason Fraley 2026-09-22 21:08:46 -04:00
parent 766aabeae3
commit 8c95c1ee82
19 changed files with 98627 additions and 2 deletions

View file

@ -114,7 +114,7 @@ Organized by domain under `src/collections/`:
- **projects/** — Projects, Labels, Releases, Sprints
- **tickets/** — Tickets, TicketVotes
- **wiki/** — WikiPages, WikiRevisions, WikiTemplates
- top-level — `Media.ts` (image uploads, used by wiki), `Shims.ts` (audience-targeted content)
- top-level — `Media.ts` (image uploads, used by wiki), `Shims.ts` (audience-targeted content), `Announcements.ts` (banner/modal/dialog/toast announcements)
Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). Full RBAC lives in `src/permissions/index.ts` + the `Roles` collection. Roles: `guest`, `user`, `admin`, `developer`.
@ -276,6 +276,16 @@ Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full
- **UI**: `NotificationsBell` (`src/components/frontend/notifications/NotificationsBell.tsx`) mounted in `SiteHeader`. Polls `/api/user-notifications?limit=12&sort=-createdAt` (cookie auth) every 30s, shows an unread-count badge, dropdown with unread highlight + relative time, click-to-open-link, per-row mark-as-read check button on unread rows (`stopPropagation` — marks read without navigating, dropdown stays open), "Mark all read". Mark-read server actions live in `src/app/(frontend)/notifications/actions.ts` (`markNotificationsRead`, `markAllNotificationsRead`).
- **Wiring**: negotiation flows in `market/actions.ts` notify the counterparty at every step; `expireNegotiationsForListing` notifies interested buyers when a listing sells/cancels/expires.
## Announcements
`src/collections/Announcements.ts` — `announcements` collection (admin group: **Announcements**). Admin-authored user-facing announcements rendered in the frontend as a `banner` (strip above the content, like ImpersonationBanner), `modal` (shadcn Dialog), `dialog` (confirmation dialog, shadcn AlertDialog), or `toast` (sonner). Variants: `info`/`success`/`warning`/`error` (blue/emerald/amber/red). Fields: `title` (required), `subtitle`, `description`, up to 3 `buttons` (`{ label, href, style }`, used by dialogs/modals and inline on banners), `enabled`, `dismissible`, `activeFrom`/`activeUntil` date window, plus `targeting` (all/specific users/roles) and `paths` (include/exclude prefixes) groups copied from Shims.
- **Evaluation is client-side** (Shims pattern): the `(frontend)` layout fetches enabled announcements (local API) and hands them to `AnnouncementHost` (`src/components/frontend/announcements/`); pure helpers in `src/lib/announcements/evaluate.ts` (`isAnnouncementActive` = enabled → time window → audience → path) decide per user/path/moment. The layout resolves the user's `roleDocs` to IDs (`userRoleDocIds`) for role targeting (the `roles` targeting field is a relationship to the dynamic Roles collection, NOT the legacy role enum). `description` is Lexical richText rendered via `RichText` (`@payloadcms/richtext-lexical/react`) wrapped in `lexical-content` on all display surfaces (legacy plain-string fallback kept). No server actions; authoring happens in the Payload admin panel.
- **Access**: create/update/delete via `announcements:create|update|delete` permissions (admin group in `src/permissions/index.ts`); read via `announcements:read`. Admin panel visibility needs `announcements:read` + `admin:announcements:manage` (central `scopedAdminPageAccess`).
- **Dismissals**: per-browser localStorage (`ptf:announcement:dismissals`, id → expiry) via `src/lib/announcements/dismissals.ts` (mirrors mission-reminder dismissals); expiry defaults to the announcement's `activeUntil` or 30 days. Non-dismissible announcements reappear on every page load while active. Overlays (modal/dialog) queue one at a time per session.
- Tests: `tests/int/announcements.int.spec.ts` (evaluate + dismissal logic, access gating, local-API roundtrip).
- Migration: `20260922_230950_add_announcements_collection` + `20260923_001209_add_announcements_role_docs_targeting` + `20260923_002422_announcements_description_richtext` (hand-written, additive; snapshot sidecars included so future drizzle diffs exclude announcements).
## Narrative Events System
`src/collections/world/NarrativeEvents.ts` — `narrative-events` collection. A flowchart-based narrative event editor using `@xyflow/react` (React Flow) in the Payload admin panel.

View file

@ -29,6 +29,8 @@ import { cookies } from "next/headers";
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
import { AnnouncementHost } from "@/components/frontend/announcements/AnnouncementHost";
import type { AnnouncementEntry } from "@/lib/announcements/evaluate";
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
// Skip static prerendering of this layout (and all pages under (frontend)/).
@ -79,6 +81,9 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
let announcementEntries: AnnouncementEntry[] = [];
let userRoleDocIds: number[] = [];
let isAdmin = false;
let statisticsEnabled = true;
let helpdeskEnabled = true;
@ -91,6 +96,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
experienceRes,
personalAccountRes,
gameRulesRes,
announcementsRes,
] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false),
@ -125,6 +131,12 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
limit: 1,
}),
payload.findGlobal({ slug: "game-rules" }),
payload.find({
collection: "announcements",
where: { enabled: { equals: true } },
sort: "-createdAt",
limit: 50,
}),
]);
isIntelligence = intelligenceQual;
isLogistics = logisticsQual;
@ -138,6 +150,10 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
isAdmin = await canAccessAdminPanel(payload, user);
announcementEntries = (announcementsRes.docs ?? []) as unknown as AnnouncementEntry[];
userRoleDocIds = (user.roleDocs ?? []).map((roleDoc) =>
typeof roleDoc === "object" && roleDoc !== null ? roleDoc.id : Number(roleDoc),
);
}
return (
@ -177,6 +193,13 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
>
<SiteHeader />
{impersonating && <ImpersonationBanner />}
{user && (
<AnnouncementHost
announcements={announcementEntries}
userId={user.id}
userRoleDocIds={userRoleDocIds}
/>
)}
{children}
</SidebarInset>
<CommandPalette

View file

@ -4,7 +4,7 @@
## Overview
54 files (~53 collections) across 15 domain groups. Access control defined in `src/permissions/index.ts` (~870 lines, permission registry across groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
55 files (~54 collections) across 15 domain groups. Access control defined in `src/permissions/index.ts` (~880 lines, permission registry across groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
## Structure
@ -12,6 +12,7 @@
collections/
Media.ts # Image uploads (public read; used by wiki image upload)
Shims.ts # Global: audience-targeted content (admin-only)
Announcements.ts # Banner/modal/dialog/toast announcements (admin-authored, client-evaluated)
users/ 11 files # Users (auth), Ranks, Profiles (incl. minigame stats), Awards,
# Qualifications, Assignments, AssignmentTransfers, Experience,

View file

@ -0,0 +1,225 @@
import { CollectionConfig } from "payload";
import { requirePermission } from "@/utils/access-control/hasPermission";
/**
* Announcements: admin-authored notifications rendered in the frontend app
* as banners, modals, confirmation dialogs, or toasts. Evaluation (time
* window, audience, path matching) happens client-side via the pure helpers
* in `src/lib/announcements/evaluate.ts`, mirroring the Shims pattern.
*/
export const Announcements: CollectionConfig = {
slug: "announcements",
labels: {
singular: "Announcement",
plural: "Announcements",
},
admin: {
group: "Announcements",
},
access: {
create: requirePermission("announcements:create"),
read: requirePermission("announcements:read"),
update: requirePermission("announcements:update"),
delete: requirePermission("announcements:delete"),
},
defaultSort: "-createdAt",
fields: [
{
name: "title",
type: "text",
required: true,
admin: {
description: "Headline shown in every display type.",
},
},
{
name: "subtitle",
type: "text",
admin: {
description: "Optional secondary line shown under the title.",
},
},
{
name: "description",
type: "richText",
admin: {
description: "Optional longer body text (rendered with formatting on the frontend).",
},
},
{
name: "variant",
type: "select",
required: true,
defaultValue: "info",
options: [
{ label: "Info", value: "info" },
{ label: "Success", value: "success" },
{ label: "Warning", value: "warning" },
{ label: "Error", value: "error" },
],
admin: {
description: "Colors the announcement (and the toast style).",
},
},
{
name: "displayType",
type: "select",
required: true,
defaultValue: "banner",
options: [
{ label: "Banner", value: "banner" },
{ label: "Modal", value: "modal" },
{ label: "Confirmation Dialog", value: "dialog" },
{ label: "Toast", value: "toast" },
],
admin: {
description:
"How the announcement is presented. Dialogs show their configured buttons; modals get a default OK button when none are set.",
},
},
{
name: "enabled",
type: "checkbox",
defaultValue: true,
admin: {
description: "Disabled announcements are never shown, regardless of targeting rules.",
},
},
{
name: "dismissible",
type: "checkbox",
defaultValue: true,
admin: {
description:
"Whether the user can dismiss it for this browser until its window ends. Non-dismissible announcements reappear on every page load while active.",
},
},
{
name: "activeFrom",
type: "date",
admin: {
date: { pickerAppearance: "dayAndTime" },
description: "Optional. The announcement is not shown before this moment.",
},
},
{
name: "activeUntil",
type: "date",
admin: {
date: { pickerAppearance: "dayAndTime" },
description: "Optional. The announcement stops being shown after this moment.",
},
},
{
name: "buttons",
type: "array",
label: "Buttons",
maxRows: 3,
admin: {
description:
"Optional action buttons (used by confirmation dialogs and modals; banners and toasts show them inline where supported).",
},
fields: [
{
name: "label",
type: "text",
required: true,
},
{
name: "href",
type: "text",
admin: {
description:
"Optional internal path to navigate to when clicked (e.g. '/logistics/shipments'). Leave empty for a close-only button.",
},
},
{
name: "style",
type: "select",
defaultValue: "default",
options: [
{ label: "Default", value: "default" },
{ label: "Outline", value: "outline" },
{ label: "Destructive", value: "destructive" },
],
},
],
},
{
name: "targeting",
type: "group",
label: "Targeting",
admin: {
description: "Who should this announcement be shown to?",
},
fields: [
{
name: "audience",
type: "select",
required: true,
defaultValue: "all",
options: [
{ label: "All Users", value: "all" },
{ label: "Specific Users", value: "users" },
{ label: "By Role", value: "roles" },
],
},
{
name: "users",
type: "relationship",
relationTo: "users",
hasMany: true,
admin: {
condition: (data, siblingData) =>
(siblingData?.audience ?? data?.targeting?.audience) === "users",
description: "Only these specific users will see this announcement.",
},
},
{
name: "roles",
type: "relationship",
relationTo: "roles",
hasMany: true,
admin: {
condition: (data, siblingData) =>
(siblingData?.audience ?? data?.targeting?.audience) === "roles",
description:
"Users holding any of these roles (the dynamic Roles collection) will see this announcement.",
},
},
],
},
{
name: "paths",
type: "group",
label: "Path Matching",
admin: {
description: "On which pages should this announcement be shown?",
},
fields: [
{
name: "matchType",
type: "select",
required: true,
defaultValue: "all",
options: [
{ label: "All Pages", value: "all" },
{ label: "Include Only", value: "include" },
{ label: "Exclude", value: "exclude" },
],
},
{
name: "paths",
type: "text",
hasMany: true,
admin: {
condition: (data, siblingData) =>
(siblingData?.matchType ?? data?.paths?.matchType) !== "all",
description:
"URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.",
},
},
],
},
],
};

View file

@ -0,0 +1,312 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { toast } from "sonner";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { cn } from "@/lib/utils";
import {
isAnnouncementActive,
type AnnouncementButtonStyle,
type AnnouncementEntry,
type AnnouncementVariant,
} from "@/lib/announcements/evaluate";
import {
readAnnouncementDismissals,
storeAnnouncementDismissal,
type AnnouncementDismissals,
} from "@/lib/announcements/dismissals";
interface AnnouncementHostProps {
announcements: AnnouncementEntry[];
userId: number | null;
userRoleDocIds: number[];
}
const VARIANT_CLASSES: Record<AnnouncementVariant, string> = {
info: "border-blue-500/40 bg-blue-500/10 text-blue-100",
success: "border-emerald-500/40 bg-emerald-500/10 text-emerald-100",
warning: "border-amber-500/40 bg-amber-500/10 text-amber-100",
error: "border-red-500/40 bg-red-500/10 text-red-100",
};
const OVERLAY_CLASSES: Record<AnnouncementVariant, string> = {
info: "border-blue-500/40 bg-blue-950/50 text-blue-100",
success: "border-emerald-500/40 bg-emerald-950/50 text-emerald-100",
warning: "border-amber-500/40 bg-amber-950/50 text-amber-100",
error: "border-red-500/40 bg-red-950/50 text-red-100",
};
const VARIANT_ICONS: Record<AnnouncementVariant, typeof Info> = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
};
const BUTTON_VARIANTS: Record<AnnouncementButtonStyle, "default" | "outline" | "destructive"> = {
default: "default",
outline: "outline",
destructive: "destructive",
};
type LexicalData = Parameters<typeof RichText>[0]["data"];
function AnnouncementRichText({ description }: { description: unknown }) {
if (typeof description === "string") {
return <p className="mt-2 font-normal text-sm opacity-90">{description}</p>;
}
if (!description || typeof description !== "object") return null;
return (
<div className="lexical-content mt-2">
<RichText data={description as LexicalData} />
</div>
);
}
export function AnnouncementHost({ announcements, userId, userRoleDocIds }: AnnouncementHostProps) {
const pathname = usePathname();
const router = useRouter();
const [mounted, setMounted] = useState(false);
const [dismissed, setDismissed] = useState<AnnouncementDismissals>({});
const [closedOverlayIds, setClosedOverlayIds] = useState<Set<number>>(new Set());
const [overlayOpen, setOverlayOpen] = useState(true);
const firedToastIdsRef = useRef<Set<number>>(new Set());
useEffect(() => {
setDismissed(readAnnouncementDismissals(Date.now()));
setMounted(true);
}, []);
const active = useMemo(() => {
if (!mounted) return [];
const now = Date.now();
return announcements.filter(
(announcement) =>
isAnnouncementActive(announcement, userId, userRoleDocIds, pathname, now) &&
!dismissed[String(announcement.id)],
);
}, [mounted, announcements, userId, userRoleDocIds, pathname, dismissed]);
const { banners, overlays, toasts } = useMemo(
() => ({
banners: active.filter((a) => a.displayType === "banner"),
overlays: active.filter((a) => a.displayType === "modal" || a.displayType === "dialog"),
toasts: active.filter((a) => a.displayType === "toast"),
}),
[active],
);
const currentOverlay = overlays.find((a) => !closedOverlayIds.has(a.id)) ?? null;
useEffect(() => {
setOverlayOpen(true);
}, [currentOverlay?.id]);
const dismiss = useCallback((announcement: AnnouncementEntry) => {
if (!announcement.dismissible) return;
storeAnnouncementDismissal(announcement.id, announcement.activeUntil, Date.now());
setDismissed((prev) => ({ ...prev, [String(announcement.id)]: Date.now() }));
}, []);
const dismissAndCloseOverlay = useCallback(
(announcement: AnnouncementEntry) => {
setClosedOverlayIds((prev) => new Set(prev).add(announcement.id));
setOverlayOpen(false);
dismiss(announcement);
},
[dismiss],
);
useEffect(() => {
for (const announcement of toasts) {
if (firedToastIdsRef.current.has(announcement.id)) continue;
firedToastIdsRef.current.add(announcement.id);
const Icon = VARIANT_ICONS[announcement.variant] ?? Info;
// toast.custom bypasses the shared Toaster's toastOptions, which force
// bg-background/text-foreground and flatten sonner's variant colors.
toast.custom(
(t) => (
<div
className={cn(
"flex items-start gap-3 rounded-lg border px-4 py-3 text-sm shadow-lg",
OVERLAY_CLASSES[announcement.variant],
)}
>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div className="min-w-0 flex-1">
<span className="text-base font-bold">{announcement.title}</span>
{announcement.subtitle && (
<p className="text-xs opacity-80">{announcement.subtitle}</p>
)}
<AnnouncementRichText description={announcement.description} />
</div>
{announcement.dismissible && (
<button
type="button"
onClick={() => toast.dismiss(t)}
aria-label="Dismiss announcement"
className="rounded p-1 opacity-70 transition-opacity hover:opacity-100"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
),
{ duration: announcement.dismissible ? 10_000 : 15_000 },
);
}
}, [toasts]);
function handleButtonClick(announcement: AnnouncementEntry, href?: string | null) {
dismissAndCloseOverlay(announcement);
if (href) router.push(href);
}
function renderButtons(announcement: AnnouncementEntry, size: "sm" | "default") {
const buttons = announcement.buttons ?? [];
const rendered = buttons.map((button, index) => (
<Button
key={`${index}-${button.label}`}
variant={BUTTON_VARIANTS[button.style] ?? "default"}
size={size}
onClick={() => handleButtonClick(announcement, button.href)}
>
{button.label}
</Button>
));
if (rendered.length === 0) {
rendered.push(
<Button key="ok" size={size} onClick={() => dismissAndCloseOverlay(announcement)}>
OK
</Button>,
);
}
return rendered;
}
function renderBody(announcement: AnnouncementEntry, Header: typeof DialogHeader, Title: typeof DialogTitle) {
const Icon = VARIANT_ICONS[announcement.variant] ?? Info;
return (
<>
<Header>
<div className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-5 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<Title className="text-left text-xl font-bold">{announcement.title}</Title>
{announcement.subtitle && (
<p className="text-xs text-muted-foreground">{announcement.subtitle}</p>
)}
</div>
</div>
</Header>
<AnnouncementRichText description={announcement.description} />
</>
);
}
if (!mounted) return null;
return (
<>
{banners.map((announcement) => {
const Icon = VARIANT_ICONS[announcement.variant] ?? Info;
const buttons = announcement.buttons ?? [];
return (
<div
key={announcement.id}
role={announcement.variant === "error" ? "alert" : "status"}
className={cn(
"flex items-start gap-3 border-b px-4 py-2.5 text-sm",
VARIANT_CLASSES[announcement.variant],
)}
>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<div className="min-w-0 flex-1">
<span className="text-base font-bold">{announcement.title}</span>
{announcement.subtitle && (
<p className="text-xs opacity-80">{announcement.subtitle}</p>
)}
<AnnouncementRichText description={announcement.description} />
{buttons.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{buttons.map((button, index) => (
<Button
key={`${index}-${button.label}`}
variant={BUTTON_VARIANTS[button.style] ?? "default"}
size="sm"
onClick={() => handleButtonClick(announcement, button.href)}
>
{button.label}
</Button>
))}
</div>
)}
</div>
{announcement.dismissible && (
<button
type="button"
onClick={() => dismiss(announcement)}
aria-label="Dismiss announcement"
className="rounded p-1 opacity-70 transition-opacity hover:opacity-100"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
);
})}
{currentOverlay && currentOverlay.displayType === "modal" && (
<Dialog
open={overlayOpen}
onOpenChange={(open) => {
if (!open && currentOverlay) dismissAndCloseOverlay(currentOverlay);
}}
>
<DialogContent
className={cn("border", OVERLAY_CLASSES[currentOverlay.variant])}
aria-describedby={undefined}
onOpenAutoFocus={(event) => event.preventDefault()}
>
{renderBody(currentOverlay, DialogHeader, DialogTitle)}
<DialogFooter>{renderButtons(currentOverlay, "default")}</DialogFooter>
</DialogContent>
</Dialog>
)}
{currentOverlay && currentOverlay.displayType === "dialog" && (
<AlertDialog
open={overlayOpen}
onOpenChange={(open) => {
if (!open && currentOverlay) dismissAndCloseOverlay(currentOverlay);
}}
>
<AlertDialogContent
className={cn("border", OVERLAY_CLASSES[currentOverlay.variant])}
aria-describedby={undefined}
>
{renderBody(currentOverlay, AlertDialogHeader, AlertDialogTitle)}
<AlertDialogFooter>{renderButtons(currentOverlay, "default")}</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
}

View file

@ -0,0 +1,70 @@
/**
* Per-browser announcement dismissals in localStorage, keyed by announcement
* id with expiry timestamps. Mirrors the mission-reminder dismissal store:
* browser storage is read during mount and written during mount or the
* explicit dismissal handler; it is never accessed during render.
*/
const DISMISSALS_STORAGE_KEY = "ptf:announcement:dismissals";
export type AnnouncementDismissals = Record<string, number>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function readAnnouncementDismissals(now: number): AnnouncementDismissals {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(DISMISSALS_STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const dismissals: AnnouncementDismissals = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value) && value > now) {
dismissals[key] = value;
}
}
return dismissals;
} catch (error) {
if (error instanceof Error) return {};
throw error;
}
}
export function writeAnnouncementDismissals(
dismissals: AnnouncementDismissals,
now: number,
): void {
if (typeof window === "undefined") return;
const activeDismissals: AnnouncementDismissals = {};
for (const [key, expiresAt] of Object.entries(dismissals)) {
if (expiresAt > now) activeDismissals[key] = expiresAt;
}
try {
window.localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(activeDismissals));
} catch (error) {
if (error instanceof Error) return;
throw error;
}
}
/**
* Merges a single dismissal into the stored map. Until the announcement's
* `activeUntil` when set, otherwise 30 days from `now`.
*/
export function storeAnnouncementDismissal(
announcementId: number,
activeUntil: string | null | undefined,
now: number,
): void {
const until = activeUntil ? Date.parse(activeUntil) : NaN;
const expiresAt = Number.isFinite(until) && until > now ? until : now + 30 * 24 * 60 * 60 * 1000;
const existing = readAnnouncementDismissals(now);
writeAnnouncementDismissals({ ...existing, [String(announcementId)]: expiresAt }, now);
}

View file

@ -0,0 +1,137 @@
/**
* Announcements evaluation: pure, side-effect-free functions that decide
* whether an announcement should be shown to a user on a path at a moment.
* Client-safe (no server dependencies), mirroring `src/lib/shims/evaluate.ts`.
*/
export type AnnouncementVariant = "info" | "success" | "warning" | "error";
export type AnnouncementDisplayType = "banner" | "modal" | "dialog" | "toast";
export type AnnouncementAudience = "all" | "users" | "roles";
export type AnnouncementPathMatch = "all" | "include" | "exclude";
export interface AnnouncementTargeting {
audience: AnnouncementAudience;
/** User IDs, used when audience = "users". */
users?: Array<number | { id: number }> | null;
/** Role doc IDs (roles collection), used when audience = "roles". */
roles?: Array<number | { id: number }> | null;
}
export interface AnnouncementPathConfig {
matchType: AnnouncementPathMatch;
paths?: string[] | null;
}
export type AnnouncementButtonStyle = "default" | "outline" | "destructive";
export interface AnnouncementButton {
label: string;
href?: string | null;
style: AnnouncementButtonStyle;
}
export interface AnnouncementEntry {
id: number;
title: string;
subtitle?: string | null;
/** Lexical rich-text JSON (or a legacy plain string). */
description?: unknown;
variant: AnnouncementVariant;
displayType: AnnouncementDisplayType;
enabled?: boolean | null;
dismissible?: boolean | null;
activeFrom?: string | null;
activeUntil?: string | null;
buttons?: AnnouncementButton[] | null;
targeting?: AnnouncementTargeting | null;
paths?: AnnouncementPathConfig | null;
}
function parseTime(value?: string | null): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Time-window check. Empty bounds mean "open on that side".
*/
export function isWithinWindow(announcement: AnnouncementEntry, now: number): boolean {
const from = parseTime(announcement.activeFrom);
const until = parseTime(announcement.activeUntil);
if (from !== null && now < from) return false;
if (until !== null && now >= until) return false;
return true;
}
/**
* Checks whether the current pathname matches the announcement's path config.
*
* - matchType "all": always matches.
* - matchType "include": pathname must start with one of the listed prefixes.
* - matchType "exclude": pathname must NOT start with any listed prefix.
*/
export function matchesPath(
pathname: string,
pathConfig?: AnnouncementPathConfig | null,
): boolean {
if (!pathConfig || pathConfig.matchType === "all") return true;
const prefixes = pathConfig.paths ?? [];
if (prefixes.length === 0) return true;
const matched = prefixes.some((prefix) => {
const normalized = prefix.startsWith("/") ? prefix : `/${prefix}`;
return pathname === normalized || pathname.startsWith(`${normalized}/`);
});
return pathConfig.matchType === "include" ? matched : !matched;
}
/**
* Checks whether a user is in the announcement's target audience.
* Does NOT check enabled, the time window, or the path.
*/
export function matchesAudience(
announcement: AnnouncementEntry,
userId: number | null,
userRoleDocIds: number[],
): boolean {
const targeting = announcement.targeting;
if (!targeting) return true;
switch (targeting.audience) {
case "users": {
if (userId === null) return false;
const targetUsers = targeting.users ?? [];
return targetUsers.some((u) => (typeof u === "object" ? u.id : u) === userId);
}
case "roles": {
const targetRoles = targeting.roles ?? [];
return targetRoles.some((r) => {
const id = typeof r === "object" && r !== null ? r.id : r;
return userRoleDocIds.includes(Number(id));
});
}
case "all":
default:
return true;
}
}
/**
* Full evaluation: enabled → time window → audience → path.
*/
export function isAnnouncementActive(
announcement: AnnouncementEntry,
userId: number | null,
userRoleDocIds: number[],
pathname: string,
now: number,
): boolean {
if (!announcement.enabled) return false;
if (!isWithinWindow(announcement, now)) return false;
if (!matchesAudience(announcement, userId, userRoleDocIds)) return false;
if (!matchesPath(pathname, announcement.paths)) return false;
return true;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_announcements_buttons_style" AS ENUM('default', 'outline', 'destructive');
CREATE TYPE "public"."enum_announcements_targeting_roles" AS ENUM('guest', 'user', 'trusted', 'admin', 'developer');
CREATE TYPE "public"."enum_announcements_variant" AS ENUM('info', 'success', 'warning', 'error');
CREATE TYPE "public"."enum_announcements_display_type" AS ENUM('banner', 'modal', 'dialog', 'toast');
CREATE TYPE "public"."enum_announcements_targeting_audience" AS ENUM('all', 'users', 'roles');
CREATE TYPE "public"."enum_announcements_paths_match_type" AS ENUM('all', 'include', 'exclude');
CREATE TABLE "announcements_buttons" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"label" varchar NOT NULL,
"href" varchar,
"style" "enum_announcements_buttons_style" DEFAULT 'default'
);
CREATE TABLE "announcements_targeting_roles" (
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_announcements_targeting_roles",
"id" serial PRIMARY KEY NOT NULL
);
CREATE TABLE "announcements" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"subtitle" varchar,
"description" varchar,
"variant" "enum_announcements_variant" DEFAULT 'info' NOT NULL,
"display_type" "enum_announcements_display_type" DEFAULT 'banner' NOT NULL,
"enabled" boolean DEFAULT true,
"dismissible" boolean DEFAULT true,
"active_from" timestamp(3) with time zone,
"active_until" timestamp(3) with time zone,
"targeting_audience" "enum_announcements_targeting_audience" DEFAULT 'all' NOT NULL,
"paths_match_type" "enum_announcements_paths_match_type" DEFAULT 'all' NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "announcements_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
CREATE TABLE "announcements_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "announcements_find" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "announcements_create" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "announcements_update" boolean DEFAULT false;
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "announcements_delete" boolean DEFAULT false;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "announcements_id" integer;
ALTER TABLE "announcements_buttons" ADD CONSTRAINT "announcements_buttons_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."announcements"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "announcements_targeting_roles" ADD CONSTRAINT "announcements_targeting_roles_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."announcements"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "announcements_texts" ADD CONSTRAINT "announcements_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."announcements"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "announcements_rels" ADD CONSTRAINT "announcements_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."announcements"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "announcements_rels" ADD CONSTRAINT "announcements_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "announcements_buttons_order_idx" ON "announcements_buttons" USING btree ("_order");
CREATE INDEX "announcements_buttons_parent_id_idx" ON "announcements_buttons" USING btree ("_parent_id");
CREATE INDEX "announcements_targeting_roles_order_idx" ON "announcements_targeting_roles" USING btree ("order");
CREATE INDEX "announcements_targeting_roles_parent_idx" ON "announcements_targeting_roles" USING btree ("parent_id");
CREATE INDEX "announcements_updated_at_idx" ON "announcements" USING btree ("updated_at");
CREATE INDEX "announcements_created_at_idx" ON "announcements" USING btree ("created_at");
CREATE INDEX "announcements_texts_order_parent" ON "announcements_texts" USING btree ("order","parent_id");
CREATE INDEX "announcements_rels_order_idx" ON "announcements_rels" USING btree ("order");
CREATE INDEX "announcements_rels_parent_idx" ON "announcements_rels" USING btree ("parent_id");
CREATE INDEX "announcements_rels_path_idx" ON "announcements_rels" USING btree ("path");
CREATE INDEX "announcements_rels_users_id_idx" ON "announcements_rels" USING btree ("users_id");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_announcements_fk" FOREIGN KEY ("announcements_id") REFERENCES "public"."announcements"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_announcements_id_idx" ON "payload_locked_documents_rels" USING btree ("announcements_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcements_buttons" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "announcements_targeting_roles" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "announcements" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "announcements_texts" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "announcements_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "announcements_buttons" CASCADE;
DROP TABLE "announcements_targeting_roles" CASCADE;
DROP TABLE "announcements" CASCADE;
DROP TABLE "announcements_texts" CASCADE;
DROP TABLE "announcements_rels" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_announcements_fk";
DROP INDEX "payload_locked_documents_rels_announcements_id_idx";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "announcements_find";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "announcements_create";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "announcements_update";
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "announcements_delete";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "announcements_id";
DROP TYPE "public"."enum_announcements_buttons_style";
DROP TYPE "public"."enum_announcements_targeting_roles";
DROP TYPE "public"."enum_announcements_variant";
DROP TYPE "public"."enum_announcements_display_type";
DROP TYPE "public"."enum_announcements_targeting_audience";
DROP TYPE "public"."enum_announcements_paths_match_type";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcements_rels" ADD COLUMN "roles_id" integer;
ALTER TABLE "announcements_rels" ADD CONSTRAINT "announcements_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "announcements_rels_roles_id_idx" ON "announcements_rels" USING btree ("roles_id");
ALTER TABLE "announcements_targeting_roles" DISABLE ROW LEVEL SECURITY;
DROP TABLE "announcements_targeting_roles";
DROP TYPE "public"."enum_announcements_targeting_roles";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_announcements_targeting_roles" AS ENUM('guest', 'user', 'trusted', 'admin', 'developer');
CREATE TABLE "announcements_targeting_roles" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"value" "enum_announcements_targeting_roles",
CONSTRAINT "announcements_targeting_roles_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."announcements"("id") ON DELETE cascade ON UPDATE no action
);
CREATE INDEX "announcements_targeting_roles_order_idx" ON "announcements_targeting_roles" USING btree ("order");
CREATE INDEX "announcements_targeting_roles_parent_idx" ON "announcements_targeting_roles" USING btree ("parent_id");
DROP INDEX "announcements_rels_roles_id_idx";
ALTER TABLE "announcements_rels" DROP CONSTRAINT "announcements_rels_roles_fk";
ALTER TABLE "announcements_rels" DROP COLUMN "roles_id";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcements" ALTER COLUMN "description" SET DATA TYPE jsonb USING
CASE
WHEN "description" IS NULL OR "description" = '' THEN NULL
ELSE jsonb_build_object(
'root',
jsonb_build_object(
'type', 'root',
'format', '',
'indent', 0,
'version', 1,
'direction', 'ltr',
'children', jsonb_build_array(
jsonb_build_object(
'type', 'paragraph',
'format', '',
'indent', 0,
'version', 1,
'direction', 'ltr',
'children', jsonb_build_array(
jsonb_build_object(
'type', 'text',
'format', 0,
'style', '',
'mode', 'normal',
'text', "description",
'version', 1
)
)
)
)
)
)
END;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcements" ALTER COLUMN "description" SET DATA TYPE varchar USING
CASE
WHEN "description" IS NULL THEN NULL
ELSE COALESCE("description" #>> '{root,children,0,children,0,text}', '')
END;`)
}

View file

@ -83,6 +83,9 @@ import * as migration_20260921_130500_weather_freeform from './20260921_130500_w
import * as migration_20260921_134000_add_node_icons from './20260921_134000_add_node_icons';
import * as migration_20260921_140000_add_currency_symbol_image from './20260921_140000_add_currency_symbol_image';
import * as migration_20260921_150000_add_feature_flags from './20260921_150000_add_feature_flags';
import * as migration_20260922_230950_add_announcements_collection from './20260922_230950_add_announcements_collection';
import * as migration_20260923_001209_add_announcements_role_docs_targeting from './20260923_001209_add_announcements_role_docs_targeting';
import * as migration_20260923_002422_announcements_description_richtext from './20260923_002422_announcements_description_richtext';
export const migrations = [
{
@ -510,4 +513,19 @@ export const migrations = [
down: migration_20260921_150000_add_feature_flags.down,
name: '20260921_150000_add_feature_flags',
},
{
up: migration_20260922_230950_add_announcements_collection.up,
down: migration_20260922_230950_add_announcements_collection.down,
name: '20260922_230950_add_announcements_collection',
},
{
up: migration_20260923_001209_add_announcements_role_docs_targeting.up,
down: migration_20260923_001209_add_announcements_role_docs_targeting.down,
name: '20260923_001209_add_announcements_role_docs_targeting',
},
{
up: migration_20260923_002422_announcements_description_richtext.up,
down: migration_20260923_002422_announcements_description_richtext.down,
name: '20260923_002422_announcements_description_richtext',
},
];

File diff suppressed because it is too large Load diff

View file

@ -140,6 +140,7 @@ export interface Config {
'custom-minefield-scores': CustomMinefieldScore;
'custom-radio-tests': CustomRadioTest;
'custom-radio-test-scores': CustomRadioTestScore;
announcements: Announcement;
'plugin-ai-instructions': PluginAiInstruction;
'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv;
@ -228,6 +229,7 @@ export interface Config {
'custom-minefield-scores': CustomMinefieldScoresSelect<false> | CustomMinefieldScoresSelect<true>;
'custom-radio-tests': CustomRadioTestsSelect<false> | CustomRadioTestsSelect<true>;
'custom-radio-test-scores': CustomRadioTestScoresSelect<false> | CustomRadioTestScoresSelect<true>;
announcements: AnnouncementsSelect<false> | AnnouncementsSelect<true>;
'plugin-ai-instructions': PluginAiInstructionsSelect<false> | PluginAiInstructionsSelect<true>;
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
@ -1915,6 +1917,11 @@ export interface Role {
| 'zone-pressure:update'
| 'zone-pressure:delete'
| 'admin:zone-pressure:manage'
| 'announcements:create'
| 'announcements:read'
| 'announcements:update'
| 'announcements:delete'
| 'admin:announcements:manage'
| 'game-rules:read'
| 'game-rules:update'
| 'shims:read'
@ -4758,6 +4765,103 @@ export interface CustomRadioTestScore {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "announcements".
*/
export interface Announcement {
id: number;
/**
* Headline shown in every display type.
*/
title: string;
/**
* Optional secondary line shown under the title.
*/
subtitle?: string | null;
/**
* Optional longer body text (rendered with formatting on the frontend).
*/
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
/**
* Colors the announcement (and the toast style).
*/
variant: 'info' | 'success' | 'warning' | 'error';
/**
* How the announcement is presented. Dialogs show their configured buttons; modals get a default OK button when none are set.
*/
displayType: 'banner' | 'modal' | 'dialog' | 'toast';
/**
* Disabled announcements are never shown, regardless of targeting rules.
*/
enabled?: boolean | null;
/**
* Whether the user can dismiss it for this browser until its window ends. Non-dismissible announcements reappear on every page load while active.
*/
dismissible?: boolean | null;
/**
* Optional. The announcement is not shown before this moment.
*/
activeFrom?: string | null;
/**
* Optional. The announcement stops being shown after this moment.
*/
activeUntil?: string | null;
/**
* Optional action buttons (used by confirmation dialogs and modals; banners and toasts show them inline where supported).
*/
buttons?:
| {
label: string;
/**
* Optional internal path to navigate to when clicked (e.g. '/logistics/shipments'). Leave empty for a close-only button.
*/
href?: string | null;
style?: ('default' | 'outline' | 'destructive') | null;
id?: string | null;
}[]
| null;
/**
* Who should this announcement be shown to?
*/
targeting: {
audience: 'all' | 'users' | 'roles';
/**
* Only these specific users will see this announcement.
*/
users?: (number | User)[] | null;
/**
* Users holding any of these roles (the dynamic Roles collection) will see this announcement.
*/
roles?: (number | Role)[] | null;
};
/**
* On which pages should this announcement be shown?
*/
paths: {
matchType: 'all' | 'include' | 'exclude';
/**
* URL path prefixes to match. E.g. '/logistics' matches /logistics and /logistics/market. Leading slash required.
*/
paths?: string[] | null;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-ai-instructions".
@ -6115,6 +6219,24 @@ export interface PayloadMcpApiKey {
*/
delete?: boolean | null;
};
announcements?: {
/**
* Allow clients to find announcements.
*/
find?: boolean | null;
/**
* Allow clients to create announcements.
*/
create?: boolean | null;
/**
* Allow clients to update announcements.
*/
update?: boolean | null;
/**
* Allow clients to delete announcements.
*/
delete?: boolean | null;
};
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean | null;
@ -6434,6 +6556,10 @@ export interface PayloadLockedDocument {
relationTo: 'custom-radio-test-scores';
value: number | CustomRadioTestScore;
} | null)
| ({
relationTo: 'announcements';
value: number | Announcement;
} | null)
| ({
relationTo: 'plugin-ai-instructions';
value: number | PluginAiInstruction;
@ -8501,6 +8627,44 @@ export interface CustomRadioTestScoresSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "announcements_select".
*/
export interface AnnouncementsSelect<T extends boolean = true> {
title?: T;
subtitle?: T;
description?: T;
variant?: T;
displayType?: T;
enabled?: T;
dismissible?: T;
activeFrom?: T;
activeUntil?: T;
buttons?:
| T
| {
label?: T;
href?: T;
style?: T;
id?: T;
};
targeting?:
| T
| {
audience?: T;
users?: T;
roles?: T;
};
paths?:
| T
| {
matchType?: T;
paths?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-ai-instructions_select".
@ -9112,6 +9276,14 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
update?: T;
delete?: T;
};
announcements?:
| T
| {
find?: T;
create?: T;
update?: T;
delete?: T;
};
updatedAt?: T;
createdAt?: T;
enableAPIKey?: T;

View file

@ -77,6 +77,7 @@ import { Labels } from "@/collections/projects/Labels";
import { StoryBeats } from "@/collections/story/StoryBeats";
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
import { Shims } from "@/collections/Shims";
import { Announcements } from "@/collections/Announcements";
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
@ -330,6 +331,9 @@ const collections = [
CustomMinefieldScores,
CustomRadioTests,
CustomRadioTestScores,
// Announcements
Announcements,
];
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`

View file

@ -617,6 +617,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
],
},
{
group: "Announcements",
permissions: [
{ value: "announcements:create", label: "Create" },
{ value: "announcements:read", label: "Read" },
{ value: "announcements:update", label: "Update" },
{ value: "announcements:delete", label: "Delete" },
{ value: "admin:announcements:manage", label: "Manage Admin Page" },
],
},
// ---- Globals -------------------------------------------------------------
{
group: "Game Rules (Global)",
@ -966,6 +977,11 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"game-rules:update",
"shims:read",
"shims:update",
"announcements:create",
"announcements:read",
"announcements:update",
"announcements:delete",
"admin:announcements:manage",
"logistics:manage",
"intelligence:manage",
"profiles:intel_excerpt:update",

View file

@ -0,0 +1,291 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { Role, User } from "@/payload-types";
import { Announcements } from "@/collections/Announcements";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
import {
isAnnouncementActive,
isWithinWindow,
matchesAudience,
matchesPath,
type AnnouncementEntry,
} from "@/lib/announcements/evaluate";
import {
readAnnouncementDismissals,
storeAnnouncementDismissal,
writeAnnouncementDismissals,
} from "@/lib/announcements/dismissals";
let payload: Payload;
const RUN = `ann-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
const NOW = 1_700_000_000_000;
const HOUR = 60 * 60 * 1000;
const base: AnnouncementEntry = {
id: 1,
title: "Test announcement",
variant: "info",
displayType: "banner",
enabled: true,
dismissible: true,
targeting: { audience: "all" },
paths: { matchType: "all" },
};
describe("announcement evaluation (pure)", () => {
it("treats missing bounds as an open window", () => {
expect(isWithinWindow({ ...base, activeFrom: null, activeUntil: null }, NOW)).toBe(true);
});
it("respects activeFrom and activeUntil", () => {
expect(isWithinWindow({ ...base, activeFrom: new Date(NOW + HOUR).toISOString() }, NOW)).toBe(
false,
);
expect(isWithinWindow({ ...base, activeFrom: new Date(NOW - HOUR).toISOString() }, NOW)).toBe(
true,
);
expect(isWithinWindow({ ...base, activeUntil: new Date(NOW).toISOString() }, NOW)).toBe(false);
expect(
isWithinWindow({ ...base, activeUntil: new Date(NOW + HOUR).toISOString() }, NOW),
).toBe(true);
});
it("audience all matches anyone; users matches by id; roles matches any listed roleDoc id", () => {
expect(matchesAudience({ ...base, targeting: { audience: "all" } }, 7, [])).toBe(true);
expect(
matchesAudience({ ...base, targeting: { audience: "users", users: [7, 9] } }, 9, []),
).toBe(true);
expect(
matchesAudience({ ...base, targeting: { audience: "users", users: [7, 9] } }, 8, []),
).toBe(false);
expect(matchesAudience({ ...base, targeting: { audience: "users" } }, null, [])).toBe(false);
expect(
matchesAudience(
{ ...base, targeting: { audience: "users", users: [{ id: 7 }] as never } },
7,
[],
),
).toBe(true);
expect(
matchesAudience(
{ ...base, targeting: { audience: "roles", roles: [3, 5] } },
null,
[2],
),
).toBe(false);
expect(
matchesAudience(
{ ...base, targeting: { audience: "roles", roles: [3, 5] } },
1,
[2, 5],
),
).toBe(true);
expect(
matchesAudience({ ...base, targeting: { audience: "roles" } }, 1, []),
).toBe(false);
});
it("isAnnouncementActive requires enabled", () => {
expect(isAnnouncementActive({ ...base, enabled: false }, 1, [], "/logistics", NOW)).toBe(
false,
);
expect(isAnnouncementActive(base, 1, [], "/logistics", NOW)).toBe(true);
});
it("path matching supports all, include prefixes, and exclude", () => {
expect(matchesPath("/logistics/market", { matchType: "all" })).toBe(true);
expect(
matchesPath("/logistics/market", { matchType: "include", paths: ["/logistics"] }),
).toBe(true);
expect(matchesPath("/wiki/some-page", { matchType: "include", paths: ["/logistics"] })).toBe(
false,
);
expect(
matchesPath("/wiki/some-page", { matchType: "exclude", paths: ["/logistics"] }),
).toBe(true);
expect(
matchesPath("/logistics/market", { matchType: "exclude", paths: ["/logistics"] }),
).toBe(false);
expect(matchesPath("/logistics", { matchType: "include", paths: ["/logistics"] })).toBe(true);
expect(
matchesPath("/logistics-market", { matchType: "include", paths: ["/logistics"] }),
).toBe(false);
});
it("isAnnouncementActive requires enabled", () => {
expect(isAnnouncementActive({ ...base, enabled: false }, 1, [], "/logistics", NOW)).toBe(
false,
);
expect(isAnnouncementActive(base, 1, [], "/logistics", NOW)).toBe(true);
});
});
describe("announcement dismissals (localStorage)", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("roundtrips dismissals and drops expired entries on read", () => {
writeAnnouncementDismissals({ "1": NOW + HOUR, "2": NOW - HOUR }, NOW);
const read = readAnnouncementDismissals(NOW);
expect(read["1"]).toBe(NOW + HOUR);
expect(read["2"]).toBeUndefined();
});
it("storeAnnouncementDismissal expires at activeUntil when set", () => {
storeAnnouncementDismissal(5, new Date(NOW + 2 * HOUR).toISOString(), NOW);
storeAnnouncementDismissal(6, new Date(NOW - 2 * HOUR).toISOString(), NOW);
storeAnnouncementDismissal(7, null, NOW);
const read = readAnnouncementDismissals(NOW);
expect(read["5"]).toBe(NOW + 2 * HOUR);
// Expired activeUntil still yields the 30-day fallback.
expect(read["6"]).toBeGreaterThan(NOW);
expect(read["7"]).toBeGreaterThan(NOW + 29 * 24 * HOUR);
});
});
describe("announcements collection access control", () => {
const roleIds: number[] = [];
const userIds: number[] = [];
const announcementIds: number[] = [];
let permittedUser: User;
let plainUser: User;
let devUser: User;
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
const role = (await payload.create({
collection: "roles",
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
overrideAccess: true,
depth: 0,
})) as unknown as Role;
roleIds.push(role.id);
return role;
};
const makeUser = async (label: string, roleId: number): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roleDocs: [roleId],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const accessDecision = async (
action: "create" | "read" | "update" | "delete",
user: User | null,
): Promise<unknown> => {
const fn = Announcements.access?.[action];
expect(typeof fn).toBe("function");
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
req: { user, payload },
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
invalidatePermissionCache();
const permittedRole = await makeRole("permitted", {
permissions: [
"announcements:create",
"announcements:read",
"announcements:update",
"announcements:delete",
],
});
const plainRole = await makeRole("plain", { permissions: [] });
const devRole = await makeRole("dev", { isSuperuser: true });
permittedUser = await makeUser("permitted", permittedRole.id);
plainUser = await makeUser("plain", plainRole.id);
devUser = await makeUser("dev", devRole.id);
}, TIMEOUT);
afterAll(async () => {
if (!payload) return;
for (const id of announcementIds) {
await payload
.delete({ collection: "announcements", id, overrideAccess: true })
.catch(() => {});
}
for (const id of userIds) {
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
});
it("grants CRUD to roles holding the announcements permissions", async () => {
expect(await accessDecision("read", permittedUser)).toBe(true);
expect(await accessDecision("create", permittedUser)).toBe(true);
expect(await accessDecision("update", permittedUser)).toBe(true);
expect(await accessDecision("delete", permittedUser)).toBe(true);
});
it("denies users without announcements permissions", async () => {
expect(await accessDecision("read", plainUser)).toBe(false);
expect(await accessDecision("create", plainUser)).toBe(false);
});
it("grants superusers everything and denies anonymous users", async () => {
expect(await accessDecision("read", devUser)).toBe(true);
expect(await accessDecision("delete", devUser)).toBe(true);
expect(await accessDecision("read", null)).toBe(false);
expect(await accessDecision("create", null)).toBe(false);
});
it("roundtrips an announcement through the local API", async () => {
const created = (await payload.create({
collection: "announcements",
data: {
title: `${RUN} maintenance window`,
variant: "warning",
displayType: "banner",
enabled: true,
dismissible: false,
targeting: { audience: "roles", roles: [roleIds[0]] },
paths: { matchType: "exclude", paths: ["/admin"] },
},
overrideAccess: true,
depth: 0,
})) as { id: number };
announcementIds.push(created.id);
const found = await payload.find({
collection: "announcements",
where: { enabled: { equals: true } },
limit: 50,
depth: 0,
overrideAccess: true,
});
const doc = found.docs.find((d) => d.id === created.id);
expect(doc).toBeDefined();
expect(doc?.title).toBe(`${RUN} maintenance window`);
expect((doc as { targeting: { roles: number[] } }).targeting.roles).toContain(roleIds[0]);
await payload.delete({ collection: "announcements", id: created.id, overrideAccess: true });
announcementIds.pop();
}, TIMEOUT);
});