1
0
Fork 0

Compare commits

..

No commits in common. "dcc308c2ae12a1097647930d952e558480b8e033" and "3af17928e8af8da5b6a63181f769cef8f433c301" have entirely different histories.

53 changed files with 474 additions and 24935 deletions

View file

@ -341,7 +341,6 @@ In development, `postgresAdapter` uses `push: false` — run `bun run payload mi
- **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons. - **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons.
- **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`). - **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`).
- **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging. - **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging.
- **No em-dashes in final outputs**: never emit em-dashes (`—`, `–`) in anything you produce as a final deliverable: chat replies, commit messages, PR descriptions, docs, comments, UI copy. Replace them with a comma, semicolon, colon, or parenthetical, or split into two sentences. (Earlier bullets in this section predate the rule; new writing must comply.)
## UI components ## UI components
@ -360,8 +359,6 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used. `bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history. **Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
## Gotchas ## Gotchas

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.8", "version": "0.2.6",
"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",

View file

@ -1,22 +0,0 @@
import type { Metadata } from "next";
import EventLogTable from "@/components/frontend/events/EventLogTable";
export const metadata: Metadata = {
title: "Event Log (Polaris Task Force)",
};
export default function EventLogPage() {
return (
<div className="flex flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Event Log</h1>
<p className="text-muted-foreground text-sm">
The full game event ledger: system actions and GM narrative events. Filter by type,
target, source, or date range.
</p>
</div>
<EventLogTable />
</div>
);
}

View file

@ -166,7 +166,7 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
/> />
</span> </span>
)} )}
{campaign.startDate && campaign.endDate && <span>-</span>} {campaign.startDate && campaign.endDate && <span>—</span>}
{campaign.endDate ? ( {campaign.endDate ? (
<span> <span>
<ClientDate <ClientDate
@ -267,14 +267,14 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
)} )}
{campaign.endDate ? ( {campaign.endDate ? (
<> <>
{" - "} {" — "}
<ClientDate <ClientDate
date={campaign.endDate} date={campaign.endDate}
options={{ year: "numeric", month: "short", day: "numeric" }} options={{ year: "numeric", month: "short", day: "numeric" }}
/> />
</> </>
) : ( ) : (
" - Present" " — Present"
)} )}
</span> </span>
</div> </div>

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { Campaign, Map, Media, Mission, User } from "@/payload-types"; import type { Campaign, Map, Media, Mission } from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
@ -13,7 +13,6 @@ import {
FlagIcon, FlagIcon,
MapPinIcon, MapPinIcon,
ServerIcon, ServerIcon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate"; import { ClientDate } from "@/components/frontend/ClientDate";
@ -95,13 +94,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
? (missionWithCoverImage.coverImage as Media) ? (missionWithCoverImage.coverImage as Media)
: null; : null;
const coverImageUrl = coverImage?.url ?? null; const coverImageUrl = coverImage?.url ?? null;
const host =
mission.operationType === "side" &&
typeof mission.createdBy === "object" &&
mission.createdBy !== null
? (mission.createdBy as User)
: null;
const hostLabel = host ? (host.displayName || host.username) : null;
const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMins = mission.classification.estimatedDuration % 60; const durationMins = mission.classification.estimatedDuration % 60;
@ -336,17 +328,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</span> </span>
</div> </div>
)} )}
{hostLabel && (
<div className="flex items-center gap-2 px-3 py-2">
<UserIcon className="size-4 text-white/60" />
<span className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Host
</span>
<span className="max-w-56 truncate text-sm text-white">{hostLabel}</span>
</span>
</div>
)}
{campaign && ( {campaign && (
<Link <Link
href={`/intelligence/campaigns/${campaign.id}`} href={`/intelligence/campaigns/${campaign.id}`}
@ -386,7 +367,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
</div> </div>
<div> <div>
<span className="text-muted-foreground">Max Players</span> <span className="text-muted-foreground">Max Players</span>
<p className="font-medium">{mission.missionRoles?.maxPlayers ?? "-"}</p> <p className="font-medium">{mission.missionRoles?.maxPlayers ?? "—"}</p>
</div> </div>
</div> </div>
</ItemContent> </ItemContent>

View file

@ -738,70 +738,6 @@ export async function cancelFridaySideOp(
} }
} }
export async function markSideOpReady(
missionId: number,
): Promise<ActionResult<{ missionId: number }>> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
const mission = await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
});
const canManage = await hasPermission(payload, user, "missions:update");
if (!canManage && !isSideOpOwner(mission, userId)) {
return {
success: false,
error: "Only the player who scheduled this side operation can mark it Ready.",
};
}
const status = mission.ownershipAndStatus.status;
if (status === "Ready") {
return { success: true, data: { missionId: mission.id } };
}
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
return {
success: false,
error:
status === "Active"
? "This mission is already underway and cannot be marked Ready."
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
};
}
const updated = await payload.update({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
data: {
ownershipAndStatus: {
status: "Ready",
},
},
});
await emitGameEvent(payload, {
type: EventTypes.MissionReady,
message: `${user.displayName || user.username} marked side operation "${updated.name}" (${updated.codeName}) as Ready. Details are locked in and the roll-call can be posted.`,
actor: userId,
targetCollection: "missions",
targetId: updated.id,
});
return { success: true, data: { missionId: updated.id } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function moveFridaySideOpToCampaign( export async function moveFridaySideOpToCampaign(
missionId: number, missionId: number,
campaignId: number, campaignId: number,

View file

@ -21,7 +21,6 @@ import { pluralize } from "@/lib/utils";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyConfigFromGameRules } from "@/lib/banking"; import { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BoxIcon, BoxIcon,
@ -184,8 +183,6 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
}; };
}); });
const canManage = await hasLogisticsQualification(payload, user); const canManage = await hasLogisticsQualification(payload, user);
const canManageStorage = await isSuperuser(payload, user);
const canTransferStorage = canManageStorage || canManage;
const classificationsRes = await payload.find({ const classificationsRes = await payload.find({
collection: "labor-classifications", collection: "labor-classifications",
@ -310,13 +307,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
{structure.faction.name} {structure.faction.name}
</span> </span>
)} )}
{user && ( {user && <ManageStorageButton structureId={structure.id} />}
<ManageStorageButton
structureId={structure.id}
canManage={canManageStorage}
canTransfer={canTransferStorage}
/>
)}
</div> </div>
</div> </div>
</header> </header>
@ -422,11 +413,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
currencyConfig={currencyConfig} currencyConfig={currencyConfig}
/> />
<StructureStorageView <StructureStorageView structure={structure} />
structure={structure}
canMutate={canManageStorage}
canArrange={canTransferStorage}
/>
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}

View file

