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",
|
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() {
|
export default async function HelpdeskPage() {
|
||||||
const headers = await nextHeaders();
|
const headers = await nextHeaders();
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|
@ -25,6 +41,11 @@ export default async function HelpdeskPage() {
|
||||||
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
|
redirect(`/login?returnTo=${encodeURIComponent(pathname)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { helpdeskEnabled } = await payload.findGlobal({ slug: "game-rules" });
|
||||||
|
if (!helpdeskEnabled) {
|
||||||
|
return <HelpdeskDisabled />;
|
||||||
|
}
|
||||||
|
|
||||||
const ticketsRes = await payload.find({
|
const ticketsRes = await payload.find({
|
||||||
collection: "tickets",
|
collection: "tickets",
|
||||||
sort: "-updatedAt",
|
sort: "-updatedAt",
|
||||||
|
|
|
||||||
|
|
@ -80,46 +80,57 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
|
||||||
|
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
|
let statisticsEnabled = true;
|
||||||
|
let helpdeskEnabled = true;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
|
const [
|
||||||
await Promise.all([
|
intelligenceQual,
|
||||||
hasIntelligenceQualification(payload, user).catch(() => false),
|
logisticsQual,
|
||||||
hasLogisticsQualification(payload, user).catch(() => false),
|
profileRes,
|
||||||
payload.find({
|
experienceRes,
|
||||||
collection: "profiles",
|
personalAccountRes,
|
||||||
where: { user: { equals: user.id } },
|
gameRulesRes,
|
||||||
select: {
|
] = await Promise.all([
|
||||||
rank: true,
|
hasIntelligenceQualification(payload, user).catch(() => false),
|
||||||
progression: {
|
hasLogisticsQualification(payload, user).catch(() => false),
|
||||||
awards: true,
|
payload.find({
|
||||||
scoreboard: true,
|
collection: "profiles",
|
||||||
xp: true,
|
where: { user: { equals: user.id } },
|
||||||
minigames: true,
|
select: {
|
||||||
},
|
rank: true,
|
||||||
|
progression: {
|
||||||
|
awards: true,
|
||||||
|
scoreboard: true,
|
||||||
|
xp: true,
|
||||||
|
minigames: true,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
payload.find({
|
}),
|
||||||
collection: "experience",
|
payload.find({
|
||||||
sort: "requiredPoints",
|
collection: "experience",
|
||||||
limit: 100,
|
sort: "requiredPoints",
|
||||||
select: {
|
limit: 100,
|
||||||
name: true,
|
select: {
|
||||||
description: true,
|
name: true,
|
||||||
requiredPoints: true,
|
description: true,
|
||||||
},
|
requiredPoints: true,
|
||||||
}),
|
},
|
||||||
payload.find({
|
}),
|
||||||
collection: "bank-accounts",
|
payload.find({
|
||||||
where: {
|
collection: "bank-accounts",
|
||||||
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
|
where: {
|
||||||
},
|
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
|
||||||
limit: 1,
|
},
|
||||||
}),
|
limit: 1,
|
||||||
]);
|
}),
|
||||||
|
payload.findGlobal({ slug: "game-rules" }),
|
||||||
|
]);
|
||||||
isIntelligence = intelligenceQual;
|
isIntelligence = intelligenceQual;
|
||||||
isLogistics = logisticsQual;
|
isLogistics = logisticsQual;
|
||||||
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
|
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
|
||||||
|
statisticsEnabled = gameRulesRes.statisticsEnabled ?? true;
|
||||||
|
helpdeskEnabled = gameRulesRes.helpdeskEnabled ?? true;
|
||||||
|
|
||||||
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
||||||
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||||
|
|
@ -150,6 +161,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
|
adminUrl={isAdmin ? payloadConfig.routes.admin : undefined}
|
||||||
hasIntelligence={isIntelligence}
|
hasIntelligence={isIntelligence}
|
||||||
hasLogistics={isLogistics}
|
hasLogistics={isLogistics}
|
||||||
|
statisticsEnabled={statisticsEnabled}
|
||||||
|
helpdeskEnabled={helpdeskEnabled}
|
||||||
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
showCallsign={user.preferences?.display?.showCallsign ?? false}
|
||||||
canImpersonate={await isSuperuser(payload, user)}
|
canImpersonate={await isSuperuser(payload, user)}
|
||||||
preferences={user.preferences}
|
preferences={user.preferences}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,28 @@ function npcIdFromListing(listing: MarketListing): number | null {
|
||||||
return 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() {
|
export default async function StatisticsPage() {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
const { statisticsEnabled } = await payload.findGlobal({ slug: "game-rules" });
|
||||||
|
if (!statisticsEnabled) {
|
||||||
|
return <StatisticsDisabled />;
|
||||||
|
}
|
||||||
const [npcsRes, listingsRes] = await Promise.all([
|
const [npcsRes, listingsRes] = await Promise.all([
|
||||||
payload.find({
|
payload.find({
|
||||||
collection: "game-npcs",
|
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,
|
adminUrl,
|
||||||
hasIntelligence = true,
|
hasIntelligence = true,
|
||||||
hasLogistics = true,
|
hasLogistics = true,
|
||||||
|
statisticsEnabled = true,
|
||||||
|
helpdeskEnabled = true,
|
||||||
showCallsign = false,
|
showCallsign = false,
|
||||||
canImpersonate = false,
|
canImpersonate = false,
|
||||||
preferences,
|
preferences,
|
||||||
|
|
@ -86,6 +88,8 @@ export function AppSidebar({
|
||||||
adminUrl?: string;
|
adminUrl?: string;
|
||||||
hasIntelligence?: boolean;
|
hasIntelligence?: boolean;
|
||||||
hasLogistics?: boolean;
|
hasLogistics?: boolean;
|
||||||
|
statisticsEnabled?: boolean;
|
||||||
|
helpdeskEnabled?: boolean;
|
||||||
showCallsign?: boolean;
|
showCallsign?: boolean;
|
||||||
canImpersonate?: boolean;
|
canImpersonate?: boolean;
|
||||||
preferences?: NonNullable<User["preferences"]>;
|
preferences?: NonNullable<User["preferences"]>;
|
||||||
|
|
@ -119,6 +123,19 @@ export function AppSidebar({
|
||||||
}
|
}
|
||||||
}, [isMiniMode, preferences]);
|
}, [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 (
|
return (
|
||||||
<Sidebar variant="inset" {...props}>
|
<Sidebar variant="inset" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
|
|
@ -155,10 +172,10 @@ export function AppSidebar({
|
||||||
)}
|
)}
|
||||||
<NavCore label="Operations" items={data.navOperations} />
|
<NavCore label="Operations" items={data.navOperations} />
|
||||||
{hasLogistics && (
|
{hasLogistics && (
|
||||||
<NavCore label="Infrastructure" items={data.navLogistics} expandDefaultOpen={hasLogistics} />
|
<NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} />
|
||||||
)}
|
)}
|
||||||
<NavCore label="Chronicle" items={data.navStory} />
|
<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="Commerce" items={data.navCommerce} />
|
||||||
<NavCore label="Personal" items={data.navPersonal} />
|
<NavCore label="Personal" items={data.navPersonal} />
|
||||||
{/*<NavProjects projects={data.projects} />*/}
|
{/*<NavProjects projects={data.projects} />*/}
|
||||||
|
|
|
||||||
|
|
@ -114,24 +114,28 @@ export function AarView({ aar }: { aar: AarResult }) {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Zone pressure">
|
{aar.canSeePressure ? (
|
||||||
{aar.pressure.length === 0 ? (
|
<Section title="Zone pressure">
|
||||||
<NoticeState variant="empty" message="No pressure effects recorded for this operation." />
|
{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) => (
|
<ul className="flex flex-col gap-1">
|
||||||
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
|
{aar.pressure.map((p, i) => (
|
||||||
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
|
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
|
||||||
<span
|
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
|
||||||
className={`font-mono text-[10px] uppercase tracking-widest ${p.delta >= 0 ? "text-emerald-400" : "text-rose-400"}`}
|
<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>
|
{p.delta >= 0 ? `+${p.delta}` : p.delta}
|
||||||
</li>
|
</span>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</Section>
|
)}
|
||||||
|
</Section>
|
||||||
|
) : (
|
||||||
|
<NoticeState variant="empty" message="Zone pressure is currently disabled." />
|
||||||
|
)}
|
||||||
|
|
||||||
<Section title="Reservations">
|
<Section title="Reservations">
|
||||||
{aar.reservations.length === 0 ? (
|
{aar.reservations.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,18 @@ export async function getSurfaceCapabilities(
|
||||||
payload: PayloadType,
|
payload: PayloadType,
|
||||||
user: SurfaceUser,
|
user: SurfaceUser,
|
||||||
): Promise<SurfaceCapabilities> {
|
): 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-reservations:read"),
|
||||||
hasPermission(payload, user, "operation-readiness:read"),
|
hasPermission(payload, user, "operation-readiness:read"),
|
||||||
hasPermission(payload, user, "zone-pressure: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 {
|
function relationId(value: unknown): number | null {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface AarResult {
|
||||||
effects: OperationEffectRow[];
|
effects: OperationEffectRow[];
|
||||||
counts: AarCounts;
|
counts: AarCounts;
|
||||||
pressure: { zoneId: number; factionId: number | null; delta: number; createdAt: string }[];
|
pressure: { zoneId: number; factionId: number | null; delta: number; createdAt: string }[];
|
||||||
|
canSeePressure: boolean;
|
||||||
reservations: { reservationKey: string; status: string; expiresAt: string | null }[];
|
reservations: { reservationKey: string; status: string; expiresAt: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,11 +196,16 @@ export async function getOperationAar(
|
||||||
else if (status === "reversed") counts.reversed += 1;
|
else if (status === "reversed") counts.reversed += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSeePressure = await hasPermission(payload, user, "zone-pressure:read");
|
const [rules, canSeePressure, canSeeReservations] = await Promise.all([
|
||||||
const canSeeReservations = await hasPermission(payload, user, "operation-reservations:read");
|
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"] = [];
|
const pressure: AarResult["pressure"] = [];
|
||||||
if (canSeePressure) {
|
if (canSeePressureFlag) {
|
||||||
const rows = await payload.find({
|
const rows = await payload.find({
|
||||||
collection: "zone-pressure",
|
collection: "zone-pressure",
|
||||||
where: { operationId: { equals: operationId } },
|
where: { operationId: { equals: operationId } },
|
||||||
|
|
@ -254,6 +260,7 @@ export async function getOperationAar(
|
||||||
effects: effectsFound.docs.map(toEffectRow),
|
effects: effectsFound.docs.map(toEffectRow),
|
||||||
counts,
|
counts,
|
||||||
pressure,
|
pressure,
|
||||||
|
canSeePressure: canSeePressureFlag,
|
||||||
reservations,
|
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_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_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';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -504,4 +505,9 @@ export const migrations = [
|
||||||
down: migration_20260921_140000_add_currency_symbol_image.down,
|
down: migration_20260921_140000_add_currency_symbol_image.down,
|
||||||
name: '20260921_140000_add_currency_symbol_image',
|
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