1
0
Fork 0

feat(game): admin feature flags for zone pressure, statistics, and helpdesk

This commit is contained in:
Jason Fraley 2026-09-22 14:52:40 -04:00
parent 8213d76816
commit ae4ed8402d
10 changed files with 194 additions and 59 deletions

View file

@ -12,6 +12,22 @@ export const metadata = {
title: "Helpdesk: Polaris Task Force",
};
function HelpdeskDisabled() {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<LifeBuoyIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Helpdesk</h1>
</div>
<p className="text-sm text-muted-foreground">
The Helpdesk is currently disabled by a unit admin.
</p>
</div>
</div>
);
}
export default async function HelpdeskPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
@ -25,6 +41,11 @@ export default async function HelpdeskPage() {
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
}
const { helpdeskEnabled } = await payload.findGlobal({ slug: "game-rules" });
if (!helpdeskEnabled) {
return <HelpdeskDisabled />;
}
const ticketsRes = await payload.find({
collection: "tickets",
sort: "-updatedAt",

View file

@ -80,46 +80,57 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
let isAdmin = false;
let statisticsEnabled = true;
let helpdeskEnabled = true;
if (user) {
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false),
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
select: {
rank: true,
progression: {
awards: true,
scoreboard: true,
xp: true,
minigames: true,
},
const [
intelligenceQual,
logisticsQual,
profileRes,
experienceRes,
personalAccountRes,
gameRulesRes,
] = await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false),
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
select: {
rank: true,
progression: {
awards: true,
scoreboard: true,
xp: true,
minigames: true,
},
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: {
name: true,
description: true,
requiredPoints: true,
},
}),
payload.find({
collection: "bank-accounts",
where: {
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
},
limit: 1,
}),
]);
},
}),
payload.find({
collection: "experience",
sort: "requiredPoints",
limit: 100,
select: {
name: true,
description: true,
requiredPoints: true,
},
}),
payload.find({
collection: "bank-accounts",
where: {
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
},
limit: 1,
}),
payload.findGlobal({ slug: "game-rules" }),
]);
isIntelligence = intelligenceQual;
isLogistics = logisticsQual;
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
statisticsEnabled = gameRulesRes.statisticsEnabled ?? true;
helpdeskEnabled = gameRulesRes.helpdeskEnabled ?? true;
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs);
@ -150,6 +161,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
hasIntelligence={isIntelligence}
hasLogistics={isLogistics}
statisticsEnabled={statisticsEnabled}
helpdeskEnabled={helpdeskEnabled}
showCallsign={user.preferences?.display?.showCallsign ?? false}
canImpersonate={await isSuperuser(payload, user)}
preferences={user.preferences}

View file

@ -32,8 +32,28 @@ function npcIdFromListing(listing: MarketListing): number | null {
return null;
}
function StatisticsDisabled() {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<TrendingUp className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Statistics</h1>
</div>
<p className="text-sm text-muted-foreground">
Personnel statistics are currently disabled by a unit admin.
</p>
</div>
</div>
);
}
export default async function StatisticsPage() {
const payload = await getPayload({ config });
const { statisticsEnabled } = await payload.findGlobal({ slug: "game-rules" });
if (!statisticsEnabled) {
return <StatisticsDisabled />;
}
const [npcsRes, listingsRes] = await Promise.all([
payload.find({
collection: "game-npcs",

View file

@ -226,5 +226,31 @@ export const GameRules: GlobalConfig = {
},
],
},
{
name: "zonePressureEnabled",
type: "checkbox",
defaultValue: true,
admin: {
description:
"When off, the Zone Pressure feature is hidden from the Operations feed (the overview card and the AAR section).",
},
},
{
name: "statisticsEnabled",
type: "checkbox",
defaultValue: true,
admin: {
description:
"When off, the Personnel > Statistics page and its navigation entry are hidden.",
},
},
{
name: "helpdeskEnabled",
type: "checkbox",
defaultValue: true,
admin: {
description: "When off, the Helpdesk page and its navigation entry are hidden.",
},
},
],
};

View file

@ -72,6 +72,8 @@ export function AppSidebar({
adminUrl,
hasIntelligence = true,
hasLogistics = true,
statisticsEnabled = true,
helpdeskEnabled = true,
showCallsign = false,
canImpersonate = false,
preferences,
@ -86,6 +88,8 @@ export function AppSidebar({
adminUrl?: string;
hasIntelligence?: boolean;
hasLogistics?: boolean;
statisticsEnabled?: boolean;
helpdeskEnabled?: boolean;
showCallsign?: boolean;
canImpersonate?: boolean;
preferences?: NonNullable<User["preferences"]>;
@ -119,6 +123,19 @@ export function AppSidebar({
}
}, [isMiniMode, preferences]);
// Game-flags: hide the Helpdesk group and the Personnel -> Statistics entry
// when their feature is toggled off (data preserved, front-end only). An
// empty filtered list makes NavCore skip the group entirely, label included.
const filteredNavLogistics = data.navLogistics.map((section) =>
section.title === "Personnel"
? {
...section,
items: section.items.filter((item) => item.title === "Contacts" || statisticsEnabled),
}
: section,
);
const filteredNavHelpdesk = helpdeskEnabled ? data.navHelpdesk : [];
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
@ -155,10 +172,10 @@ export function AppSidebar({
)}
<NavCore label="Operations" items={data.navOperations} />
{hasLogistics && (
<NavCore label="Infrastructure" items={data.navLogistics} expandDefaultOpen={hasLogistics} />
<NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} />
)}
<NavCore label="Chronicle" items={data.navStory} />
<NavCore label="Helpdesk" items={data.navHelpdesk} />
<NavCore label="Helpdesk" items={filteredNavHelpdesk} />
<NavCore label="Commerce" items={data.navCommerce} />
<NavCore label="Personal" items={data.navPersonal} />
{/*<NavProjects projects={data.projects} />*/}

View file

@ -114,24 +114,28 @@ export function AarView({ aar }: { aar: AarResult }) {
)}
</Section>
<Section title="Zone pressure">
{aar.pressure.length === 0 ? (
<NoticeState variant="empty" message="No pressure effects recorded for this operation." />
) : (
<ul className="flex flex-col gap-1">
{aar.pressure.map((p, i) => (
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
<span
className={`font-mono text-[10px] uppercase tracking-widest ${p.delta >= 0 ? "text-emerald-400" : "text-rose-400"}`}
>
{p.delta >= 0 ? `+${p.delta}` : p.delta}
</span>
</li>
))}
</ul>
)}
</Section>
{aar.canSeePressure ? (
<Section title="Zone pressure">
{aar.pressure.length === 0 ? (
<NoticeState variant="empty" message="No pressure effects recorded for this operation." />
) : (
<ul className="flex flex-col gap-1">
{aar.pressure.map((p, i) => (
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
<span
className={`font-mono text-[10px] uppercase tracking-widest ${p.delta >= 0 ? "text-emerald-400" : "text-rose-400"}`}
>
{p.delta >= 0 ? `+${p.delta}` : p.delta}
</span>
</li>
))}
</ul>
)}
</Section>
) : (
<NoticeState variant="empty" message="Zone pressure is currently disabled." />
)}
<Section title="Reservations">
{aar.reservations.length === 0 ? (

View file

@ -88,12 +88,18 @@ export async function getSurfaceCapabilities(
payload: PayloadType,
user: SurfaceUser,
): Promise<SurfaceCapabilities> {
const [reservations, readiness, pressure] = await Promise.all([
const [reservations, readiness, pressure, rules] = await Promise.all([
hasPermission(payload, user, "operation-reservations:read"),
hasPermission(payload, user, "operation-readiness:read"),
hasPermission(payload, user, "zone-pressure:read"),
payload.findGlobal({ slug: "game-rules" }),
]);
return { canSeeReservations: reservations, canSeeReadiness: readiness, canSeePressure: pressure };
const zonePressureEnabled = rules.zonePressureEnabled ?? true;
return {
canSeeReservations: reservations,
canSeeReadiness: readiness,
canSeePressure: pressure && zonePressureEnabled,
};
}
function relationId(value: unknown): number | null {

View file

@ -41,6 +41,7 @@ export interface AarResult {
effects: OperationEffectRow[];
counts: AarCounts;
pressure: { zoneId: number; factionId: number | null; delta: number; createdAt: string }[];
canSeePressure: boolean;
reservations: { reservationKey: string; status: string; expiresAt: string | null }[];
}
@ -195,11 +196,16 @@ export async function getOperationAar(
else if (status === "reversed") counts.reversed += 1;
}
const canSeePressure = await hasPermission(payload, user, "zone-pressure:read");
const canSeeReservations = await hasPermission(payload, user, "operation-reservations:read");
const [rules, canSeePressure, canSeeReservations] = await Promise.all([
payload.findGlobal({ slug: "game-rules" }),
hasPermission(payload, user, "zone-pressure:read"),
hasPermission(payload, user, "operation-reservations:read"),
]);
const zonePressureEnabled = rules.zonePressureEnabled ?? true;
const canSeePressureFlag = canSeePressure && zonePressureEnabled;
const pressure: AarResult["pressure"] = [];
if (canSeePressure) {
if (canSeePressureFlag) {
const rows = await payload.find({
collection: "zone-pressure",
where: { operationId: { equals: operationId } },
@ -254,6 +260,7 @@ export async function getOperationAar(
effects: effectsFound.docs.map(toEffectRow),
counts,
pressure,
canSeePressure: canSeePressureFlag,
reservations,
};
}

View file

@ -0,0 +1,15 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_rules" ADD COLUMN "zone_pressure_enabled" boolean DEFAULT true;
ALTER TABLE "game_rules" ADD COLUMN "statistics_enabled" boolean DEFAULT true;
ALTER TABLE "game_rules" ADD COLUMN "helpdesk_enabled" boolean DEFAULT true;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_rules" DROP COLUMN "zone_pressure_enabled";
ALTER TABLE "game_rules" DROP COLUMN "statistics_enabled";
ALTER TABLE "game_rules" DROP COLUMN "helpdesk_enabled";`)
}

View file

@ -82,6 +82,7 @@ import * as migration_20260921_123000_add_map_intel_fields from './20260921_1230
import * as migration_20260921_130500_weather_freeform from './20260921_130500_weather_freeform';
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';
export const migrations = [
{
@ -504,4 +505,9 @@ export const migrations = [
down: migration_20260921_140000_add_currency_symbol_image.down,
name: '20260921_140000_add_currency_symbol_image',
},
{
up: migration_20260921_150000_add_feature_flags.up,
down: migration_20260921_150000_add_feature_flags.down,
name: '20260921_150000_add_feature_flags',
},
];