feat(game): admin feature flags for zone pressure, statistics, and helpdesk
This commit is contained in:
parent
8213d76816
commit
ae4ed8402d
10 changed files with 194 additions and 59 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />*/}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
15
src/migrations/20260921_150000_add_feature_flags.ts
Normal file
15
src/migrations/20260921_150000_add_feature_flags.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue