Compare commits
4 commits
766aabeae3
...
8afe0cd1ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 8afe0cd1ed | |||
| f4fbcc80fa | |||
| d51c8445bb | |||
| 8c95c1ee82 |
22 changed files with 98668 additions and 8 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.49",
|
||||
"version": "0.2.50",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,21 @@ function basemapUrl(map: MapDoc): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static preview for tile-mode maps: a 2x2 mosaic of the map's own zoom-1
|
||||
* tiles covering the whole world square, center-cropped by the card frame.
|
||||
*/
|
||||
function tilePreviewUrls(template: string): string[] | null {
|
||||
if (!template.includes("{z}") || !template.includes("{x}") || !template.includes("{y}")) {
|
||||
return null;
|
||||
}
|
||||
return [0, 1].flatMap((y) =>
|
||||
[0, 1].map((x) =>
|
||||
template.replace("{z}", "1").replace("{x}", String(x)).replace("{y}", String(y)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function MapPickerPage() {
|
||||
const payload = await getPayload({ config: await config });
|
||||
|
||||
|
|
@ -70,6 +85,10 @@ export default async function MapPickerPage() {
|
|||
? (map.factionControl as Faction)
|
||||
: null;
|
||||
const url = basemapUrl(map);
|
||||
const tileUrls =
|
||||
map.basemapMode === "tiles" && map.tileUrlTemplate
|
||||
? tilePreviewUrls(map.tileUrlTemplate)
|
||||
: null;
|
||||
const stats = [
|
||||
map.weather
|
||||
? {
|
||||
|
|
@ -101,6 +120,18 @@ export default async function MapPickerPage() {
|
|||
alt={map.name}
|
||||
className="size-full object-cover opacity-80 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
) : tileUrls ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div className="grid size-[512px] max-w-none grid-cols-2 opacity-80 transition-opacity group-hover:opacity-100">
|
||||
{tileUrls.map((tileUrl) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img key={tileUrl} src={tileUrl} alt="" className="size-[256px]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<MapIcon className="size-8 opacity-40" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
225
src/collections/Announcements.ts
Normal file
225
src/collections/Announcements.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
312
src/components/frontend/announcements/AnnouncementHost.tsx
Normal file
312
src/components/frontend/announcements/AnnouncementHost.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
src/lib/announcements/dismissals.ts
Normal file
70
src/lib/announcements/dismissals.ts
Normal 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);
|
||||
}
|
||||
137
src/lib/announcements/evaluate.ts
Normal file
137
src/lib/announcements/evaluate.ts
Normal 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;
|
||||
}
|
||||
32109
src/migrations/20260922_230950_add_announcements_collection.json
Normal file
32109
src/migrations/20260922_230950_add_announcements_collection.json
Normal file
File diff suppressed because it is too large
Load diff
111
src/migrations/20260922_230950_add_announcements_collection.ts
Normal file
111
src/migrations/20260922_230950_add_announcements_collection.ts
Normal 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
|
|
@ -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";`)
|
||||
}
|
||||
32044
src/migrations/20260923_002422_announcements_description_richtext.json
Normal file
32044
src/migrations/20260923_002422_announcements_description_richtext.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;`)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -59,10 +59,12 @@ function resolveHemtt(explicit?: string): string {
|
|||
throw new Error("hemtt not found; pass --hemtt <path>");
|
||||
}
|
||||
|
||||
// Mirrors the Assets collection's assetType enum (see payload-types.ts).
|
||||
// The in-game export (ptf_export addon) emits exactly these values.
|
||||
const KNOWN_ASSET_TYPES = new Set([
|
||||
"weapon", "magazine", "ammo", "backpack", "vest", "helmet", "uniform",
|
||||
"item", "attachment", "optics", "muzzle", "grip", "rail", "explosive",
|
||||
"grenade", "medical", "tool",
|
||||
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
|
||||
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
|
||||
"explosive", "skin", "miscellaneous",
|
||||
]);
|
||||
function classifyAssetType(clazz: string, type: string): string {
|
||||
const t = (type || "").toLowerCase();
|
||||
|
|
@ -116,6 +118,7 @@ interface ImportPlan {
|
|||
displayName: string;
|
||||
description: string;
|
||||
assetType: string;
|
||||
mod: string;
|
||||
pictureRef: string;
|
||||
paaPath: string;
|
||||
pngPath: string;
|
||||
|
|
@ -134,6 +137,7 @@ function planImport(
|
|||
const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz;
|
||||
const description = String(rec.description ?? "").trim();
|
||||
const pictureRef = String(rec.picture ?? "").trim();
|
||||
const mod = String(rec.mod ?? "").trim() || "Arma 3";
|
||||
const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : "";
|
||||
const ref = iconRef || pictureRef;
|
||||
const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : "";
|
||||
|
|
@ -143,7 +147,7 @@ function planImport(
|
|||
const screenshotPath = lookups.screenshots
|
||||
? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "")
|
||||
: "";
|
||||
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, pictureRef: ref, paaPath, pngPath, screenshotPath };
|
||||
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, mod, pictureRef: ref, paaPath, pngPath, screenshotPath };
|
||||
}
|
||||
|
||||
async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> {
|
||||
|
|
@ -182,7 +186,7 @@ async function writeEntry(
|
|||
data: {
|
||||
...common,
|
||||
assetType: plan.assetType,
|
||||
...(plan.pngPath ? { "Asset Metadata": { mod: "Arma 3", thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
|
||||
...(plan.pngPath ? { "Asset Metadata": { mod: plan.mod, thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
|
||||
},
|
||||
}).then((d: any) => `asset:${d.id}`);
|
||||
}
|
||||
|
|
|
|||
291
tests/int/announcements.int.spec.ts
Normal file
291
tests/int/announcements.int.spec.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue