Compare commits
No commits in common. "8afe0cd1ed2696ee5bcc15d591dc3fdf0dd5b2e5" and "766aabeae3f95dbb1eb56f1a6c816937e9a2fd77" have entirely different histories.
8afe0cd1ed
...
766aabeae3
22 changed files with 8 additions and 98668 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -114,7 +114,7 @@ Organized by domain under `src/collections/`:
|
||||||
- **projects/** — Projects, Labels, Releases, Sprints
|
- **projects/** — Projects, Labels, Releases, Sprints
|
||||||
- **tickets/** — Tickets, TicketVotes
|
- **tickets/** — Tickets, TicketVotes
|
||||||
- **wiki/** — WikiPages, WikiRevisions, WikiTemplates
|
- **wiki/** — WikiPages, WikiRevisions, WikiTemplates
|
||||||
- top-level — `Media.ts` (image uploads, used by wiki), `Shims.ts` (audience-targeted content), `Announcements.ts` (banner/modal/dialog/toast announcements)
|
- top-level — `Media.ts` (image uploads, used by wiki), `Shims.ts` (audience-targeted content)
|
||||||
|
|
||||||
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`.
|
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,16 +276,6 @@ 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`).
|
- **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.
|
- **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
|
## 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.
|
`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",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.50",
|
"version": "0.2.49",
|
||||||
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ import { cookies } from "next/headers";
|
||||||
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation";
|
||||||
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner";
|
||||||
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
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";
|
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
|
||||||
|
|
||||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||||
|
|
@ -81,9 +79,6 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
|
const shimsGlobal = await payload.findGlobal({ slug: "shims" });
|
||||||
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
||||||
|
|
||||||
let announcementEntries: AnnouncementEntry[] = [];
|
|
||||||
let userRoleDocIds: number[] = [];
|
|
||||||
|
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
let statisticsEnabled = true;
|
let statisticsEnabled = true;
|
||||||
let helpdeskEnabled = true;
|
let helpdeskEnabled = true;
|
||||||
|
|
@ -96,7 +91,6 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
experienceRes,
|
experienceRes,
|
||||||
personalAccountRes,
|
personalAccountRes,
|
||||||
gameRulesRes,
|
gameRulesRes,
|
||||||
announcementsRes,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
hasIntelligenceQualification(payload, user).catch(() => false),
|
hasIntelligenceQualification(payload, user).catch(() => false),
|
||||||
hasLogisticsQualification(payload, user).catch(() => false),
|
hasLogisticsQualification(payload, user).catch(() => false),
|
||||||
|
|
@ -131,12 +125,6 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}),
|
}),
|
||||||
payload.findGlobal({ slug: "game-rules" }),
|
payload.findGlobal({ slug: "game-rules" }),
|
||||||
payload.find({
|
|
||||||
collection: "announcements",
|
|
||||||
where: { enabled: { equals: true } },
|
|
||||||
sort: "-createdAt",
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
isIntelligence = intelligenceQual;
|
isIntelligence = intelligenceQual;
|
||||||
isLogistics = logisticsQual;
|
isLogistics = logisticsQual;
|
||||||
|
|
@ -150,10 +138,6 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
|
currentRank = (profileRes.docs[0]?.rank as Rank | null | undefined)?.abbreviation ?? "";
|
||||||
|
|
||||||
isAdmin = await canAccessAdminPanel(payload, user);
|
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 (
|
return (
|
||||||
|
|
@ -193,13 +177,6 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
>
|
>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
{impersonating && <ImpersonationBanner />}
|
{impersonating && <ImpersonationBanner />}
|
||||||
{user && (
|
|
||||||
<AnnouncementHost
|
|
||||||
announcements={announcementEntries}
|
|
||||||
userId={user.id}
|
|
||||||
userRoleDocIds={userRoleDocIds}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|
|
||||||
|
|
@ -23,21 +23,6 @@ function basemapUrl(map: MapDoc): string | null {
|
||||||
return 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() {
|
export default async function MapPickerPage() {
|
||||||
const payload = await getPayload({ config: await config });
|
const payload = await getPayload({ config: await config });
|
||||||
|
|
||||||
|
|
@ -85,10 +70,6 @@ export default async function MapPickerPage() {
|
||||||
? (map.factionControl as Faction)
|
? (map.factionControl as Faction)
|
||||||
: null;
|
: null;
|
||||||
const url = basemapUrl(map);
|
const url = basemapUrl(map);
|
||||||
const tileUrls =
|
|
||||||
map.basemapMode === "tiles" && map.tileUrlTemplate
|
|
||||||
? tilePreviewUrls(map.tileUrlTemplate)
|
|
||||||
: null;
|
|
||||||
const stats = [
|
const stats = [
|
||||||
map.weather
|
map.weather
|
||||||
? {
|
? {
|
||||||
|
|
@ -120,18 +101,6 @@ export default async function MapPickerPage() {
|
||||||
alt={map.name}
|
alt={map.name}
|
||||||
className="size-full object-cover opacity-80 transition-opacity group-hover:opacity-100"
|
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">
|
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||||
<MapIcon className="size-8 opacity-40" />
|
<MapIcon className="size-8 opacity-40" />
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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")`.
|
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")`.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
collections/
|
collections/
|
||||||
Media.ts # Image uploads (public read; used by wiki image upload)
|
Media.ts # Image uploads (public read; used by wiki image upload)
|
||||||
Shims.ts # Global: audience-targeted content (admin-only)
|
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,
|
users/ 11 files # Users (auth), Ranks, Profiles (incl. minigame stats), Awards,
|
||||||
# Qualifications, Assignments, AssignmentTransfers, Experience,
|
# Qualifications, Assignments, AssignmentTransfers, Experience,
|
||||||
|
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
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.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
"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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
@ -1,111 +0,0 @@
|
||||||
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
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
|
@ -1,47 +0,0 @@
|
||||||
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,9 +83,6 @@ 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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -513,19 +510,4 @@ export const migrations = [
|
||||||
down: migration_20260921_150000_add_feature_flags.down,
|
down: migration_20260921_150000_add_feature_flags.down,
|
||||||
name: '20260921_150000_add_feature_flags',
|
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,7 +140,6 @@ export interface Config {
|
||||||
'custom-minefield-scores': CustomMinefieldScore;
|
'custom-minefield-scores': CustomMinefieldScore;
|
||||||
'custom-radio-tests': CustomRadioTest;
|
'custom-radio-tests': CustomRadioTest;
|
||||||
'custom-radio-test-scores': CustomRadioTestScore;
|
'custom-radio-test-scores': CustomRadioTestScore;
|
||||||
announcements: Announcement;
|
|
||||||
'plugin-ai-instructions': PluginAiInstruction;
|
'plugin-ai-instructions': PluginAiInstruction;
|
||||||
'payload-mcp-api-keys': PayloadMcpApiKey;
|
'payload-mcp-api-keys': PayloadMcpApiKey;
|
||||||
'payload-kv': PayloadKv;
|
'payload-kv': PayloadKv;
|
||||||
|
|
@ -229,7 +228,6 @@ export interface Config {
|
||||||
'custom-minefield-scores': CustomMinefieldScoresSelect<false> | CustomMinefieldScoresSelect<true>;
|
'custom-minefield-scores': CustomMinefieldScoresSelect<false> | CustomMinefieldScoresSelect<true>;
|
||||||
'custom-radio-tests': CustomRadioTestsSelect<false> | CustomRadioTestsSelect<true>;
|
'custom-radio-tests': CustomRadioTestsSelect<false> | CustomRadioTestsSelect<true>;
|
||||||
'custom-radio-test-scores': CustomRadioTestScoresSelect<false> | CustomRadioTestScoresSelect<true>;
|
'custom-radio-test-scores': CustomRadioTestScoresSelect<false> | CustomRadioTestScoresSelect<true>;
|
||||||
announcements: AnnouncementsSelect<false> | AnnouncementsSelect<true>;
|
|
||||||
'plugin-ai-instructions': PluginAiInstructionsSelect<false> | PluginAiInstructionsSelect<true>;
|
'plugin-ai-instructions': PluginAiInstructionsSelect<false> | PluginAiInstructionsSelect<true>;
|
||||||
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
|
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
|
|
@ -1917,11 +1915,6 @@ export interface Role {
|
||||||
| 'zone-pressure:update'
|
| 'zone-pressure:update'
|
||||||
| 'zone-pressure:delete'
|
| 'zone-pressure:delete'
|
||||||
| 'admin:zone-pressure:manage'
|
| 'admin:zone-pressure:manage'
|
||||||
| 'announcements:create'
|
|
||||||
| 'announcements:read'
|
|
||||||
| 'announcements:update'
|
|
||||||
| 'announcements:delete'
|
|
||||||
| 'admin:announcements:manage'
|
|
||||||
| 'game-rules:read'
|
| 'game-rules:read'
|
||||||
| 'game-rules:update'
|
| 'game-rules:update'
|
||||||
| 'shims:read'
|
| 'shims:read'
|
||||||
|
|
@ -4765,103 +4758,6 @@ export interface CustomRadioTestScore {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "plugin-ai-instructions".
|
* via the `definition` "plugin-ai-instructions".
|
||||||
|
|
@ -6219,24 +6115,6 @@ export interface PayloadMcpApiKey {
|
||||||
*/
|
*/
|
||||||
delete?: boolean | null;
|
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;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
enableAPIKey?: boolean | null;
|
enableAPIKey?: boolean | null;
|
||||||
|
|
@ -6556,10 +6434,6 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'custom-radio-test-scores';
|
relationTo: 'custom-radio-test-scores';
|
||||||
value: number | CustomRadioTestScore;
|
value: number | CustomRadioTestScore;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
|
||||||
relationTo: 'announcements';
|
|
||||||
value: number | Announcement;
|
|
||||||
} | null)
|
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'plugin-ai-instructions';
|
relationTo: 'plugin-ai-instructions';
|
||||||
value: number | PluginAiInstruction;
|
value: number | PluginAiInstruction;
|
||||||
|
|
@ -8627,44 +8501,6 @@ export interface CustomRadioTestScoresSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "plugin-ai-instructions_select".
|
* via the `definition` "plugin-ai-instructions_select".
|
||||||
|
|
@ -9276,14 +9112,6 @@ export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
|
||||||
update?: T;
|
update?: T;
|
||||||
delete?: T;
|
delete?: T;
|
||||||
};
|
};
|
||||||
announcements?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
find?: T;
|
|
||||||
create?: T;
|
|
||||||
update?: T;
|
|
||||||
delete?: T;
|
|
||||||
};
|
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
enableAPIKey?: T;
|
enableAPIKey?: T;
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,6 @@ import { Labels } from "@/collections/projects/Labels";
|
||||||
import { StoryBeats } from "@/collections/story/StoryBeats";
|
import { StoryBeats } from "@/collections/story/StoryBeats";
|
||||||
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
||||||
import { Shims } from "@/collections/Shims";
|
import { Shims } from "@/collections/Shims";
|
||||||
import { Announcements } from "@/collections/Announcements";
|
|
||||||
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
||||||
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
|
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
|
||||||
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||||
|
|
@ -331,9 +330,6 @@ const collections = [
|
||||||
CustomMinefieldScores,
|
CustomMinefieldScores,
|
||||||
CustomRadioTests,
|
CustomRadioTests,
|
||||||
CustomRadioTestScores,
|
CustomRadioTestScores,
|
||||||
|
|
||||||
// Announcements
|
|
||||||
Announcements,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`
|
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`
|
||||||
|
|
|
||||||
|
|
@ -617,17 +617,6 @@ 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 -------------------------------------------------------------
|
// ---- Globals -------------------------------------------------------------
|
||||||
{
|
{
|
||||||
group: "Game Rules (Global)",
|
group: "Game Rules (Global)",
|
||||||
|
|
@ -977,11 +966,6 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
||||||
"game-rules:update",
|
"game-rules:update",
|
||||||
"shims:read",
|
"shims:read",
|
||||||
"shims:update",
|
"shims:update",
|
||||||
"announcements:create",
|
|
||||||
"announcements:read",
|
|
||||||
"announcements:update",
|
|
||||||
"announcements:delete",
|
|
||||||
"admin:announcements:manage",
|
|
||||||
"logistics:manage",
|
"logistics:manage",
|
||||||
"intelligence:manage",
|
"intelligence:manage",
|
||||||
"profiles:intel_excerpt:update",
|
"profiles:intel_excerpt:update",
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,10 @@ function resolveHemtt(explicit?: string): string {
|
||||||
throw new Error("hemtt not found; pass --hemtt <path>");
|
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([
|
const KNOWN_ASSET_TYPES = new Set([
|
||||||
"weapon", "magazine", "optic", "muzzle", "bipod", "uniform", "vest",
|
"weapon", "magazine", "ammo", "backpack", "vest", "helmet", "uniform",
|
||||||
"backpack", "helmet", "facewear", "nvg", "radio", "medical", "tool",
|
"item", "attachment", "optics", "muzzle", "grip", "rail", "explosive",
|
||||||
"explosive", "skin", "miscellaneous",
|
"grenade", "medical", "tool",
|
||||||
]);
|
]);
|
||||||
function classifyAssetType(clazz: string, type: string): string {
|
function classifyAssetType(clazz: string, type: string): string {
|
||||||
const t = (type || "").toLowerCase();
|
const t = (type || "").toLowerCase();
|
||||||
|
|
@ -118,7 +116,6 @@ interface ImportPlan {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
description: string;
|
description: string;
|
||||||
assetType: string;
|
assetType: string;
|
||||||
mod: string;
|
|
||||||
pictureRef: string;
|
pictureRef: string;
|
||||||
paaPath: string;
|
paaPath: string;
|
||||||
pngPath: string;
|
pngPath: string;
|
||||||
|
|
@ -137,7 +134,6 @@ function planImport(
|
||||||
const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz;
|
const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz;
|
||||||
const description = String(rec.description ?? "").trim();
|
const description = String(rec.description ?? "").trim();
|
||||||
const pictureRef = String(rec.picture ?? "").trim();
|
const pictureRef = String(rec.picture ?? "").trim();
|
||||||
const mod = String(rec.mod ?? "").trim() || "Arma 3";
|
|
||||||
const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : "";
|
const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : "";
|
||||||
const ref = iconRef || pictureRef;
|
const ref = iconRef || pictureRef;
|
||||||
const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : "";
|
const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : "";
|
||||||
|
|
@ -147,7 +143,7 @@ function planImport(
|
||||||
const screenshotPath = lookups.screenshots
|
const screenshotPath = lookups.screenshots
|
||||||
? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "")
|
? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "")
|
||||||
: "";
|
: "";
|
||||||
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, mod, pictureRef: ref, paaPath, pngPath, screenshotPath };
|
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, pictureRef: ref, paaPath, pngPath, screenshotPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> {
|
async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> {
|
||||||
|
|
@ -186,7 +182,7 @@ async function writeEntry(
|
||||||
data: {
|
data: {
|
||||||
...common,
|
...common,
|
||||||
assetType: plan.assetType,
|
assetType: plan.assetType,
|
||||||
...(plan.pngPath ? { "Asset Metadata": { mod: plan.mod, thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
|
...(plan.pngPath ? { "Asset Metadata": { mod: "Arma 3", thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
|
||||||
},
|
},
|
||||||
}).then((d: any) => `asset:${d.id}`);
|
}).then((d: any) => `asset:${d.id}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
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