@ -3,8 +3,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Resource, Structure } from "@/payload-types"; import { GameStructure, Resource, Structure } from "@/payload-types";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
@ -15,19 +14,6 @@ interface ActionResult {
data?: GameStructure; data?: GameStructure;
} }
type PayloadClient = Awaited<ReturnType<typeof getPayload>>;
type AuthedUser = Awaited<ReturnType<typeof authenticate>>["user"];
// SECURITY: creates/destroys stored items, overriding economy/production — superusers only
async function canManageStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
return isSuperuser(payload, user);
}
// SECURITY: conserves quantities (moves items around) — logistics personnel + superusers
async function canHandleStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
return (await isSuperuser(payload, user)) || (await hasLogisticsQualification(payload, user));
}
async function authenticate() { async function authenticate() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -88,30 +74,13 @@ export async function addResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canManageStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Only superusers may deposit resources into structures.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
return await addResourceInternal(payload, user, structureId, resourceId, amount);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
async function addResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
if (amount <= 0) { if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." }; return { success: false, error: "Amount must be greater than zero." };
} }
@ -295,30 +264,13 @@ export async function removeResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canManageStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Only superusers may withdraw resources from structures.", error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
}; };
} }
return await removeResourceInternal(payload, user, structureId, resourceId, amount);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
async function removeResourceInternal(
payload: PayloadClient,
user: AuthedUser,
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
if (amount <= 0) { if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." }; return { success: false, error: "Amount must be greater than zero." };
} }
@ -421,10 +373,10 @@ export async function transferResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Logistics personnel or superuser role required for transfers.", error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
}; };
} }
@ -512,23 +464,17 @@ export async function transferResource(
}; };
} }
// Remove from source (unguarded internal — gate already applied above) // Remove from source
const removeResult = await removeResourceInternal( const removeResult = await removeResource(fromStructureId, resourceId, amount);
payload,
user,
fromStructureId,
resourceId,
amount,
);
if (!removeResult.success) { if (!removeResult.success) {
return removeResult; return removeResult;
} }
// Add to destination // Add to destination
const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount); const addResult = await addResource(toStructureId, resourceId, amount);
if (!addResult.success) { if (!addResult.success) {
// Rollback: re-add to source // Rollback: re-add to source
await addResourceInternal(payload, user, fromStructureId, resourceId, amount); await addResource(fromStructureId, resourceId, amount);
return { success: false, error: `Transfer failed at destination: ${addResult.error}` }; return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
} }
@ -578,10 +524,10 @@ export async function placeResourceOnGrid(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canManageStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Only superusers may place resources on the storage grid.", error: "Insufficient permissions. Admin or Developer role required for placement.",
}; };
} }
@ -695,10 +641,10 @@ export async function moveGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Logistics personnel or superuser role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -784,10 +730,10 @@ export async function mergeGridStacks(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Logistics personnel or superuser role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -866,10 +812,10 @@ export async function rotateGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Logistics personnel or superuser role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -953,10 +899,10 @@ export async function removeGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canManageStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Only superusers may remove items from the storage grid.", error: "Insufficient permissions. Admin or Developer role required for removal.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -1007,10 +953,10 @@ export async function splitGridStack(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Logistics personnel or superuser role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
@ -1115,8 +1061,8 @@ export async function splitGridStack(
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> { export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { success: false, error: "Logistics personnel or superuser role required." }; return { success: false, error: "Insufficient permissions." };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0; const gridW = structure.type.xSize ?? 0;
@ -1213,10 +1159,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> { export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await canHandleStorage(payload, user))) { if (!(await hasPermission(payload, user, "structures:update"))) {
return { return {
success: false, success: false,
error: "Logistics personnel or superuser role required.", error: "Insufficient permissions. User, Admin, or Developer role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);

View file

@ -263,7 +263,7 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
<ItemContent> <ItemContent>
<ItemTitle>Grid Dimensions</ItemTitle> <ItemTitle>Grid Dimensions</ItemTitle>
<ItemDescription> <ItemDescription>
{vehicle.width ?? "-"}×{vehicle.height ?? "-"} {vehicle.width ?? "—"}×{vehicle.height ?? "—"}
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -307,7 +307,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const airKills = scoreboard.airKills ?? 0; const airKills = scoreboard.airKills ?? 0;
const deaths = scoreboard.deaths ?? 0; const deaths = scoreboard.deaths ?? 0;
const totalKills = infantryKills + vehicleKills + armorKills + airKills; const totalKills = infantryKills + vehicleKills + armorKills + airKills;
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "-"; const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
const enlistDate = new Date(profile.dossier.enlistmentDate); const enlistDate = new Date(profile.dossier.enlistmentDate);
const now = new Date(); const now = new Date();
@ -562,7 +562,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
Deaths Deaths
</td> </td>
<td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td> <td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-mono text-muted-foreground">-</td> <td className="px-4 py-2 text-right font-mono text-muted-foreground">—</td>
</tr> </tr>
<tr className="bg-muted/30"> <tr className="bg-muted/30">
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold"> <td className="px-4 py-2.5 flex items-center gap-2 font-semibold">

View file

@ -8,7 +8,6 @@ export interface BotConfig {
attendancePollMs: number; attendancePollMs: number;
notificationPollMs: number; notificationPollMs: number;
evaluationPollMs: number; evaluationPollMs: number;
readyReminderPollMs: number;
} }
const required = (name: string): string => { const required = (name: string): string => {
@ -49,5 +48,4 @@ export const botConfig: BotConfig = {
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000), attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000), notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000), evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
}; };

View file

@ -63,7 +63,7 @@ const getReminderMissions = async (payload: Payload): Promise<Mission[]> => {
const formatLinks = (links: ReminderLink[], appUrl: string): string => { const formatLinks = (links: ReminderLink[], appUrl: string): string => {
const lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`); const lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`);
// Discord embed field values cap at 1024 chars. // Discord embed field values cap at 1024 chars.
return lines.join("\n").slice(0, 1024) || "-"; return lines.join("\n").slice(0, 1024) || "—";
}; };
const renderReminderEmbed = ( const renderReminderEmbed = (

View file

@ -4,7 +4,6 @@ import { start as startMissionEmbeds } from "./missionEmbeds";
import { start as startNotificationBridge } from "./notificationBridge"; import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests"; import { start as startTransferRequests } from "./transferRequests";
import { start as startEvaluationReminders } from "./evaluationReminders"; import { start as startEvaluationReminders } from "./evaluationReminders";
import { start as startReadyReminders } from "./readyReminders";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void; export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
@ -13,5 +12,4 @@ export const services: ServiceStarter[] = [
startNotificationBridge, startNotificationBridge,
startTransferRequests, startTransferRequests,
startEvaluationReminders, startEvaluationReminders,
startReadyReminders,
]; ];

View file

@ -147,7 +147,7 @@ export const renderAttendanceEmbed = (
summary: MissionAttendanceSummary, summary: MissionAttendanceSummary,
): EmbedBuilder => { ): EmbedBuilder => {
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000); const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-"; const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "—";
return new EmbedBuilder() return new EmbedBuilder()
.setTitle(missionTitle(mission)) .setTitle(missionTitle(mission))
.setDescription( .setDescription(

View file

@ -3,19 +3,14 @@ import type { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types"; import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes"; import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
import { READY_REMINDER_NOTIFICATION_TYPE } from "@/lib/intelligence/readyReminders";
const MAX_SENDS_PER_TICK = 5; const MAX_SENDS_PER_TICK = 5;
const SEND_DELAY_MS = 300; const SEND_DELAY_MS = 300;
const EMBED_COLOR = 0x3b82f6; const EMBED_COLOR = 0x3b82f6;
// Transfer notifications are delivered by the dedicated transfer bot service // Transfer notifications are delivered by the dedicated transfer bot service
// (Phase 2), not this generic bridge. Ready reminders are delivered by the // (Phase 2), not this generic bridge. Exclude exactly these types.
// dedicated readyReminders DM service. Exclude exactly these types. const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES));
const NON_BRIDGED_TYPES = new Set<string>([
...Object.values(TRANSFER_NOTIFICATION_TYPES),
READY_REMINDER_NOTIFICATION_TYPE,
]);
let cursor: number | null = null; let cursor: number | null = null;

View file

@ -1,130 +0,0 @@
import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { Mission, User } from "@/payload-types";
import { botConfig } from "@/bot/config";
import {
READY_REMINDER_LEAD_DAYS,
daysUntil,
getReadyReminderMissions,
isReminderDue,
missionTitle,
} from "@/lib/intelligence/readyReminders";
/**
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
* them to finish the op and mark it "Ready" — the status that posts the
* roll-call, locks in details, and delivers the mission hooks.
*
* The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
* web UI, so honoring it here would silently DM nobody. This is a dedicated
* creator-prompt flow, not a forwarding of the generic notification stream
* (the web half of each reminder is excluded from the bridge via
* NON_BRIDGED_TYPES). A per-day marker on the mission (readyReminderDmSentAt)
* keeps the cadence to one DM per day.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 10;
const SEND_DELAY_MS = 300;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const creatorIdOf = (mission: Mission): number | null =>
typeof mission.createdBy === "object" ? (mission.createdBy?.id ?? null) : (mission.createdBy ?? null);
const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
const days = daysUntil(mission.classification.startDateTime, now);
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
return new EmbedBuilder()
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
.setDescription(
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
)
.setColor(EMBED_COLOR)
.addFields({
name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
})
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
};
const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
now: Date,
): Promise<boolean> => {
const creatorId = creatorIdOf(mission);
if (!creatorId) return false;
const creator = (await payload.findByID({
collection: "users",
id: creatorId,
depth: 0,
overrideAccess: true,
})) as unknown as User;
if (!creator.discordId) return false;
const target = await client.users.fetch(creator.discordId);
await target.send({ embeds: [renderReminderEmbed(mission, now)] });
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderDmSentAt: now.toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Sent ready reminder DM for mission #${mission.id} (${mission.name}) to user #${creatorId}.`,
);
return true;
};
export interface ReconcileSummary {
processed: number;
sent: number;
}
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
if (reconcileRunning) return { processed: 0, sent: 0 };
reconcileRunning = true;
let processed = 0;
let sent = 0;
try {
const now = new Date();
const missions = (await getReadyReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderDmSentAt, now)) continue;
processed += 1;
try {
if (await processMission(client, payload, mission, now)) {
sent += 1;
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
}
} catch (error) {
payload.logger.error(
`[Bot] Ready reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Ready reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Ready reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.readyReminderPollMs);
};

View file

@ -87,7 +87,7 @@ export const Shims: GlobalConfig = {
admin: { admin: {
condition: (_data, siblingData) => siblingData?.audience === "percentage", condition: (_data, siblingData) => siblingData?.audience === "percentage",
description: description:
"Percentage of users (0-100) who receive this shim. Assignment is deterministic per user.", "Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.",
}, },
}, },
{ {

View file

@ -216,24 +216,6 @@ export const Missions: CollectionConfig = {
hidden: true, hidden: true,
}, },
}, },
{
name: "readyReminderWebSentAt",
type: "date",
admin: {
description:
"Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
hidden: true,
},
},
{
name: "readyReminderDmSentAt",
type: "date",
admin: {
description:
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
hidden: true,
},
},
{ {
name: "createdBy", name: "createdBy",
type: "relationship", type: "relationship",

View file

@ -83,7 +83,7 @@ export const MarketNegotiations: CollectionConfig = {
max: 100, max: 100,
admin: { admin: {
description: description:
"NPC vendor patience (0-100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.", "NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.",
}, },
}, },
{ {

View file

@ -45,7 +45,7 @@ function resolveAccountLabel(account: (number | null) | BankAccount | undefined)
label: string; label: string;
id: number | null; id: number | null;
} { } {
if (!account) return { label: "-", id: null }; if (!account) return { label: "—", id: null };
if (typeof account === "object") { if (typeof account === "object") {
return { label: account.name ?? `#${account.id}`, id: account.id }; return { label: account.name ?? `#${account.id}`, id: account.id };
} }

View file

@ -9,7 +9,6 @@ import {
Users, Users,
Maximize, Maximize,
Minimize, Minimize,
ScrollText,
SparkleIcon, SparkleIcon,
} from "lucide-react"; } from "lucide-react";
@ -57,11 +56,6 @@ const data = {
url: "/intelligence/missions/calendar", url: "/intelligence/missions/calendar",
icon: CalendarDays, icon: CalendarDays,
}, },
{
title: "Event Log",
url: "/events",
icon: ScrollText,
},
], ],
...sidebarData, ...sidebarData,
}; };
@ -157,6 +151,7 @@ export function AppSidebar({
)} )}
<NavCore label="Helpdesk" items={data.navHelpdesk} /> <NavCore label="Helpdesk" items={data.navHelpdesk} />
<NavCore label="Commerce" items={data.navCommerce} /> <NavCore label="Commerce" items={data.navCommerce} />
<NavCore label="Personnel" items={data.navPersonnel} />
<NavCore label="Personal" items={data.navPersonal} /> <NavCore label="Personal" items={data.navPersonal} />
{/*<NavProjects projects={data.projects} />*/} {/*<NavProjects projects={data.projects} />*/}
</div> </div>

View file

@ -19,7 +19,6 @@ import {
TrendingUp, TrendingUp,
Truck, Truck,
UserCog, UserCog,
Users,
} from "lucide-react"; } from "lucide-react";
export const sidebarData = { export const sidebarData = {
@ -63,11 +62,6 @@ export const sidebarData = {
}, },
], ],
navLogistics: [ navLogistics: [
{
title: "Assets",
url: "",
icon: Boxes,
items: [
{ {
title: "Structures", title: "Structures",
url: "/logistics", url: "/logistics",
@ -83,13 +77,6 @@ export const sidebarData = {
url: "/logistics/vehicles", url: "/logistics/vehicles",
icon: Truck, icon: Truck,
}, },
],
},
{
title: "Operations",
url: "",
icon: Package,
items: [
{ {
title: "Shipments", title: "Shipments",
url: "/logistics/shipments", url: "/logistics/shipments",
@ -101,25 +88,6 @@ export const sidebarData = {
icon: Car, icon: Car,
}, },
], ],
},
{
title: "Personnel",
url: "",
icon: Users,
items: [
{
title: "Contacts",
url: "/personnel/contacts",
icon: Contact,
},
{
title: "Statistics",
url: "/personnel/statistics",
icon: BarChart3,
},
],
},
],
navCommerce: [ navCommerce: [
{ {
title: "Banking", title: "Banking",
@ -132,6 +100,18 @@ export const sidebarData = {
icon: ShoppingBag, icon: ShoppingBag,
}, },
], ],
navPersonnel: [
{
title: "Contacts",
url: "/personnel/contacts",
icon: Contact,
},
{
title: "Statistics",
url: "/personnel/statistics",
icon: BarChart3,
},
],
navPersonal: [ navPersonal: [
{ {
title: "Awards", title: "Awards",

View file

@ -224,7 +224,7 @@ export function EodLeaderboard({
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(index)}> <span className={rankClass(index)}>
{row.isOutsideTopTen ? "-" : index + 1} {row.isOutsideTopTen ? "—" : index + 1}
</span> </span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">

View file

@ -1,485 +0,0 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { formatType } from "@/utils/event-log/formatType";
import { cn } from "@/lib/utils";
// Local label map mirroring GameEventLogs TARGET_COLLECTIONS (the collection
// file imports Payload server-side, so it cannot be imported from a client
// component).
const TARGET_COLLECTION_LABELS: Record<string, string> = {
"game-structures": "Game Structures",
"game-vehicles": "Game Vehicles",
"game-npcs": "Game NPCs",
"npc-staffing": "NPC Staffing",
structures: "Structures",
resources: "Resources",
assets: "Assets",
vehicles: "Vehicles",
factions: "Factions",
missions: "Missions",
campaigns: "Campaigns",
users: "Users",
technologies: "Technologies",
maps: "Maps",
shipments: "Shipments",
"bank-accounts": "Bank Accounts",
"bank-transactions": "Bank Transactions",
"ledger-entries": "Ledger Entries",
"locker-storages": "Locker Storages",
loadouts: "Loadouts",
"market-listings": "Market Listings",
"market-negotiations": "Market Negotiations",
"market-state": "Market State",
evaluations: "Evaluations",
tickets: "Tickets",
"wiki-pages": "Wiki Pages",
"wiki-revisions": "Wiki Revisions",
"wiki-templates": "Wiki Templates",
"game-servers": "Game Servers",
"assignment-transfers": "Assignment Transfers",
"voice-scripts": "Voice Scripts",
"voice-submissions": "Voice Submissions",
};
const TARGET_COLLECTION_OPTIONS = Object.entries(TARGET_COLLECTION_LABELS).sort((a, b) =>
a[1].localeCompare(b[1]),
);
const PAGE_SIZES = [25, 50, 100];
type SourceFilter = "all" | "system" | "narrative";
type SortDir = "desc" | "asc";
interface EventRow {
id: number;
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: number | null | { displayName?: string | null; username?: string };
structure?: number | null | { name?: string | null };
targetCollection?: string | null;
targetId?: number | null;
}
interface EventLogResponse {
docs: EventRow[];
totalDocs: number;
page?: number;
totalPages?: number;
}
/** Group event type values by their prefix (e.g. "market" from "market:sale"). */
function eventTypeGroups(): [string, string[]][] {
const groups = new Map<string, string[]>();
for (const value of Object.values(EventTypes)) {
const prefix = value.includes(":") ? value.split(":")[0] : "other";
const list = groups.get(prefix);
if (list) {
list.push(value);
} else {
groups.set(prefix, [value]);
}
}
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
const TYPE_GROUPS = eventTypeGroups();
function actorLabel(actor: EventRow["actor"]): string {
if (actor == null || typeof actor === "number") return "System";
return actor.displayName || actor.username || "Unknown";
}
function structureLabel(structure: EventRow["structure"]): string {
if (structure == null) return "-";
if (typeof structure === "number") return `#${structure}`;
return structure.name || "Unknown structure";
}
function formatTimestamp(ts: string): string {
const date = new Date(ts);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function EventLogTable() {
const [searchInput, setSearchInput] = React.useState("");
const [search, setSearch] = React.useState("");
const [selectedTypes, setSelectedTypes] = React.useState<string[]>([]);
const [targetCollection, setTargetCollection] = React.useState<string>("all");
const [source, setSource] = React.useState<SourceFilter>("all");
const [dateFrom, setDateFrom] = React.useState("");
const [dateTo, setDateTo] = React.useState("");
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(50);
const [rows, setRows] = React.useState<EventRow[]>([]);
const [totalDocs, setTotalDocs] = React.useState(0);
const [totalPages, setTotalPages] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// Debounce the search box.
React.useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(t);
}, [searchInput]);
// Any filter change resets to page 1.
React.useEffect(() => {
setPage(1);
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, pageSize]);
const hasActiveFilters =
search !== "" ||
selectedTypes.length > 0 ||
targetCollection !== "all" ||
source !== "all" ||
dateFrom !== "" ||
dateTo !== "";
const resetFilters = React.useCallback(() => {
setSearchInput("");
setSelectedTypes([]);
setTargetCollection("all");
setSource("all");
setDateFrom("");
setDateTo("");
}, []);
React.useEffect(() => {
const controller = new AbortController();
const conditions: Record<string, unknown>[] = [];
if (search) {
conditions.push({ or: [{ message: { like: search } }, { type: { like: search } }] });
}
if (selectedTypes.length > 0) {
conditions.push({ type: { in: selectedTypes } });
}
if (targetCollection !== "all") {
conditions.push({ targetCollection: { equals: targetCollection } });
}
if (source !== "all") {
conditions.push({ system: { equals: source === "system" } });
}
if (dateFrom) {
conditions.push({ timestamp: { greater_than_equal: `${dateFrom}T00:00:00.000Z` } });
}
if (dateTo) {
conditions.push({ timestamp: { less_than_equal: `${dateTo}T23:59:59.999Z` } });
}
const params = new URLSearchParams({
depth: "1",
sort: `${sortDir === "desc" ? "-" : ""}timestamp`,
limit: String(pageSize),
page: String(page),
});
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : { and: conditions };
params.set("where", JSON.stringify(where));
}
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/game-event-logs?${params.toString()}`, {
signal: controller.signal,
credentials: "include",
});
if (!res.ok) {
throw new Error(`Request failed (${res.status})`);
}
const data = (await res.json()) as EventLogResponse;
setRows(data.docs ?? []);
setTotalDocs(data.totalDocs ?? 0);
setTotalPages(Math.max(1, data.totalPages ?? 1));
} catch (err) {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load events");
setRows([]);
setTotalDocs(0);
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
void load();
return () => controller.abort();
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, page, pageSize]);
const toggleType = (value: string) => {
setSelectedTypes((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
);
};
const from = totalDocs === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, totalDocs);
return (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full max-w-xs">
<Search className="text-muted-foreground absolute top-1/2 left-2 size-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search message or type…"
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Type
{selectedTypes.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{selectedTypes.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-96 w-64 overflow-y-auto" align="start">
{TYPE_GROUPS.map(([prefix, types], groupIdx) => (
<React.Fragment key={prefix}>
{groupIdx > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-xs uppercase text-muted-foreground">
{prefix}
</DropdownMenuLabel>
{types.map((value) => (
<DropdownMenuCheckboxItem
key={value}
checked={selectedTypes.includes(value)}
onCheckedChange={() => toggleType(value)}
onSelect={(e) => e.preventDefault()}
>
{formatType(value)}
</DropdownMenuCheckboxItem>
))}
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
<Select value={targetCollection} onValueChange={setTargetCollection}>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="all">All targets</SelectItem>
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="h-8 w-36">
<SelectValue placeholder="Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All sources</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="narrative">Narrative</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
aria-label="From date"
/>
<span className="text-muted-foreground text-xs">-</span>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
aria-label="To date"
/>
</div>
<Select value={sortDir} onValueChange={(v) => setSortDir(v as SortDir)}>
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Newest first</SelectItem>
<SelectItem value="asc">Oldest first</SelectItem>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={resetFilters}>
<RotateCcw className="size-4" />
Reset
</Button>
)}
<span className="text-muted-foreground ml-auto text-xs">
{totalDocs.toLocaleString()} event{totalDocs === 1 ? "" : "s"}
</span>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Time</TableHead>
<TableHead className="w-44">Type</TableHead>
<TableHead>Message</TableHead>
<TableHead className="w-32">Actor</TableHead>
<TableHead className="w-44">Structure</TableHead>
<TableHead className="w-40">Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
Loading events…
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={6} className="text-destructive py-8 text-center">
{error}
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
No events match the current filters.
</TableCell>
</TableRow>
) : (
rows.map((row) => {
const narrative = row.system === false;
return (
<TableRow
key={row.id}
className={cn(narrative && "border-l-2 border-l-amber-500/70")}
>
<TableCell className="text-muted-foreground whitespace-nowrap text-xs">
{formatTimestamp(row.timestamp)}
</TableCell>
<TableCell>
<Badge variant="outline" className="max-w-40 truncate font-normal">
{formatType(row.type)}
</Badge>
</TableCell>
<TableCell className="max-w-md whitespace-normal">{row.message}</TableCell>
<TableCell className="text-xs">{actorLabel(row.actor)}</TableCell>
<TableCell className="text-xs">{structureLabel(row.structure)}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{row.targetCollection
? `${TARGET_COLLECTION_LABELS[row.targetCollection] ?? row.targetCollection}${
row.targetId != null ? ` #${row.targetId}` : ""
}`
: "-"}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
</span>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => setPageSize(Number(v))}
>
<SelectTrigger className="h-8 w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Prev
</Button>
<span className="text-muted-foreground text-xs">
Page {page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -159,7 +159,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
title={row.isOutsideTopTen ? "Pilot status: not currently ranked in the top 10" : undefined} title={row.isOutsideTopTen ? "Pilot status: not currently ranked in the top 10" : undefined}
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(i)}>{row.isOutsideTopTen ? "-" : i + 1}</span> <span className={rankClass(i)}>{row.isOutsideTopTen ? "—" : i + 1}</span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View file

@ -58,7 +58,7 @@ function formatDate(iso: string | null | undefined): string {
? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(iso), new Date(iso),
) )
: "-"; : "—";
} }
function isTicketStatus(value: string): value is TicketStatus { function isTicketStatus(value: string): value is TicketStatus {

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Map, Media, User } from "@/payload-types"; import type { Campaign, Map, Media } from "@/payload-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
operationTypeColors, operationTypeColors,
@ -22,7 +22,6 @@ import {
ClockIcon, ClockIcon,
CrosshairIcon, CrosshairIcon,
MapPinIcon, MapPinIcon,
UserIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
@ -53,7 +52,6 @@ export interface MissionCardProps {
readonly missionRoles?: { readonly missionRoles?: {
readonly maxPlayers: number; readonly maxPlayers: number;
} | null; } | null;
readonly createdBy?: number | User | null;
readonly coverImage?: number | Media | null; readonly coverImage?: number | Media | null;
}; };
readonly variant?: "default" | "image"; readonly variant?: "default" | "image";
@ -79,12 +77,6 @@ export function MissionCard({
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
? mission.classification.campaign ? mission.classification.campaign
: null; : null;
const createdByUser =
typeof mission.createdBy === "object" && mission.createdBy !== null
? mission.createdBy
: null;
const hostLabel = createdByUser ? (createdByUser.displayName || createdByUser.username) : null;
const showHost = mission.operationType === "side" && hostLabel !== null;
const startDate = new Date(mission.classification.startDateTime); const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", { const dateStr = startDate.toLocaleDateString("en-US", {
@ -130,7 +122,7 @@ export function MissionCard({
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<CrosshairIcon /> <CrosshairIcon />
</ItemMedia> </ItemMedia>
<ItemTitle className="flex flex-col items-start text-start"> <ItemTitle className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span> <span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
<span>{mission.name}</span> <span>{mission.name}</span>
</ItemTitle> </ItemTitle>
@ -139,7 +131,7 @@ export function MissionCard({
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`} className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
> >
{operationTypeLabels[mission.operationType] ?? mission.operationType} Operation {operationTypeLabels[mission.operationType] ?? mission.operationType}
</span> </span>
<span <span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`} className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
@ -175,12 +167,6 @@ export function MissionCard({
{mission.missionRoles.maxPlayers} slots {mission.missionRoles.maxPlayers} slots
</span> </span>
)} )}
{showHost && (
<span className="flex items-center gap-1">
<UserIcon className="size-3" />
Hosted by {hostLabel}
</span>
)}
{campaign && <span className="text-primary">{campaign.name}</span>} {campaign && <span className="text-primary">{campaign.name}</span>}
</div> </div>
</ItemContent> </ItemContent>

View file

@ -97,7 +97,7 @@ export function MissionComms({ commsAndDocs }: MissionCommsProps) {
{frequencies!.map((freq, index) => ( {frequencies!.map((freq, index) => (
<tr key={index} className="border-b border-border/50"> <tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-mono font-medium">{freq.frequency}</td> <td className="px-4 py-2 font-mono font-medium">{freq.frequency}</td>
<td className="px-4 py-2 text-muted-foreground">{freq.channel ?? "-"}</td> <td className="px-4 py-2 text-muted-foreground">{freq.channel ?? "—"}</td>
<td className="px-4 py-2 font-medium">{freq.netName}</td> <td className="px-4 py-2 font-medium">{freq.netName}</td>
<td className="px-4 py-2 text-muted-foreground">{freq.freqUsage}</td> <td className="px-4 py-2 text-muted-foreground">{freq.freqUsage}</td>
</tr> </tr>

View file

@ -112,7 +112,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
<tr key={index} className="border-b border-border/50"> <tr key={index} className="border-b border-border/50">
<td className="px-4 py-2 font-medium">{asset.assetType}</td> <td className="px-4 py-2 font-medium">{asset.assetType}</td>
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{asset.assetCount ?? "-"} {asset.assetCount ?? "—"}
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{asset.doesRespawn ? ( {asset.doesRespawn ? (
@ -122,7 +122,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
)} )}
</td> </td>
<td className="px-4 py-2 text-muted-foreground font-mono text-xs"> <td className="px-4 py-2 text-muted-foreground font-mono text-xs">
{asset.assetCallsign ?? "-"} {asset.assetCallsign ?? "—"}
</td> </td>
</tr> </tr>
))} ))}

View file

@ -41,10 +41,10 @@ function ObjectiveText({
className?: string; className?: string;
}) { }) {
if (!redacted) { if (!redacted) {
return <span className={className}>{text ?? "-"}</span>; return <span className={className}>{text ?? "—"}</span>;
} }
const display = redactText(text) ?? "-"; const display = redactText(text) ?? "—";
if (!canReveal) { if (!canReveal) {
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>; return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
@ -57,7 +57,7 @@ function ObjectiveText({
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span> <span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words"> <TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{text ?? "-"} {text ?? "—"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View file

@ -84,14 +84,14 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{role.requiredMos && role.requiredMos.length > 0 {role.requiredMos && role.requiredMos.length > 0
? resolveNames(role.requiredMos) ? resolveNames(role.requiredMos)
: "-"} : "—"}
</td> </td>
<td className="px-4 py-2 text-muted-foreground"> <td className="px-4 py-2 text-muted-foreground">
{typeof role.minimumRank === "object" && role.minimumRank !== null {typeof role.minimumRank === "object" && role.minimumRank !== null
? role.minimumRank.name ? role.minimumRank.name
: typeof role.minimumRank === "number" : typeof role.minimumRank === "number"
? `#${role.minimumRank}` ? `#${role.minimumRank}`
: "-"} : "—"}
</td> </td>
</tr> </tr>
))} ))}

View file

@ -3,13 +3,10 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { CheckCircleIcon, PencilIcon, XCircleIcon } from "lucide-react"; import { PencilIcon, XCircleIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
cancelFridaySideOp,
markSideOpReady,
} from "@/app/(frontend)/intelligence/missions/actions";
import { import {
CreateSideOpDialog, CreateSideOpDialog,
type SideOpDialogInitial, type SideOpDialogInitial,
@ -37,19 +34,6 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isTerminal = status === "Completed" || status === "Cancelled"; const isTerminal = status === "Completed" || status === "Cancelled";
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status);
function handleMarkReady() {
startTransition(async () => {
const result = await markSideOpReady(initial.missionId);
if (!result.success) {
toast.error(result.error ?? "Failed to mark the side operation as Ready.");
return;
}
toast.success("Side operation marked as Ready. Roll-call can now be posted.");
router.refresh();
});
}
function handleCancel() { function handleCancel() {
if (!confirmingCancel) { if (!confirmingCancel) {
@ -80,12 +64,6 @@ export function SideOpControls({
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canMarkReady && (
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
<CheckCircleIcon className="size-3.5" />
Mark as Ready
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}> <Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
Edit Edit
@ -101,9 +79,8 @@ export function SideOpControls({
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Marking the operation Ready locks in the details so the Discord roll-call can be posted Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
and the mission hooks delivered. Editing covers details, campaign, and narrative. and updates the Discord embed.
Cancelling frees your scheduling slot and updates the Discord embed.
</p> </p>
</ItemContent> </ItemContent>

View file

@ -119,7 +119,7 @@ export function LockerGridToolbar({
<div> <div>
<dt className="text-muted-foreground/70">FOOTPRINT</dt> <dt className="text-muted-foreground/70">FOOTPRINT</dt>
<dd className="font-mono tabular-nums text-foreground/90"> <dd className="font-mono tabular-nums text-foreground/90">
{fp ? `${fp.width}×${fp.height}` : "-"} {fp ? `${fp.width}×${fp.height}` : "—"}
</dd> </dd>
</div> </div>
<div> <div>

View file

@ -185,7 +185,7 @@ export function SprintsView({ project, sprints, isAdmin }: SprintsViewProps) {
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground"> <ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CalendarIcon className="size-3" /> <CalendarIcon className="size-3" />
{dateFor(sprint.startDate)} - {dateFor(sprint.endDate)} {dateFor(sprint.startDate)} — {dateFor(sprint.endDate)}
</span> </span>
</ItemFooter> </ItemFooter>
</Item> </Item>

View file

@ -178,7 +178,7 @@ export function AimTrainerLeaderboard({
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(index)}> <span className={rankClass(index)}>
{row.isOutsideTopTen ? "-" : index + 1} {row.isOutsideTopTen ? "—" : index + 1}
</span> </span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">

View file

@ -288,7 +288,7 @@ export function RadioLeaderboard({
} }
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
<span className={rankClass(index)}>{row.isOutsideTopTen ? "-" : index + 1}</span> <span className={rankClass(index)}>{row.isOutsideTopTen ? "—" : index + 1}</span>
</td> </td>
<td className="px-2 py-2 font-medium"> <td className="px-2 py-2 font-medium">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -318,7 +318,7 @@ export function RadioLeaderboard({
</td> </td>
{mode === "sprint" && ( {mode === "sprint" && (
<td className="px-2 py-2 text-right tabular-nums"> <td className="px-2 py-2 text-right tabular-nums">
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "-"} {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
</td> </td>
)} )}
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground"> <td className="px-4 py-2 text-right tabular-nums text-muted-foreground">

View file

@ -620,7 +620,7 @@ export function RadioTraffic({
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div> <div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
<p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p> <p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p>
</form> </form>
<div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "-" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div> <div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div>
{feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>} {feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>}
</div>} </div>}

View file

@ -541,7 +541,7 @@ export function RosterView({
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="alpha" className="gap-1.5"> <TabsTrigger value="alpha" className="gap-1.5">
<ListIcon className="size-3" /> <ListIcon className="size-3" />
A-Z A–Z
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="seniority" className="gap-1.5"> <TabsTrigger value="seniority" className="gap-1.5">
<CalendarDaysIcon className="size-3" /> <CalendarDaysIcon className="size-3" />

View file

@ -24,10 +24,6 @@ interface GridToolbarProps {
onRotate: (entryId: string) => Promise<void>; onRotate: (entryId: string) => Promise<void>;
onRemove: (entryId: string) => Promise<void>; onRemove: (entryId: string) => Promise<void>;
onSplit: (entryId: string, amount: number) => Promise<void>; onSplit: (entryId: string, amount: number) => Promise<void>;
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
canArrange?: boolean;
/** Removing grid items destroys stored stock — superuser only. */
canMutate?: boolean;
} }
export function GridToolbar({ export function GridToolbar({
@ -41,8 +37,6 @@ export function GridToolbar({
onRotate, onRotate,
onRemove, onRemove,
onSplit, onSplit,
canArrange = false,
canMutate = false,
}: GridToolbarProps) { }: GridToolbarProps) {
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight); const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
const res = selectedItem ? (selectedItem.resource as Resource) : null; const res = selectedItem ? (selectedItem.resource as Resource) : null;
@ -56,12 +50,10 @@ export function GridToolbar({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canArrange && (
<Button variant="outline" size="sm" onClick={onAutoArrange}> <Button variant="outline" size="sm" onClick={onAutoArrange}>
<ShuffleIcon className="size-3.5 mr-1" /> <ShuffleIcon className="size-3.5 mr-1" />
Auto-Arrange Auto-Arrange
</Button> </Button>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -94,12 +86,12 @@ export function GridToolbar({
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{canArrange && res.rotatable !== false && ( {res.rotatable !== false && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}> <Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" /> <RotateCwIcon className="size-3.5" />
</Button> </Button>
)} )}
{canArrange && res.stackable && selectedItem.amount > 1 && ( {res.stackable && selectedItem.amount > 1 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -109,11 +101,9 @@ export function GridToolbar({
<ScissorsIcon className="size-3.5" /> <ScissorsIcon className="size-3.5" />
</Button> </Button>
)} )}
{canMutate && (
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}> <Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
<TrashIcon className="size-3.5" /> <TrashIcon className="size-3.5" />
</Button> </Button>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -7,32 +7,18 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage
interface ManageStorageButtonProps { interface ManageStorageButtonProps {
structureId: number; structureId: number;
canManage: boolean;
canTransfer: boolean;
} }
export function ManageStorageButton({ export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
structureId,
canManage,
canTransfer,
}: ManageStorageButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!canManage && !canTransfer) return null;
return ( return (
<> <>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}> <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<SettingsIcon className="size-3.5 mr-1.5" /> <SettingsIcon className="size-3.5 mr-1.5" />
Manage Manage
</Button> </Button>
<ManageStorageDialog <ManageStorageDialog structureId={structureId} open={open} onOpenChange={setOpen} />
structureId={structureId}
open={open}
onOpenChange={setOpen}
canManage={canManage}
canTransfer={canTransfer}
/>
</> </>
); );
} }

View file

@ -46,17 +46,9 @@ interface ManageStorageDialogProps {
structureId: number; structureId: number;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
canManage: boolean;
canTransfer: boolean;
} }
export function ManageStorageDialog({ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) {
structureId,
open,
onOpenChange,
canManage,
canTransfer,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null); const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]); const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
const [allResources, setAllResources] = useState<Resource[]>([]); const [allResources, setAllResources] = useState<Resource[]>([]);
@ -649,38 +641,22 @@ export function ManageStorageDialog({
</div> </div>
</div> </div>
)} )}
<Tabs defaultValue={canManage ? "deposit" : "transfer"} className="w-full"> <Tabs defaultValue="deposit" className="w-full">
<TabsList <TabsList className="grid w-full grid-cols-3">
className={cn(
"grid w-full",
canManage && canTransfer
? "grid-cols-3"
: canManage
? "grid-cols-2"
: "grid-cols-1",
)}
>
{canManage && (
<TabsTrigger value="deposit" className="flex items-center gap-1.5"> <TabsTrigger value="deposit" className="flex items-center gap-1.5">
<ArrowDownToLineIcon className="size-3.5" /> <ArrowDownToLineIcon className="size-3.5" />
Deposit Deposit
</TabsTrigger> </TabsTrigger>
)}
{canManage && (
<TabsTrigger value="withdraw" className="flex items-center gap-1.5"> <TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" /> <ArrowUpFromLineIcon className="size-3.5" />
Withdraw Withdraw
</TabsTrigger> </TabsTrigger>
)}
{canTransfer && (
<TabsTrigger value="transfer" className="flex items-center gap-1.5"> <TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" /> <ArrowRightLeftIcon className="size-3.5" />
Transfer Transfer
</TabsTrigger> </TabsTrigger>
)}
</TabsList> </TabsList>
{canManage && (
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4"> <TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Resource</Label> <Label>Resource</Label>
@ -723,9 +699,7 @@ export function ManageStorageDialog({
min="1" min="1"
max={getDepositMax()} max={getDepositMax()}
value={depositAmount} value={depositAmount}
onChange={(e) => onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
setDepositAmount(clampAmount(e.target.value, getDepositMax()))
}
/> />
{getDepositLimitInfoFor(depositResourceId) !== undefined && ( {getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@ -748,9 +722,7 @@ export function ManageStorageDialog({
Deposit Deposit
</Button> </Button>
</TabsContent> </TabsContent>
)}
{canManage && (
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4"> <TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Resource</Label> <Label>Resource</Label>
@ -811,9 +783,7 @@ export function ManageStorageDialog({
Withdraw Withdraw
</Button> </Button>
</TabsContent> </TabsContent>
)}
{canTransfer && (
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4"> <TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Destination</Label> <Label>Destination</Label>
@ -930,7 +900,6 @@ export function ManageStorageDialog({
Transfer Transfer
</Button> </Button>
</TabsContent> </TabsContent>
)}
</Tabs> </Tabs>
</> </>
)} )}

View file

@ -45,17 +45,9 @@ function getResourceIcon(type: Resource["type"]) {
interface StructureStorageViewProps { interface StructureStorageViewProps {
structure: GameStructure & { type: Structure }; structure: GameStructure & { type: Structure };
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
canArrange?: boolean;
/** Removing grid items / withdrawing destroys stored stock — superuser only. */
canMutate?: boolean;
} }
export function StructureStorageView({ export function StructureStorageView({ structure }: StructureStorageViewProps) {
structure,
canArrange = false,
canMutate = false,
}: StructureStorageViewProps) {
const router = useRouter(); const router = useRouter();
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null); const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
@ -88,72 +80,64 @@ export function StructureStorageView({
const handleMoveItem = useCallback( const handleMoveItem = useCallback(
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => { async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
if (!canArrange) return;
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated); const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh, canArrange], [structure.id, refresh],
); );
const handleMergeStacks = useCallback( const handleMergeStacks = useCallback(
async (sourceId: string, targetId: string) => { async (sourceId: string, targetId: string) => {
if (!canArrange) return;
const result = await mergeGridStacks(structure.id, sourceId, targetId); const result = await mergeGridStacks(structure.id, sourceId, targetId);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh, canArrange], [structure.id, refresh],
); );
const handleRotateItem = useCallback( const handleRotateItem = useCallback(
async (entryId: string) => { async (entryId: string) => {
if (!canArrange) return;
const result = await rotateGridItem(structure.id, entryId); const result = await rotateGridItem(structure.id, entryId);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh, canArrange], [structure.id, refresh],
); );
const handleRemoveItem = useCallback( const handleRemoveItem = useCallback(
async (entryId: string) => { async (entryId: string) => {
if (!canMutate) return;
const result = await removeGridItem(structure.id, entryId); const result = await removeGridItem(structure.id, entryId);
if (result.success) { if (result.success) {
setSelectedItem(null); setSelectedItem(null);
refresh(); refresh();
} }
}, },
[structure.id, refresh, canMutate], [structure.id, refresh],
); );
const handleSplitStack = useCallback( const handleSplitStack = useCallback(
async (entryId: string, amount: number) => { async (entryId: string, amount: number) => {
if (!canArrange) return;
const result = await splitGridStack(structure.id, entryId, amount); const result = await splitGridStack(structure.id, entryId, amount);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh, canArrange], [structure.id, refresh],
); );
const handleAutoArrange = useCallback(async () => { const handleAutoArrange = useCallback(async () => {
if (!canArrange) return;
const result = await autoArrangeGrid(structure.id); const result = await autoArrangeGrid(structure.id);
if (result.success) refresh(); if (result.success) refresh();
}, [structure.id, refresh, canArrange]); }, [structure.id, refresh]);
const handleWithdrawFromVoid = useCallback( const handleWithdrawFromVoid = useCallback(
async (resourceId: number, amount: number) => { async (resourceId: number, amount: number) => {
if (!canMutate) return;
const result = await removeResource(structure.id, resourceId, amount); const result = await removeResource(structure.id, resourceId, amount);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh, canMutate], [structure.id, refresh],
); );
const handleRetrieveFromVoid = useCallback(async () => { const handleRetrieveFromVoid = useCallback(async () => {
if (!canArrange) return;
const result = await retrieveFromVoid(structure.id); const result = await retrieveFromVoid(structure.id);
if (result.success) refresh(); if (result.success) refresh();
}, [structure.id, refresh, canArrange]); }, [structure.id, refresh]);
if (!hasGrid) { if (!hasGrid) {
return ( return (
@ -195,8 +179,6 @@ export function StructureStorageView({
onRotate={handleRotateItem} onRotate={handleRotateItem}
onRemove={handleRemoveItem} onRemove={handleRemoveItem}
onSplit={handleSplitStack} onSplit={handleSplitStack}
canArrange={canArrange}
canMutate={canMutate}
/> />
{viewMode === "grid" ? ( {viewMode === "grid" ? (
@ -231,7 +213,6 @@ export function StructureStorageView({
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no {voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
longer fit in the grid. longer fit in the grid.
</span> </span>
{canArrange && (
<button <button
type="button" type="button"
onClick={handleRetrieveFromVoid} onClick={handleRetrieveFromVoid}
@ -239,7 +220,6 @@ export function StructureStorageView({
> >
Retrieve All to Grid Retrieve All to Grid
</button> </button>
)}
</ItemDescription> </ItemDescription>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
@ -282,7 +262,6 @@ export function StructureStorageView({
{mass > 0 && `${mass.toLocaleString()} kg`} {mass > 0 && `${mass.toLocaleString()} kg`}
</div> </div>
</div> </div>
{canMutate && (
<button <button
type="button" type="button"
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)} onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
@ -291,7 +270,6 @@ export function StructureStorageView({
> >
Withdraw Withdraw
</button> </button>
)}
</div> </div>
{index < voidStorageItems.length - 1 && <ItemSeparator />} {index < voidStorageItems.length - 1 && <ItemSeparator />}
</div> </div>

View file

@ -1,67 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
className={cn(
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return <caption className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />;
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View file

@ -77,7 +77,7 @@ export function txAmountPrefix(flow: TxFlow): string {
*/ */
export function formatTxTimestamp(ts: string): string { export function formatTxTimestamp(ts: string): string {
const date = new Date(ts); const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "-"; if (Number.isNaN(date.getTime())) return "—";
const now = new Date(); const now = new Date();
const isToday = date.toDateString() === now.toDateString(); const isToday = date.toDateString() === now.toDateString();
if (isToday) { if (isToday) {

View file

@ -1,138 +0,0 @@
import type { Payload } from "payload";
import type { Mission } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
/**
* Ready-reminder system for player-scheduled side ops.
*
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
* creator gets a once-per-day reminder (web notification + Discord DM) to
* finish the op and mark it "Ready" — the status that triggers the roll-call
* embed, locks in details, and delivers the mission hooks. Reminders stop the
* moment the op is Ready (or cancelled/completed/active), and a marker field
* records the last server-local day each channel was sent so the cadence is
* exactly one reminder per day per channel.
*
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
* calendar — always `operationType: "side"`). Staff/admin-created missions are
* never reminded.
*
* Shared by the mission-tick bin (web notifications) and the Discord bot's
* readyReminders service (DMs) — keep this module free of discord.js imports.
*/
/** How many days before the op the daily reminders begin. */
export const READY_REMINDER_LEAD_DAYS = 5;
/** Notification type used for the web-app reminder (labels + bridge exclusion). */
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
/**
* Statuses that still need the creator's attention. "Ready" is the committed
* state the reminders push toward; Active/Completed/Cancelled are terminal or
* already underway.
*/
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const;
export const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
/** Server-local YYYY-MM-DD key used for the once-per-day cadence. */
export const dayKey = (date: Date): string =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate(),
).padStart(2, "0")}`;
/**
* A channel is due when it has never sent, or last sent on a previous
* server-local day. Day-based (not 24h-based) so retries within the same day
* stay deduped, and a status flapping Ready → Scheduled resumes reminders the
* next day instead of being suppressed forever.
*/
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
if (!marker) return true;
const markerDate = new Date(marker);
if (Number.isNaN(markerDate.getTime())) return true;
return dayKey(markerDate) !== dayKey(now);
};
export const daysUntil = (startIso: string, now: Date): number =>
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000));
/**
* Ops due for reminders: player-created (createdBy set), still not Ready /
* terminal, and starting within the lead window (future start, at most
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
* the caller via isReminderDue — the "not sent today" condition can't be
* expressed in a single where clause.
*/
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
const nowIso = now.toISOString();
const windowEndIso = new Date(
now.getTime() + READY_REMINDER_LEAD_DAYS * 86_400_000,
).toISOString();
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
sort: "classification.startDateTime",
limit: 50,
where: {
and: [
{ createdBy: { exists: true } },
{ "ownershipAndStatus.status": { in: [...READY_REMINDER_ELIGIBLE_STATUSES] } },
{ "classification.startDateTime": { greater_than: nowIso } },
{ "classification.startDateTime": { less_than_equal: windowEndIso } },
],
},
});
return res.docs as unknown as Mission[];
};
export interface ReadyReminderSummary {
missions: number;
notified: number;
}
/**
* Web sweep: one in-app notification per due mission per server-local day.
* Called from the mission-tick bin. Respects each user's notification mutes
* (notifyUser handles suppression) and stamps the per-day marker.
*/
export const sendWebReadyReminders = async (
payload: Payload,
now = new Date(),
): Promise<ReadyReminderSummary> => {
const missions = await getReadyReminderMissions(payload, now);
let notified = 0;
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderWebSentAt, now)) continue;
if (mission.createdBy == null) continue;
const creatorId =
typeof mission.createdBy === "object" ? mission.createdBy.id : mission.createdBy;
if (!creatorId) continue;
const days = daysUntil(mission.classification.startDateTime, now);
const when =
days === 0
? "today"
: days === 1
? "tomorrow"
: `in ${days} days`;
await notifyUser(payload, {
userId: creatorId,
type: READY_REMINDER_NOTIFICATION_TYPE,
title: `Ready check: ${missionTitle(mission)}`,
message: `Your op starts ${when}. Finish up the details and set the mission status to Ready so the roll-call can be posted and the mission hooks delivered.`,
link: `/intelligence/missions/${mission.id}`,
});
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderWebSentAt: now.toISOString() },
overrideAccess: true,
});
notified += 1;
}
return { missions: missions.length, notified };
};

View file

@ -91,8 +91,6 @@ export function notificationLabel(type?: string | null): string {
return "New reply"; return "New reply";
case "ticket:status": case "ticket:status":
return "Ticket updated"; return "Ticket updated";
case "mission:ready-reminder":
return "Ready reminder";
case "assignment-transfer:request": case "assignment-transfer:request":
return "Transfer request"; return "Transfer request";
case "assignment-transfer:approved": case "assignment-transfer:approved":

View file

@ -1,13 +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 "missions" ADD COLUMN "ready_reminder_web_sent_at" timestamp(3) with time zone;
ALTER TABLE "missions" ADD COLUMN "ready_reminder_dm_sent_at" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" DROP COLUMN "ready_reminder_web_sent_at";
ALTER TABLE "missions" DROP COLUMN "ready_reminder_dm_sent_at";`)
}

View file

@ -1,267 +1,261 @@
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from './20260825_220000'; import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables'; import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target'; import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from './20260827_005757'; import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags'; import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection'; import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at'; import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference'; import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode'; import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from './20260828_044506'; import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from './20260831_142503'; import * as migration_20260831_142503 from "./20260831_142503";
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers'; import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target'; import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history'; import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions'; import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds'; import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from './20260903_035544'; import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from './20260903_044047'; import * as migration_20260903_044047 from "./20260903_044047";
import * as migration_20260903_053419 from './20260903_053419'; import * as migration_20260903_053419 from "./20260903_053419";
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats'; import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts'; import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats'; import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections'; import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
import * as migration_20260905_183940_base_management from './20260905_183940_base_management'; import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount'; import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications'; import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from './20260905_213824'; import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from './20260905_215658'; import * as migration_20260905_215658 from "./20260905_215658";
import * as migration_20260906_045502 from './20260906_045502'; import * as migration_20260906_045502 from "./20260906_045502";
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field'; import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css'; import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext'; import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html'; import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state'; import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target'; import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions'; import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by'; import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional'; import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler'; import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description'; import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash'; import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description'; import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: '20260820_004157', name: "20260820_004157",
}, },
{ {
up: migration_20260825_220000.up, up: migration_20260825_220000.up,
down: migration_20260825_220000.down, down: migration_20260825_220000.down,
name: '20260825_220000', name: "20260825_220000",
}, },
{ {
up: migration_20260826_050000_repair_shims_auxiliary_tables.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260826_050000_repair_shims_auxiliary_tables', name: "20260826_050000_repair_shims_auxiliary_tables",
}, },
{ {
up: migration_20260826_060000_add_game_servers_event_target.up, up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down, down: migration_20260826_060000_add_game_servers_event_target.down,
name: '20260826_060000_add_game_servers_event_target', name: "20260826_060000_add_game_servers_event_target",
}, },
{ {
up: migration_20260827_005757.up, up: migration_20260827_005757.up,
down: migration_20260827_005757.down, down: migration_20260827_005757.down,
name: '20260827_005757', name: "20260827_005757",
}, },
{ {
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up, up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down, down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: '20260827_022334_add_eod_minesweeper_best_flags', name: "20260827_022334_add_eod_minesweeper_best_flags",
}, },
{ {
up: migration_20260827_213857_add_evaluations_collection.up, up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down, down: migration_20260827_213857_add_evaluations_collection.down,
name: '20260827_213857_add_evaluations_collection', name: "20260827_213857_add_evaluations_collection",
}, },
{ {
up: migration_20260827_233224_add_mission_completed_at.up, up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down, down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at', name: "20260827_233224_add_mission_completed_at",
}, },
{ {
up: migration_20260828_011500_add_background_texture_preference.up, up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down, down: migration_20260828_011500_add_background_texture_preference.down,
name: '20260828_011500_add_background_texture_preference', name: "20260828_011500_add_background_texture_preference",
}, },
{ {
up: migration_20260828_041615_add_campaign_mode.up, up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down, down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode', name: "20260828_041615_add_campaign_mode",
}, },
{ {
up: migration_20260828_044506.up, up: migration_20260828_044506.up,
down: migration_20260828_044506.down, down: migration_20260828_044506.down,
name: '20260828_044506', name: "20260828_044506",
}, },
{ {
up: migration_20260831_142503.up, up: migration_20260831_142503.up,
down: migration_20260831_142503.down, down: migration_20260831_142503.down,
name: '20260831_142503', name: "20260831_142503",
}, },
{ {
up: migration_20260831_180233_assignment_transfers.up, up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down, down: migration_20260831_180233_assignment_transfers.down,
name: '20260831_180233_assignment_transfers', name: "20260831_180233_assignment_transfers",
}, },
{ {
up: migration_20260901_014006_add_assignment_transfer_event_target.up, up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down, down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: '20260901_014006_add_assignment_transfer_event_target', name: "20260901_014006_add_assignment_transfer_event_target",
}, },
{ {
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up, up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down, down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: '20260901_213108_add_ticket_votes_and_edit_history', name: "20260901_213108_add_ticket_votes_and_edit_history",
}, },
{ {
up: migration_20260901_222200_add_admin_page_permissions.up, up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down, down: migration_20260901_222200_add_admin_page_permissions.down,
name: '20260901_222200_add_admin_page_permissions', name: "20260901_222200_add_admin_page_permissions",
}, },
{ {
up: migration_20260902_195336_add_medal_backgrounds.up, up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down, down: migration_20260902_195336_add_medal_backgrounds.down,
name: '20260902_195336_add_medal_backgrounds', name: "20260902_195336_add_medal_backgrounds",
}, },
{ {
up: migration_20260903_035544.up, up: migration_20260903_035544.up,
down: migration_20260903_035544.down, down: migration_20260903_035544.down,
name: '20260903_035544', name: "20260903_035544",
}, },
{ {
up: migration_20260903_044047.up, up: migration_20260903_044047.up,
down: migration_20260903_044047.down, down: migration_20260903_044047.down,
name: '20260903_044047', name: "20260903_044047",
}, },
{ {
up: migration_20260903_053419.up, up: migration_20260903_053419.up,
down: migration_20260903_053419.down, down: migration_20260903_053419.down,
name: '20260903_053419', name: "20260903_053419",
}, },
{ {
up: migration_20260903_060000_add_radio_mode_stats.up, up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down, down: migration_20260903_060000_add_radio_mode_stats.down,
name: '20260903_060000_add_radio_mode_stats', name: "20260903_060000_add_radio_mode_stats",
}, },
{ {
up: migration_20260903_061000_add_radio_incorrect_counts.up, up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down, down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: '20260903_061000_add_radio_incorrect_counts', name: "20260903_061000_add_radio_incorrect_counts",
}, },
{ {
up: migration_20260903_062000_add_aim_trainer_stats.up, up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down, down: migration_20260903_062000_add_aim_trainer_stats.down,
name: '20260903_062000_add_aim_trainer_stats', name: "20260903_062000_add_aim_trainer_stats",
}, },
{ {
up: migration_20260904_230838_add_wiki_collections.up, up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down, down: migration_20260904_230838_add_wiki_collections.down,
name: '20260904_230838_add_wiki_collections', name: "20260904_230838_add_wiki_collections",
}, },
{ {
up: migration_20260905_183940_base_management.up, up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down, down: migration_20260905_183940_base_management.down,
name: '20260905_183940_base_management', name: "20260905_183940_base_management",
}, },
{ {
up: migration_20260905_195456_labor_headcount.up, up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down, down: migration_20260905_195456_labor_headcount.down,
name: '20260905_195456_labor_headcount', name: "20260905_195456_labor_headcount",
}, },
{ {
up: migration_20260905_210310_labor_classifications.up, up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down, down: migration_20260905_210310_labor_classifications.down,
name: '20260905_210310_labor_classifications', name: "20260905_210310_labor_classifications",
}, },
{ {
up: migration_20260905_213824.up, up: migration_20260905_213824.up,
down: migration_20260905_213824.down, down: migration_20260905_213824.down,
name: '20260905_213824', name: "20260905_213824",
}, },
{ {
up: migration_20260905_215658.up, up: migration_20260905_215658.up,
down: migration_20260905_215658.down, down: migration_20260905_215658.down,
name: '20260905_215658', name: "20260905_215658",
}, },
{ {
up: migration_20260906_045502.up, up: migration_20260906_045502.up,
down: migration_20260906_045502.down, down: migration_20260906_045502.down,
name: '20260906_045502', name: "20260906_045502",
}, },
{ {
up: migration_20260906_050900_add_evaluation_reminders_field.up, up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down, down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field', name: "20260906_050900_add_evaluation_reminders_field",
}, },
{ {
up: migration_20260906_061451_add_wiki_template_css.up, up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down, down: migration_20260906_061451_add_wiki_template_css.down,
name: '20260906_061451_add_wiki_template_css', name: "20260906_061451_add_wiki_template_css",
}, },
{ {
up: migration_20260906_063753_add_personal_excerpt_richtext.up, up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down, down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext', name: "20260906_063753_add_personal_excerpt_richtext",
}, },
{ {
up: migration_20260906_065405_add_wiki_template_html.up, up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down, down: migration_20260906_065405_add_wiki_template_html.down,
name: '20260906_065405_add_wiki_template_html', name: "20260906_065405_add_wiki_template_html",
}, },
{ {
up: migration_20260906_100908_add_market_economy_state.up, up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down, down: migration_20260906_100908_add_market_economy_state.down,
name: '20260906_100908_add_market_economy_state', name: "20260906_100908_add_market_economy_state",
}, },
{ {
up: migration_20260906_165837_add_market_state_event_target.up, up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down, down: migration_20260906_165837_add_market_state_event_target.down,
name: '20260906_165837_add_market_state_event_target', name: "20260906_165837_add_market_state_event_target",
}, },
{ {
up: migration_20260906_202925_add_voice_submissions.up, up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down, down: migration_20260906_202925_add_voice_submissions.down,
name: '20260906_202925_add_voice_submissions', name: "20260906_202925_add_voice_submissions",
}, },
{ {
up: migration_20260907_012326_add_missions_created_by.up, up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down, down: migration_20260907_012326_add_missions_created_by.down,
name: '20260907_012326_add_missions_created_by', name: "20260907_012326_add_missions_created_by",
}, },
{ {
up: migration_20260907_013724_make_missions_campaign_optional.up, up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down, down: migration_20260907_013724_make_missions_campaign_optional.down,
name: '20260907_013724_make_missions_campaign_optional', name: "20260907_013724_make_missions_campaign_optional",
}, },
{ {
up: migration_20260907_041051_add_voice_script_spoiler.up, up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down, down: migration_20260907_041051_add_voice_script_spoiler.down,
name: '20260907_041051_add_voice_script_spoiler', name: "20260907_041051_add_voice_script_spoiler",
}, },
{ {
up: migration_20260907_043339_add_voice_script_description.up, up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down, down: migration_20260907_043339_add_voice_script_description.down,
name: '20260907_043339_add_voice_script_description', name: "20260907_043339_add_voice_script_description",
}, },
{ {
up: migration_20260907_044934_add_missions_discord_modlist_hash.up, up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down, down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: '20260907_044934_add_missions_discord_modlist_hash', name: "20260907_044934_add_missions_discord_modlist_hash",
}, },
{ {
up: migration_20260907_083010_add_voice_submission_description.up, up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down, down: migration_20260907_083010_add_voice_submission_description.down,
name: '20260907_083010_add_voice_submission_description', name: "20260907_083010_add_voice_submission_description",
},
{
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
name: '20260907_090245_add_mission_ready_reminder_fields'
}, },
]; ];

View file

@ -2380,14 +2380,6 @@ export interface Mission {
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot). * When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
*/ */
evaluationRemindersSentAt?: string | null; evaluationRemindersSentAt?: string | null;
/**
* Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).
*/
readyReminderWebSentAt?: string | null;
/**
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
*/
readyReminderDmSentAt?: string | null;
/** /**
* Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions. * Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.
*/ */
@ -3022,7 +3014,7 @@ export interface MarketNegotiation {
buyer: number | User; buyer: number | User;
status: 'open' | 'accepted' | 'rejected' | 'closed' | 'cancelled' | 'expired'; status: 'open' | 'accepted' | 'rejected' | 'closed' | 'cancelled' | 'expired';
/** /**
* NPC vendor patience (0-100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price. * NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.
*/ */
patience?: number | null; patience?: number | null;
/** /**
@ -4497,8 +4489,6 @@ export interface MissionsSelect<T extends boolean = true> {
discordAttendanceSentAt?: T; discordAttendanceSentAt?: T;
discordModlistHash?: T; discordModlistHash?: T;
evaluationRemindersSentAt?: T; evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T;
createdBy?: T; createdBy?: T;
coverImage?: T; coverImage?: T;
classification?: classification?:
@ -5737,7 +5727,7 @@ export interface Shim {
targeting: { targeting: {
audience: 'all' | 'percentage' | 'users' | 'roles'; audience: 'all' | 'percentage' | 'users' | 'roles';
/** /**
* Percentage of users (0-100) who receive this shim. Assignment is deterministic per user. * Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.
*/ */
percentage?: number | null; percentage?: number | null;
/** /**

View file

@ -1,7 +1,6 @@
import type { SanitizedConfig } from "payload"; import type { SanitizedConfig } from "payload";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle"; import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
import { createBinLogger, errorMessage } from "./lib/binFileLogger"; import { createBinLogger, errorMessage } from "./lib/binFileLogger";
export const script = async (config: SanitizedConfig) => { export const script = async (config: SanitizedConfig) => {
@ -14,13 +13,6 @@ export const script = async (config: SanitizedConfig) => {
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const result = await autoCompleteMissions(payload); const result = await autoCompleteMissions(payload);
const reminders = await sendWebReadyReminders(payload);
if (reminders.notified > 0) {
log.info(
`[Mission Tick] Sent ${reminders.notified} ready reminder notification(s) across ${reminders.missions} due op(s).`,
);
}
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET; const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) { if (notifySecret) {

View file

@ -53,6 +53,7 @@ const USER_PERMISSIONS: Permission[] = [
"shims:read", "shims:read",
"shipments:create", "shipments:create",
"shipments:update", "shipments:update",
"structures:update",
"structures:delete", "structures:delete",
]; ];
@ -86,7 +87,6 @@ const ADMIN_PERMISSIONS: Permission[] = [
"discord:staff", "discord:staff",
"discord:announce", "discord:announce",
"structures:create", "structures:create",
"structures:update",
...ALL_COLLECTION_READS, ...ALL_COLLECTION_READS,
...ALL_ADMIN_PAGE_MANAGE, ...ALL_ADMIN_PAGE_MANAGE,
]), ]),

View file

@ -126,7 +126,6 @@ export const EventTypes = {
MissionAutoComplete: "mission:auto-complete", MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle", MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
MissionSchedule: "mission:schedule", MissionSchedule: "mission:schedule",
MissionReady: "mission:ready",
MissionUpdate: "mission:update", MissionUpdate: "mission:update",
MissionCancel: "mission:cancel", MissionCancel: "mission:cancel",
MissionMoveCampaign: "mission:move-campaign", MissionMoveCampaign: "mission:move-campaign",