1
0
Fork 0

Compare commits

...

11 commits

78 changed files with 22835 additions and 520 deletions

View file

@ -1,5 +1,5 @@
import "dotenv/config"; import "dotenv/config";
import config from "./src/payload.config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
async function main() { async function main() {

View file

@ -22,10 +22,10 @@ mission-files
# --- Tests + Playwright artifacts (not needed for the production image) --- # --- Tests + Playwright artifacts (not needed for the production image) ---
tests tests
test-results .archived/test-results
playwright-report .archived/playwright-report
blob-report blob-report
playwright-report .archived/playwright-report
.playwright-mcp .playwright-mcp
test.env test.env
playwright.config.ts playwright.config.ts
@ -56,8 +56,8 @@ AGENTS.md
DEPLOYMENT.md DEPLOYMENT.md
docs docs
Writerside Writerside
repro-account.ts .archived/repro-account.ts
reset-test-user-password.ts .archived/reset-test-user-password.ts
pnpm-lock.yaml.bk pnpm-lock.yaml.bk
.yarnrc .yarnrc
.npmrc .npmrc

View file

@ -1,5 +0,0 @@
DATABASE_URI=postgres://ptfapp:polaristaskforcedb@127.0.0.1:5432/ptf-app-stg
PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0
# Added by Payload
LAST_UPDATED=

6
.gitignore vendored
View file

@ -42,13 +42,15 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
.env .env
.env.stg
.env.prd
/media /media
# Playwright # Playwright
node_modules/ node_modules/
/test-results/ /.archived/test-results/
/playwright-report/ /.archived/playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/

74
.todo/tasks.json Normal file
View file

@ -0,0 +1,74 @@
[
{
"id": "TASK_1",
"priority": "LOW",
"tags": [
"Game::Market"
],
"title": "fix: cannot list locker items on market",
"description": "",
"status": "TODO",
"date": {
"date": {
"year": 2026,
"month": 8,
"day": 20
},
"time": {
"hour": 16,
"minute": 2,
"second": 14,
"nano": 427428729
}
},
"isImported": false
},
{
"id": "TASK_3",
"priority": "LOW",
"tags": [
"App::Helpdesk"
],
"title": "feat: editable helpdesk tickets",
"description": "",
"status": "TODO",
"date": {
"date": {
"year": 2026,
"month": 8,
"day": 20
},
"time": {
"hour": 16,
"minute": 3,
"second": 1,
"nano": 462798021
}
},
"isImported": false
},
{
"id": "TASK_8",
"priority": "LOW",
"tags": [
"App::Helpdesk"
],
"title": "feat: ticket upvotes",
"description": "Functionality for people to upvote the features they want to be prioritized for adding, or bugs to be fixed",
"status": "TODO",
"date": {
"date": {
"year": 2026,
"month": 8,
"day": 20
},
"time": {
"hour": 16,
"minute": 3,
"second": 28,
"nano": 849363122
}
},
"isImported": false
}
]

View file

@ -283,8 +283,8 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Environment ## Environment
- `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET) - `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
- `.env.example` — template. `DATABASE_URI` line still shows MongoDB (outdated — trust `.env.test` for the real Postgres format), but `APP_URL` + `GAME_TICK_NOTIFY_SECRET` are current and required by the game tick. - `.env.example` — template. `DATABASE_URI` line still shows MongoDB (outdated — trust `.env.stg` for the real Postgres format), but `APP_URL` + `GAME_TICK_NOTIFY_SECRET` are current and required by the game tick.
- `.env.test` — test/staging database - `.env.stg` — test/staging database
- `test.env` — NODE_OPTIONS for playwright (loaded by playwright config) - `test.env` — NODE_OPTIONS for playwright (loaded by playwright config)
## Deploy ## Deploy
@ -294,7 +294,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Gotchas ## Gotchas
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts. - Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference. - `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.stg` as the real reference.
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive. - `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior. - The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
- Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e. - Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e.

View file

@ -67,6 +67,16 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
- **Motion**: no section-level motion. - **Motion**: no section-level motion.
- **Layout**: vertical stack. - **Layout**: vertical stack.
### Mission card
- **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives.
- **Variants**: `default` uses the neutral outline surface; `image` adds an optional cover image with a translucent gradient overlay. The contextual default is `image` when a cover image URL is available.
- **Metadata**: compact cards show operation/status, date/time, map, player slots, and campaign; full cards additionally show estimated duration.
- **States**: default, image, hover, focus-visible, and reduced-content/full-detail display.
- **Accessibility**: the entire card is one keyboard-reachable link; decorative cover images use empty alt text and are hidden from assistive technology.
- **Motion**: existing 200ms scale, border, and foreground transitions communicate the linked-card affordance; no decorative animation.
- **Layout**: responsive metadata wraps on narrow screens while the image layer stays absolutely positioned behind content.
### Preference row ### Preference row
- **Structure**: `label.flex.items-center.justify-between` with text on the left and a shadcn control on the right. - **Structure**: `label.flex.items-center.justify-between` with text on the left and a shadcn control on the right.

14
TODO.md
View file

@ -14,9 +14,11 @@
## MVP – Priority Deadline by 8/15 ## MVP – Priority Deadline by 8/15
[!NOTE] Some pieces of this application were not implemented by this date and have been pushed to the next release.
This application must be available to my users by 8/15. The minimum viable product is defined as: This application must be available to my users by 8/15. The minimum viable product is defined as:
- [ ] Working authentication (login, logout, forgot password without using email) - [x] Working authentication (login, logout, forgot password without using email)
- [ ] Automatically generated profile with customization options for user on the front-end - [ ] Automatically generated profile with customization options for user on the front-end
- [ ] Full permissions and ACL system for all components - [ ] Full permissions and ACL system for all components
- [ ] ALL ADMIN UI access should be blocked behind a "trusted" role. - [ ] ALL ADMIN UI access should be blocked behind a "trusted" role.
@ -31,11 +33,11 @@ This application must be available to my users by 8/15. The minimum viable produ
### Logistics (core of the application) ### Logistics (core of the application)
- [ ] Logistics front-end - [x] Logistics front-end
- [ ] Working shipments screen - [x] Working shipments screen
- [ ] Working structures screen - [x] Working structures screen
- [ ] Working assets screen - [x] Working assets screen
- [ ] Working vehicles screen - [x] Working vehicles screen
- [ ] Banking system - [ ] Banking system
- [ ] Working market screen - [ ] Working market screen

View file

@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@ai-stack/payloadcms": "^3.2.30", "@ai-stack/payloadcms": "^3.2.30",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@payloadcms/db-postgres": "3.88.0", "@payloadcms/db-postgres": "3.88.0",
"@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0",

View file

@ -15,6 +15,7 @@
"generate:version-info": "bun run src/scripts/generateVersionInfo.ts", "generate:version-info": "bun run src/scripts/generateVersionInfo.ts",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"migrate": "bun run src/scripts/run-migrations.ts",
"db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit", "db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start", "start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start", "staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start",
@ -28,6 +29,7 @@
"dependencies": { "dependencies": {
"@ai-stack/payloadcms": "^3.2.30", "@ai-stack/payloadcms": "^3.2.30",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@payloadcms/db-postgres": "3.88.0", "@payloadcms/db-postgres": "3.88.0",
"@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0",

19
reset-seq.sql Normal file
View file

@ -0,0 +1,19 @@
SELECT
'SELECT SETVAL(' ||
quote_literal(quote_ident(sequence_namespace.nspname) || '.' || quote_ident(class_sequence.relname)) ||
', COALESCE(MAX(' ||quote_ident(pg_attribute.attname)|| '), 1) ) FROM ' ||
quote_ident(table_namespace.nspname)|| '.'||quote_ident(class_table.relname)|| ';'
FROM pg_depend
INNER JOIN pg_class AS class_sequence
ON class_sequence.oid = pg_depend.objid
AND class_sequence.relkind = 'S'
INNER JOIN pg_class AS class_table
ON class_table.oid = pg_depend.refobjid
INNER JOIN pg_attribute
ON pg_attribute.attrelid = class_table.oid
AND pg_depend.refobjsubid = pg_attribute.attnum
INNER JOIN pg_namespace as table_namespace
ON table_namespace.oid = class_table.relnamespace
INNER JOIN pg_namespace AS sequence_namespace
ON sequence_namespace.oid = class_sequence.relnamespace
ORDER BY sequence_namespace.nspname, class_sequence.relname;

View file

@ -4,6 +4,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
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 { broadcastToUser } from "@/lib/realtime/bus";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -100,6 +101,11 @@ export async function submitFlappyScore(input: {
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
}); });
broadcastToUser("profile-update", Number(user.id), {
reason: "xp",
xpGained: runXp,
profileId: profile.id,
});
if (passes > 0) { if (passes > 0) {
await emitGameEvent(payload, { await emitGameEvent(payload, {

View file

@ -71,6 +71,7 @@ export async function createTicket(input: {
description: string; description: string;
category?: TicketCategory; category?: TicketCategory;
priority?: TicketPriority; priority?: TicketPriority;
projectId?: number;
}): Promise<ActionResult<Ticket>> { }): Promise<ActionResult<Ticket>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
@ -95,6 +96,7 @@ export async function createTicket(input: {
priority, priority,
status: "open", status: "open",
reporter: userId, reporter: userId,
project: input.projectId ?? null,
activity: [ activity: [
{ {
type: "system", type: "system",
@ -102,7 +104,7 @@ export async function createTicket(input: {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}, },
], ],
}, } as any,
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as Ticket; })) as unknown as Ticket;

View file

@ -13,6 +13,8 @@ import {
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { statusColors as missionStatusColors } from "@/lib/intelligence/missionColors";
interface CampaignDetailPageProps { interface CampaignDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -31,16 +33,6 @@ const factionTypeColors: Record<string, string> = {
special: "bg-purple-500/20 text-purple-400", special: "bg-purple-500/20 text-purple-400",
}; };
const missionStatusColors: Record<string, string> = {
Concept: "bg-muted text-muted-foreground",
Planning: "bg-muted text-muted-foreground",
Ready: "bg-blue-500/20 text-blue-400",
Scheduled: "bg-amber-500/20 text-amber-400",
Active: "bg-green-500/20 text-green-400",
Completed: "bg-emerald-500/20 text-emerald-400",
Cancelled: "bg-red-500/20 text-red-400",
};
export default async function CampaignDetailPage({ params }: CampaignDetailPageProps) { export default async function CampaignDetailPage({ params }: CampaignDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -198,40 +190,12 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
{missions.length > 0 ? ( {missions.length > 0 ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{missions.map((mission) => ( {missions.map((mission) => (
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}> <MissionCard
<Item key={mission.id}
variant="outline" mission={mission}
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] group relative border border-transparent hover:border-white/75" variant="image"
> showFullDetails={false}
<div className="absolute inset-0 pointer-events-none opacity-25 overflow-hidden after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:inset-0 after:z-10 after:absolute group-hover:after:opacity-0 after:transition-opacity"> />
<img
src={(mission.coverImage as Media)?.url ?? undefined}
className="w-full h-full aspect-video object-cover object-center"
/>
</div>
<ItemHeader>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">
{mission.codeName}
</span>
<ItemTitle>{mission.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
missionStatusColors[mission.ownershipAndStatus.status] ??
"bg-muted text-muted-foreground"
}`}
>
{mission.ownershipAndStatus.status}
</span>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-1">
{mission.summary}
</ItemDescription>
</ItemContent>
</Item>
</Link>
))} ))}
</div> </div>
) : ( ) : (

View file

@ -14,6 +14,7 @@ import {
ShieldIcon, ShieldIcon,
} 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 { MissionCard } from "@/components/frontend/intelligence/MissionCard";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";
interface FactionDetailPageProps { interface FactionDetailPageProps {
@ -36,16 +37,6 @@ const typeLabels: Record<string, string> = {
special: "Special", special: "Special",
}; };
const missionStatusColors: Record<string, string> = {
Concept: "bg-muted text-muted-foreground",
Planning: "bg-muted text-muted-foreground",
Ready: "bg-blue-500/20 text-blue-400",
Scheduled: "bg-amber-500/20 text-amber-400",
Active: "bg-green-500/20 text-green-400",
Completed: "bg-emerald-500/20 text-emerald-400",
Cancelled: "bg-red-500/20 text-red-400",
};
const campaignStatusColors: Record<string, string> = { const campaignStatusColors: Record<string, string> = {
concept: "bg-muted text-muted-foreground", concept: "bg-muted text-muted-foreground",
inProgress: "bg-blue-500/20 text-blue-400", inProgress: "bg-blue-500/20 text-blue-400",
@ -134,13 +125,16 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
where: { "factionsInvolved.faction": { equals: faction.id } }, where: { "factionsInvolved.faction": { equals: faction.id } },
sort: "-classification.startDateTime", sort: "-classification.startDateTime",
limit: 100, limit: 100,
depth: 0, depth: 1,
select: { select: {
name: true, name: true,
codeName: true, codeName: true,
summary: true, summary: true,
operationType: true,
ownershipAndStatus: true, ownershipAndStatus: true,
classification: true, classification: true,
missionRoles: true,
coverImage: true,
}, },
}) })
.catch(() => ({ docs: [] as Mission[] })); .catch(() => ({ docs: [] as Mission[] }));
@ -303,34 +297,7 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{missions.map((mission) => ( {missions.map((mission) => (
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}> <MissionCard key={mission.id} mission={mission} showFullDetails={false} />
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">
{mission.codeName}
</span>
<ItemTitle>{mission.name}</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
missionStatusColors[mission.ownershipAndStatus.status] ??
"bg-muted text-muted-foreground"
}`}
>
{mission.ownershipAndStatus.status}
</span>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground line-clamp-1">
{mission.summary}
</p>
</ItemContent>
</Item>
</Link>
))} ))}
</div> </div>
</section> </section>

View file

@ -19,6 +19,7 @@ import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext"; import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider"; import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
import { isSuperuser } from "@/utils/access-control/hasPermission"; import { isSuperuser } from "@/utils/access-control/hasPermission";
import { Rank } from "@/payload-types";
// Skip static prerendering of this layout (and all pages under (frontend)/). // Skip static prerendering of this layout (and all pages under (frontend)/).
// The layout calls `getPayload()` at render time to resolve the current user, // The layout calls `getPayload()` at render time to resolve the current user,
@ -58,6 +59,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
let isIntelligence = false; let isIntelligence = false;
let isLogistics = false; let isLogistics = false;
let currentRank = "";
if (user) { if (user) {
[isIntelligence, isLogistics] = await Promise.all([ [isIntelligence, isLogistics] = await Promise.all([
@ -69,6 +71,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
collection: "profiles", collection: "profiles",
where: { user: { equals: user.id } }, where: { user: { equals: user.id } },
select: { select: {
rank: true,
progression: { progression: {
awards: true, awards: true,
scoreboard: true, scoreboard: true,
@ -90,6 +93,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const userXp = profileRes.docs[0]?.progression?.xp ?? 0; const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs); xpLevel = resolveLevel(userXp, experienceRes.docs);
currentRank = (profileRes.docs[0]?.rank as Rank).abbreviation;
} }
return ( return (
@ -107,6 +112,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
user={{ user={{
displayName: user.payloadDisplayName ?? "Guest", displayName: user.payloadDisplayName ?? "Guest",
username: user.username ?? "Not logged in", username: user.username ?? "Not logged in",
rank: currentRank,
}} }}
xpLevel={xpLevel} xpLevel={xpLevel}
adminUrl={payloadConfig.routes.admin} adminUrl={payloadConfig.routes.admin}

View file

@ -114,21 +114,28 @@ export default async function HomePage() {
missionRoles: briefingMission.missionRoles missionRoles: briefingMission.missionRoles
? { maxPlayers: briefingMission.missionRoles.maxPlayers } ? { maxPlayers: briefingMission.missionRoles.maxPlayers }
: null, : null,
coverImage: briefingMission.coverImage,
} }
: null; : null;
const upcomingMissions = upcomingRes.docs.map((m) => ({ const upcomingMissions = upcomingRes.docs.map((m) => ({
id: m.id, id: m.id,
name: m.name, name: m.name,
codeName: m.codeName, codeName: m.codeName,
summary: m.summary,
operationType: m.operationType,
classification: { classification: {
missionType: m.classification.missionType,
startDateTime: m.classification.startDateTime, startDateTime: m.classification.startDateTime,
estimatedDuration: m.classification.estimatedDuration,
map: m.classification.map as number | Map, map: m.classification.map as number | Map,
campaign: m.classification.campaign as number | Campaign, campaign: m.classification.campaign as number | Campaign,
}, },
ownershipAndStatus: { ownershipAndStatus: {
status: m.ownershipAndStatus.status, status: m.ownershipAndStatus.status,
}, },
missionRoles: m.missionRoles ? { maxPlayers: m.missionRoles.maxPlayers } : null,
coverImage: m.coverImage,
})); }));
// Fetch recent events // Fetch recent events
@ -198,7 +205,7 @@ export default async function HomePage() {
<div className="p-5 flex flex-col gap-6"> <div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
Welcome back, {profileUser?.displayName ?? user.username} Welcome back, {(profile?.rank as Rank).name}. {/*{profileUser?.displayName ?? user.username}*/}
</h1> </h1>
<p className="text-sm text-muted-foreground">Here&apos;s your operations dashboard.</p> <p className="text-sm text-muted-foreground">Here&apos;s your operations dashboard.</p>
</div> </div>

View file

@ -0,0 +1,160 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { getPayload } from "payload";
import { ProjectBoardView } from "@/components/frontend/projects/ProjectBoardView";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const metadata = {
title: "Board — Polaris Task Force",
};
const KANBAN_STATUSES = [
{ status: "open", label: "Open" },
{ status: "planning", label: "Planning" },
{ status: "in_progress", label: "In Progress" },
{ status: "in_review", label: "In Review" },
{ status: "waiting_on_reporter", label: "Waiting on Reporter" },
{ status: "in_testing", label: "Testing" },
{ status: "resolved", label: "Done" },
{ status: "canceled", label: "Canceled" },
{ status: "closed", label: "Closed" },
] as const;
export default async function BoardPage({
params,
}: {
params: Promise<{ key: string }>;
}) {
const { key } = await params;
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=/projects/${key}/board`);
}
const projectResult = await payload.find({
collection: "projects" as any,
where: { key: { equals: key.toUpperCase() } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (projectResult.docs.length === 0) {
notFound();
}
const project = projectResult.docs[0] as any;
const canManageTickets = await hasPermission(payload, user, "tickets:staff");
const [ticketResult, sprintResult, labelResult] = await Promise.all([
payload.find({
collection: "tickets" as any,
where: { project: { equals: project.id } },
depth: 2,
limit: 500,
overrideAccess: true,
}),
payload.find({
collection: "sprints" as any,
where: { project: { equals: project.id } },
sort: "-startDate",
depth: 0,
limit: 50,
overrideAccess: true,
}),
payload.find({
collection: "labels" as any,
where: {
or: [
{ project: { equals: project.id } },
{ project: { equals: null } },
],
},
depth: 0,
limit: 100,
overrideAccess: true,
}),
]);
const ticketsByStatus = new Map<string, any[]>();
for (const col of KANBAN_STATUSES) {
ticketsByStatus.set(col.status, []);
}
for (const ticket of ticketResult.docs) {
const t = ticket as any;
const status = t.status || "open";
const col = ticketsByStatus.get(status);
if (col) {
col.push({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
type: t.type || "task",
storyPoints: t.storyPoints,
labels: (t.labels || []).map((l: any) =>
typeof l === "object" && l
? { id: l.id, name: l.name, color: l.color }
: { id: l, name: "", color: "#6366f1" },
),
assignee: t.assignee
? typeof t.assignee === "object"
? {
id: t.assignee.id,
displayName:
t.assignee.payloadDisplayName || t.assignee.username,
}
: { id: t.assignee, displayName: `User #${t.assignee}` }
: null,
reporter: t.reporter
? typeof t.reporter === "object"
? {
id: t.reporter.id,
displayName:
t.reporter.payloadDisplayName || t.reporter.username,
}
: { id: t.reporter, displayName: `User #${t.reporter}` }
: null,
});
}
}
const columns = KANBAN_STATUSES.map((col) => ({
status: col.status,
label: col.label,
tickets: ticketsByStatus.get(col.status) || [],
}));
return (
<div className="flex flex-col gap-6 p-5">
<ProjectBoardView
project={{
id: project.id,
name: project.name,
key: project.key,
}}
columns={columns}
sprints={sprintResult.docs.map((s: any) => ({
id: s.id,
name: s.name,
status: s.status,
startDate: s.startDate,
endDate: s.endDate,
}))}
labels={labelResult.docs.map((l: any) => ({
id: l.id,
name: l.name,
color: l.color,
project: l.project,
}))}
currentUserId={user.id as number}
isAdmin={canManageTickets}
/>
</div>
);
}

View file

@ -0,0 +1,33 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { getPayload } from "payload";
export default async function ProjectPage({
params,
}: {
params: Promise<{ key: string }>;
}) {
const { key } = await params;
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=/projects/${key}`);
}
const result = await payload.find({
collection: "projects" as any,
where: { key: { equals: key.toUpperCase() } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (result.docs.length === 0) {
notFound();
}
redirect(`/projects/${key}/board`);
}

View file

@ -0,0 +1,102 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { getPayload } from "payload";
import { ReleasesView } from "@/components/frontend/projects/ReleasesView";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { richTextToPlaintext } from "@/lib/projects/projectMeta";
export const metadata = {
title: "Releases — Polaris Task Force",
};
export default async function ReleasesPage({
params,
}: {
params: Promise<{ key: string }>;
}) {
const { key } = await params;
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=/projects/${key}/releases`);
}
const projectResult = await payload.find({
collection: "projects" as any,
where: { key: { equals: key.toUpperCase() } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (projectResult.docs.length === 0) {
notFound();
}
const project = projectResult.docs[0] as any;
const canManage = await hasPermission(payload, user, "releases:update");
const canCreate = await hasPermission(payload, user, "releases:create");
const [releaseResult, ticketResult] = await Promise.all([
payload.find({
collection: "releases" as any,
where: { project: { equals: project.id } },
sort: "-releaseDate",
depth: 0,
limit: 100,
overrideAccess: true,
}),
payload.find({
collection: "tickets" as any,
where: { project: { equals: project.id } },
depth: 0,
limit: 500,
overrideAccess: true,
}),
]);
const tickets = ticketResult.docs as any[];
const releases = releaseResult.docs.map((r: any) => {
const releaseTickets = tickets.filter((ticket: any) => {
const releaseId = typeof ticket.release === "object" && ticket.release !== null
? ticket.release.id
: ticket.release;
return releaseId === r.id;
});
const ticketsTotal = releaseTickets.length;
const ticketsResolved = releaseTickets.filter(
(ticket: any) => ticket.status === "resolved",
).length;
return {
id: r.id,
name: r.name,
status: r.status,
targetVersion: r.targetVersion,
releaseDate: r.releaseDate,
description: richTextToPlaintext(r.description) || null,
createdAt: r.createdAt,
ticketsTotal,
ticketsResolved,
};
});
return (
<div className="flex flex-col gap-6 p-5">
<ReleasesView
project={{
id: project.id,
name: project.name,
key: project.key,
}}
releases={releases}
isAdmin={canManage || canCreate}
/>
</div>
);
}

View file

@ -0,0 +1,107 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { getPayload } from "payload";
import { SprintsView } from "@/components/frontend/projects/SprintsView";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const metadata = {
title: "Sprints — Polaris Task Force",
};
export default async function SprintsPage({
params,
}: {
params: Promise<{ key: string }>;
}) {
const { key } = await params;
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect(`/login?returnTo=/projects/${key}/sprints`);
}
const projectResult = await payload.find({
collection: "projects" as any,
where: { key: { equals: key.toUpperCase() } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (projectResult.docs.length === 0) {
notFound();
}
const project = projectResult.docs[0] as any;
const canManage = await hasPermission(payload, user, "sprints:update");
const canCreate = await hasPermission(payload, user, "sprints:create");
const [sprintResult, ticketResult] = await Promise.all([
payload.find({
collection: "sprints" as any,
where: { project: { equals: project.id } },
sort: "-startDate",
depth: 0,
limit: 100,
overrideAccess: true,
}),
payload.find({
collection: "tickets" as any,
where: { project: { equals: project.id } },
depth: 0,
limit: 500,
overrideAccess: true,
}),
]);
const tickets = ticketResult.docs as any[];
const sprints = sprintResult.docs.map((sprint: any) => {
const sprintTickets = tickets.filter(
(ticket: any) => {
const sprintId = typeof ticket.sprint === "object" && ticket.sprint !== null
? ticket.sprint.id
: ticket.sprint;
return sprintId === sprint.id;
},
);
const storyPointsTotal = sprintTickets.reduce(
(sum: number, ticket: any) => sum + (ticket.storyPoints ?? 0),
0,
);
const storyPointsCompleted = sprintTickets
.filter((ticket: any) => ticket.status === "resolved")
.reduce((sum: number, ticket: any) => sum + (ticket.storyPoints ?? 0), 0);
return {
id: sprint.id,
name: sprint.name,
status: sprint.status,
goal: sprint.goal,
startDate: sprint.startDate,
endDate: sprint.endDate,
capacity: sprint.capacity,
storyPointsTotal,
storyPointsCompleted,
};
});
return (
<div className="flex flex-col gap-6 p-5">
<SprintsView
project={{
id: project.id,
name: project.name,
key: project.key,
}}
sprints={sprints}
isAdmin={canManage || canCreate}
/>
</div>
);
}

View file

@ -0,0 +1,307 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { plaintextToLexical } from "@/lib/tickets/lexical";
export interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) throw new Error("Unauthorized");
return { payload, user };
}
async function requirePermission(
payload: Awaited<ReturnType<typeof getPayload>>,
user: Parameters<typeof hasPermission>[1],
permission: Parameters<typeof hasPermission>[2],
) {
if (!(await hasPermission(payload, user, permission))) {
throw new Error("You don't have permission to manage project tracking.");
}
}
export async function createProject(input: {
name: string;
key: string;
description?: string;
leadId?: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "projects:create");
const name = input.name.trim();
if (!name) throw new Error("Project name is required.");
let key = input.key.trim().toUpperCase();
if (!key) throw new Error("Project key is required.");
if (key.length > 10) throw new Error("Project key must be 10 characters or fewer.");
if (!/^[A-Z0-9]+$/.test(key)) throw new Error("Project key must be uppercase alphanumeric.");
// Check uniqueness
const existing = await payload.find({
collection: "projects" as any,
where: { key: { equals: key } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (existing.docs.length > 0) throw new Error(`Project key "${key}" already exists.`);
const description = input.description?.trim();
const data: Record<string, any> = {
name,
key,
status: "active",
lead: input.leadId ?? null,
};
if (description) {
data.description = plaintextToLexical(description);
}
const project = await payload.create({
collection: "projects" as any,
data,
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.ProjectCreate,
message: `Project "${name}" (${key}) created`,
actor: user.id as number,
});
return { success: true, data: project };
} 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 createSprint(input: {
name: string;
projectId: number;
goal?: string;
startDate?: string;
endDate?: string;
capacity?: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "sprints:create");
const name = input.name.trim();
if (!name) throw new Error("Sprint name is required.");
const sprint = await payload.create({
collection: "sprints" as any,
data: {
name,
project: input.projectId,
goal: input.goal?.trim() || null,
startDate: input.startDate || null,
endDate: input.endDate || null,
status: "planning",
capacity: input.capacity ?? null,
},
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.SprintCreate,
message: `Sprint "${name}" created`,
actor: user.id as number,
});
return { success: true, data: sprint };
} 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 startSprint(input: {
sprintId: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "sprints:update");
const sprint = await payload.update({
collection: "sprints" as any,
id: input.sprintId,
data: { status: "active" },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.SprintStart,
message: `Sprint "${(sprint as any).name}" started`,
actor: user.id as number,
});
return { success: true };
} 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 completeSprint(input: {
sprintId: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "sprints:update");
const sprint = await payload.update({
collection: "sprints" as any,
id: input.sprintId,
data: { status: "completed" },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.SprintComplete,
message: `Sprint "${(sprint as any).name}" completed`,
actor: user.id as number,
});
return { success: true };
} 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 startRelease(input: {
releaseId: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "releases:update");
const release = await payload.update({
collection: "releases" as any,
id: input.releaseId,
data: { status: "in_progress" },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.ReleaseStart,
message: `Release "${(release as any).name}" started`,
actor: user.id as number,
});
return { success: true };
} 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 completeRelease(input: {
releaseId: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "releases:update");
const release = await payload.update({
collection: "releases" as any,
id: input.releaseId,
data: { status: "released", releaseDate: new Date().toISOString() },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.ReleaseComplete,
message: `Release "${(release as any).name}" released`,
actor: user.id as number,
});
return { success: true };
} 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 createRelease(input: {
name: string;
projectId: number;
description?: string;
targetVersion?: string;
releaseDate?: string;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
await requirePermission(payload, user, "releases:create");
const name = input.name.trim();
if (!name) throw new Error("Release name is required.");
const data: Record<string, any> = {
name,
project: input.projectId,
status: "draft",
targetVersion: input.targetVersion?.trim() || null,
releaseDate: input.releaseDate || null,
};
if (input.description?.trim()) {
data.description = plaintextToLexical(input.description.trim());
}
const release = await payload.create({
collection: "releases" as any,
data,
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.ReleaseCreate,
message: `Release "${name}" created`,
actor: user.id as number,
});
return { success: true, data: release };
} 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 };
}
}

View file

@ -0,0 +1,55 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import { ProjectsView } from "@/components/frontend/projects/ProjectsView";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { richTextToPlaintext } from "@/lib/projects/projectMeta";
export const metadata = {
title: "Projects — Polaris Task Force",
};
export default async function ProjectsPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect("/login?returnTo=/projects");
}
const projects = await payload.find({
collection: "projects" as any,
sort: "name",
depth: 1,
limit: 100,
overrideAccess: true,
});
const canCreate = await hasPermission(payload, user, "projects:create");
return (
<div className="flex flex-col gap-6 p-5">
<ProjectsView
projects={projects.docs.map((p: any) => ({
id: p.id,
name: p.name,
key: p.key,
status: p.status,
lead: p.lead
? {
id: p.lead.id,
displayName:
p.lead.payloadDisplayName || p.lead.username,
}
: null,
description: richTextToPlaintext(p.description) || null,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
}))}
currentUserId={user.id as number}
isAdmin={canCreate}
/>
</div>
);
}

View file

@ -0,0 +1,73 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import { ProjectSearchView } from "@/components/frontend/projects/ProjectSearchView";
export const metadata = {
title: "Search — Polaris Task Force",
};
export default async function SearchPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user) {
redirect("/login?returnTo=/projects/search");
}
const [ticketResult, labelResult] = await Promise.all([
payload.find({
collection: "tickets" as any,
where: { project: { exists: true } },
depth: 2,
pagination: false,
overrideAccess: true,
}),
payload.find({
collection: "labels" as any,
depth: 0,
limit: 200,
overrideAccess: true,
}),
]);
const tickets = (ticketResult.docs as any[]).map((t) => ({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
type: t.type || "task",
category: t.category,
storyPoints: t.storyPoints,
project: t.project
? typeof t.project === "object"
? { id: t.project.id, name: t.project.name, key: t.project.key }
: { id: t.project, name: `Project #${t.project}`, key: `P${t.project}` }
: { id: 0, name: "No project", key: "NONE" },
labels: (t.labels || []).map((l: any) =>
typeof l === "object" && l
? { id: l.id, name: l.name, color: l.color }
: { id: l, name: "", color: "#6366f1" },
),
assignee: t.assignee
? typeof t.assignee === "object"
? { id: t.assignee.id, displayName: t.assignee.payloadDisplayName || t.assignee.username }
: { id: t.assignee, displayName: `User #${t.assignee}` }
: null,
href: `/helpdesk/${t.id}`,
}));
const labels = (labelResult.docs as any[]).map((l) => ({
id: l.id,
name: l.name,
color: l.color,
}));
return (
<div className="flex flex-col gap-6 p-5">
<ProjectSearchView tickets={tickets} allLabels={labels} />
</div>
);
}

View file

@ -298,6 +298,23 @@
} }
} }
@keyframes xp-gain {
0% {
opacity: 0;
transform: translateX(0.35rem);
}
15% {
opacity: 1;
}
75% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(-1.1rem);
}
}
.locker-search-highlight { .locker-search-highlight {
animation: locker-search-pulse 1.1s ease-in-out infinite; animation: locker-search-pulse 1.1s ease-in-out infinite;
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9); box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
@ -331,4 +348,4 @@
*::-webkit-scrollbar-corner { *::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { getPayload } from "payload";
import { removePresence, touchPresence } from "@/lib/realtime/presence";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
async function authenticate(req: NextRequest) {
const payload = await getPayload({ config: await config });
return payload.auth({ headers: req.headers, canSetHeaders: false });
}
function channelFrom(req: NextRequest): string | null {
const channel = req.nextUrl.searchParams.get("channel")?.trim();
return channel && /^[a-z0-9:_-]{1,80}$/i.test(channel) ? channel : null;
}
export async function POST(req: NextRequest) {
const channel = channelFrom(req);
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
const { user } = await authenticate(req);
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
touchPresence(channel, {
id: user.id as number,
name: user.payloadDisplayName || user.username || `Pilot #${user.id}`,
});
return NextResponse.json({ ok: true });
}
export async function DELETE(req: NextRequest) {
const channel = channelFrom(req);
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
const { user } = await authenticate(req);
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
removePresence(channel, user.id as number);
return NextResponse.json({ ok: true });
}

View file

@ -1,11 +1,17 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import config from "@payload-config";
import { getPayload } from "payload";
import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus"; import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus";
import { getPresence } from "@/lib/realtime/presence";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const channel = req.nextUrl.searchParams.get("channel");
let client: SSEEventClient | null = null; let client: SSEEventClient | null = null;
let heartbeat: ReturnType<typeof setInterval> | null = null; let heartbeat: ReturnType<typeof setInterval> | null = null;
@ -21,10 +27,15 @@ export async function GET(req: NextRequest) {
start(controller) { start(controller) {
client = { client = {
send: (data) => controller.enqueue(encoder.encode(data)), send: (data) => controller.enqueue(encoder.encode(data)),
userId: user ? Number(user.id) : undefined,
events: channel ? new Set(["presence"]) : undefined,
}; };
subscribe(client); subscribe(client);
controller.enqueue(encoder.encode(": connected\n\n")); controller.enqueue(encoder.encode(": connected\n\n"));
if (channel) {
controller.enqueue(encoder.encode(`event: presence\ndata: ${JSON.stringify({ channel, action: "snapshot", participants: getPresence(channel) })}\n\n`));
}
heartbeat = setInterval(() => { heartbeat = setInterval(() => {
try { try {

View file

@ -0,0 +1,49 @@
import type { CollectionConfig } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const Labels: CollectionConfig = {
slug: "labels",
admin: {
group: "Projects",
useAsTitle: "name",
defaultColumns: ["name", "color", "project"],
},
access: {
read: ({ req }) => !!req.user,
create: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "labels:create");
},
update: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "labels:update");
},
delete: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "labels:delete");
},
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "color",
type: "text",
defaultValue: "#6366f1",
admin: {
description: "Hex color code.",
},
},
{
name: "project",
type: "relationship",
relationTo: "projects",
admin: {
description: "Null for global labels.",
},
},
],
};

View file

@ -0,0 +1,69 @@
import type { CollectionConfig } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const Projects: CollectionConfig = {
slug: "projects",
admin: {
group: "Projects",
useAsTitle: "name",
defaultColumns: ["name", "key", "status", "lead", "updatedAt"],
listSearchableFields: ["name", "key"],
},
access: {
read: ({ req }) => !!req.user,
create: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "projects:create");
},
update: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "projects:update");
},
delete: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "projects:delete");
},
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "key",
type: "text",
required: true,
unique: true,
admin: {
description: "Short project key (e.g. PTF). Max 10 uppercase alphanumeric characters.",
},
},
{
name: "description",
type: "richText",
},
{
name: "lead",
type: "relationship",
relationTo: "users",
},
{
name: "status",
type: "select",
defaultValue: "active",
options: [
{ label: "Active", value: "active" },
{ label: "Archived", value: "archived" },
],
},
{
name: "defaultAssignee",
type: "relationship",
relationTo: "users",
admin: {
description: "Default assignee for new tickets in this project.",
},
},
],
};

View file

@ -0,0 +1,68 @@
import type { CollectionConfig } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const Releases: CollectionConfig = {
slug: "releases",
admin: {
group: "Projects",
useAsTitle: "name",
defaultColumns: ["name", "project", "targetVersion", "status", "releaseDate"],
},
access: {
read: ({ req }) => !!req.user,
create: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "releases:create");
},
update: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "releases:update");
},
delete: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "releases:delete");
},
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "project",
type: "relationship",
relationTo: "projects",
required: true,
index: true,
},
{
name: "description",
type: "richText",
},
{
name: "status",
type: "select",
defaultValue: "draft",
options: [
{ label: "Draft", value: "draft" },
{ label: "In Progress", value: "in_progress" },
{ label: "Released", value: "released" },
],
},
{
name: "releaseDate",
type: "date",
admin: {
date: { pickerAppearance: "dayAndTime" },
},
},
{
name: "targetVersion",
type: "text",
admin: {
description: "Semver string, e.g. 2.3.0",
},
},
],
};

View file

@ -0,0 +1,69 @@
import type { CollectionConfig } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
export const Sprints: CollectionConfig = {
slug: "sprints",
admin: {
group: "Projects",
useAsTitle: "name",
defaultColumns: ["name", "project", "status", "startDate", "endDate"],
},
access: {
read: ({ req }) => !!req.user,
create: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "sprints:create");
},
update: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "sprints:update");
},
delete: async ({ req }) => {
if (!req.user) return false;
return await hasPermission(req.payload, req.user, "sprints:delete");
},
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "project",
type: "relationship",
relationTo: "projects",
required: true,
index: true,
},
{
name: "goal",
type: "text",
},
{
name: "startDate",
type: "date",
admin: { date: { pickerAppearance: "dayAndTime" } },
},
{
name: "endDate",
type: "date",
admin: { date: { pickerAppearance: "dayAndTime" } },
},
{
name: "status",
type: "select",
defaultValue: "planning",
options: [
{ label: "Planning", value: "planning" },
{ label: "Active", value: "active" },
{ label: "Completed", value: "completed" },
],
},
{
name: "capacity",
type: "number",
admin: { description: "Story point capacity for this sprint." },
},
],
};

View file

@ -239,5 +239,109 @@ export const Tickets: CollectionConfig = {
date: { pickerAppearance: "dayAndTime" }, date: { pickerAppearance: "dayAndTime" },
}, },
}, },
// --- Project tracking fields (Phase 1) ---
{
name: "project",
type: "relationship",
relationTo: "projects",
admin: {
description: "Project this ticket belongs to. Null = standalone/backlog.",
},
index: true,
},
{
name: "sprint",
type: "relationship",
relationTo: "sprints",
admin: {
description: "Sprint this ticket is assigned to.",
},
},
{
name: "release",
type: "relationship",
relationTo: "releases",
admin: {
description: "Release this ticket targets.",
},
},
{
name: "parentTicket",
type: "relationship",
relationTo: "tickets",
admin: {
description: "Parent ticket (for subtasks / epic children).",
},
},
{
name: "labels",
type: "relationship",
relationTo: "labels",
hasMany: true,
admin: {
description: "Labels for filtering and organization.",
},
},
{
name: "type",
type: "select",
defaultValue: "task",
options: [
{ label: "Task", value: "task" },
{ label: "Bug", value: "bug" },
{ label: "Story", value: "story" },
{ label: "Epic", value: "epic" },
{ label: "Improvement", value: "improvement" },
],
admin: {
description: "Issue type.",
},
},
{
name: "storyPoints",
type: "number",
min: 0,
admin: {
description: "Fibonacci story point estimate.",
},
},
{
name: "estimate",
type: "number",
min: 0,
admin: {
description: "Time estimate in hours.",
},
},
{
name: "startDate",
type: "date",
admin: {
description: "When work starts (for Gantt).",
date: { pickerAppearance: "dayAndTime" },
},
},
{
name: "dueDate",
type: "date",
admin: {
description: "When work is due (for Gantt).",
date: { pickerAppearance: "dayAndTime" },
},
},
{
name: "resolution",
type: "select",
options: [
{ label: "Fixed", value: "fixed" },
{ label: "Won't Fix", value: "wontfix" },
{ label: "Duplicate", value: "duplicate" },
{ label: "Invalid", value: "invalid" },
],
admin: {
description: "How the ticket was resolved.",
},
},
], ],
}; };

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload"; import { CollectionConfig } from "payload";
import { Award } from "@/payload-types"; import { Award } from "@/payload-types";
import { broadcastToUser } from "@/lib/realtime/bus";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const Profiles: CollectionConfig = { export const Profiles: CollectionConfig = {
@ -23,6 +24,16 @@ export const Profiles: CollectionConfig = {
return await hasPermission(req.payload, req.user, "profiles:delete"); return await hasPermission(req.payload, req.user, "profiles:delete");
}, },
}, },
hooks: {
afterChange: [
async ({ doc }) => {
const userId = typeof doc.user === "object" ? doc.user?.id : doc.user;
if (typeof userId === "number") {
broadcastToUser("profile-update", userId, { profileId: doc.id });
}
},
],
},
fields: [ fields: [
{ {
name: "user", name: "user",

View file

@ -5,7 +5,9 @@ import {
Backpack, Backpack,
Bolt, Bolt,
Crosshair, Crosshair,
Kanban,
LifeBuoy, LifeBuoy,
Search,
Send, Send,
Shield, Shield,
UserCog, UserCog,
@ -29,6 +31,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import XPDisplay from "@/components/frontend/blocks/XPDisplay"; import XPDisplay from "@/components/frontend/blocks/XPDisplay";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel"; import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import { Rank } from "@/payload-types";
const data = { const data = {
navMain: [ navMain: [
@ -47,6 +50,16 @@ const data = {
url: "/helpdesk", url: "/helpdesk",
icon: LifeBuoy, icon: LifeBuoy,
}, },
{
title: "Projects",
url: "/projects",
icon: Kanban,
},
{
title: "Search",
url: "/projects/search",
icon: Search,
},
], ],
navIntelligence: [ navIntelligence: [
{ {
@ -139,6 +152,7 @@ export function AppSidebar({
user: { user: {
displayName: string; displayName: string;
username: string; username: string;
rank: string;
}; };
xpLevel: ResolvedLevel; xpLevel: ResolvedLevel;
adminUrl?: string; adminUrl?: string;
@ -216,7 +230,9 @@ export function AppSidebar({
<SidebarFooter> <SidebarFooter>
<NavUser <NavUser
user={{ user={{
displayName: user?.displayName ?? "Not logged in", displayName: user?.displayName
? [user?.rank ? `(${user?.rank}.)` : null, user?.displayName].join(" ")
: "Not logged in",
username: user?.username ?? "", username: user?.username ?? "",
avatar: "", avatar: "",
}} }}

View file

@ -1,12 +1,42 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { SparkleIcon } from "lucide-react"; import { SparkleIcon } from "lucide-react";
import type { ResolvedLevel } from "@/utils/xp/resolveLevel"; import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
import { PROFILE_UPDATE_EVENT, type ProfileUpdateDetail } from "@/hooks/useGameTick";
const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => { const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
const { levelName, progress, xp, nextLevelXP } = xpLevel; const { levelName, progress, xp, nextLevelXP } = xpLevel;
const [xpGain, setXpGain] = useState<number | null>(null);
const [xpGainId, setXpGainId] = useState(0);
const lastGainRef = useRef<{ amount: number; at: number } | null>(null);
useEffect(() => {
let clearTimer: ReturnType<typeof setTimeout> | null = null;
const handleProfileUpdate = (event: Event) => {
const detail = (event as CustomEvent<ProfileUpdateDetail>).detail;
if (!detail || typeof detail.xpGained !== "number" || detail.xpGained <= 0) return;
const now = Date.now();
if (lastGainRef.current?.amount === detail.xpGained && now - lastGainRef.current.at < 500) {
return;
}
lastGainRef.current = { amount: detail.xpGained, at: now };
setXpGain(detail.xpGained);
setXpGainId((id) => id + 1);
if (clearTimer) clearTimeout(clearTimer);
clearTimer = setTimeout(() => setXpGain(null), 1500);
};
window.addEventListener(PROFILE_UPDATE_EVENT, handleProfileUpdate);
return () => {
if (clearTimer) clearTimeout(clearTimer);
window.removeEventListener(PROFILE_UPDATE_EVENT, handleProfileUpdate);
};
}, []);
return ( return (
<Item> <Item>
@ -15,7 +45,18 @@ const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
<SparkleIcon /> <SparkleIcon />
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<span className="uppercase">{levelName}</span> <span className="uppercase">{levelName}</span>
<span className="text-2xl font-bold uppercase">{xp.toLocaleString()}</span> <span className="relative text-2xl font-bold uppercase flex flex-row items-center">
{xp.toLocaleString()}
{xpGain !== null && (
<span
key={`${xpGain}-${xpGainId}`}
className="pointer-events-none absolute right-full top-0 mr-2 whitespace-nowrap text-sm font-bold normal-case text-emerald-400 animate-[xp-gain_1.5s_ease-out_forwards]"
aria-live="polite"
>
+{xpGain.toLocaleString()} XP
</span>
)}
</span>
</div> </div>
</ItemTitle> </ItemTitle>
</ItemHeader> </ItemHeader>

View file

@ -1,41 +1,14 @@
import Link from "next/link";
import type { Campaign, Map } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react"; import {
MissionCard,
type MissionCardProps,
} from "@/components/frontend/intelligence/MissionCard";
import { CrosshairIcon } from "lucide-react";
interface MissionBriefingProps { interface MissionBriefingProps {
mission: { readonly mission: MissionCardProps["mission"] | null;
id: number;
name: string;
codeName: string;
summary: string;
operationType: "main" | "side" | "external";
classification: {
missionType: string;
startDateTime: string;
estimatedDuration: number;
map: number | Map;
campaign: number | Campaign;
};
ownershipAndStatus: {
status: string;
};
missionRoles?: {
maxPlayers: number;
} | null;
} | null;
} }
const statusColors: Record<string, string> = {
Concept: "bg-muted text-muted-foreground",
Planning: "bg-muted text-muted-foreground",
Ready: "bg-blue-500/20 text-blue-400",
Scheduled: "bg-amber-500/20 text-amber-400",
Active: "bg-green-500/20 text-green-400",
Completed: "bg-emerald-500/20 text-emerald-400",
Cancelled: "bg-red-500/20 text-red-400",
};
export function MissionBriefing({ mission }: MissionBriefingProps) { export function MissionBriefing({ mission }: MissionBriefingProps) {
if (!mission) { if (!mission) {
return ( return (
@ -53,82 +26,5 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
); );
} }
const map = return <MissionCard mission={mission} showFullDetails={true} />;
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
const campaign =
typeof mission.classification.campaign === "object"
? (mission.classification.campaign as Campaign)
: null;
const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
const timeStr = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMins = mission.classification.estimatedDuration % 60;
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CrosshairIcon className="size-4 text-muted-foreground" />
<ItemTitle>Mission Briefing</ItemTitle>
</div>
<Link
href={`/intelligence/missions/${mission.id}`}
className="text-xs text-muted-foreground hover:text-primary transition-colors"
>
View details
</Link>
</ItemHeader>
<ItemContent className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</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"}`}
>
{mission.ownershipAndStatus.status}
</span>
</div>
<h3 className="text-base font-semibold">{mission.name}</h3>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CalendarDaysIcon className="size-3" />
{dateStr} at {timeStr}
</span>
<span className="flex items-center gap-1">
<ClockIcon className="size-3" />
{durationStr}
</span>
{map && (
<span className="flex items-center gap-1">
<MapPinIcon className="size-3" />
{map.name}
</span>
)}
{mission.missionRoles?.maxPlayers && (
<span className="flex items-center gap-1">
<UsersIcon className="size-3" />
{mission.missionRoles.maxPlayers} slots
</span>
)}
{campaign && <span className="text-primary">{campaign.name}</span>}
</div>
</ItemContent>
</Item>
);
} }

View file

@ -1,24 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Map } from "@/payload-types";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ChevronRightIcon, CrosshairIcon } from "lucide-react"; import {
MissionCard,
interface MissionEntry { type MissionCardProps,
id: number; } from "@/components/frontend/intelligence/MissionCard";
name: string; import { CrosshairIcon } from "lucide-react";
codeName: string;
classification: {
startDateTime: string;
map: number | Map;
campaign: number | Campaign;
};
ownershipAndStatus: {
status: string;
};
}
interface UpcomingMissionsProps { interface UpcomingMissionsProps {
missions: MissionEntry[]; readonly missions: MissionCardProps["mission"][];
} }
export function UpcomingMissions({ missions }: UpcomingMissionsProps) { export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
@ -47,56 +36,16 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
</div> </div>
<Link <Link
href="/intelligence/missions" href="/intelligence/missions"
className="text-xs text-muted-foreground hover:text-primary transition-colors" className="text-xs text-muted-foreground transition-colors hover:text-primary"
> >
View all View all
</Link> </Link>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{missions.map((mission) => { {missions.map((mission) => (
const startDate = new Date(mission.classification.startDateTime); <MissionCard key={mission.id} mission={mission} showFullDetails={false} />
const dateStr = startDate.toLocaleDateString("en-US", { ))}
month: "short",
day: "numeric",
});
const timeStr = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
const campaign =
typeof mission.classification.campaign === "object"
? (mission.classification.campaign as Campaign)
: null;
return (
<Link
key={mission.id}
href={`/intelligence/missions/${mission.id}`}
className="flex items-start gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors group"
>
<div className="flex flex-col items-center text-center min-w-[2.5rem]">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
{startDate.toLocaleDateString("en-US", { weekday: "short" })}
</span>
<span className="text-sm font-bold">{dateStr.split(" ")[1]}</span>
<span className="text-[10px] text-muted-foreground">{timeStr}</span>
</div>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="font-mono text-[10px] text-muted-foreground">
{mission.codeName}
</span>
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
{mission.name}
</span>
{campaign && (
<span className="text-[10px] text-muted-foreground">{campaign.name}</span>
)}
</div>
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity mt-1 shrink-0" />
</Link>
);
})}
</div> </div>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useState } from "react";
import type { FlappyStats } from "@/app/(frontend)/flappy/actions"; import type { FlappyStats } from "@/app/(frontend)/flappy/actions";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { FlappyBird } from "./FlappyBird"; import { FlappyBird } from "./FlappyBird";
import { FlappyLeaderboard } from "./FlappyLeaderboard"; import { FlappyLeaderboard } from "./FlappyLeaderboard";
@ -14,10 +15,16 @@ export function FlappyArena({
}) { }) {
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [playing, setPlaying] = useState(false);
const activePilots = useRealtimePresence("flappy", playing, true);
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
setPlaying(phase === "playing");
}, []);
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<FlappyBird initialStats={initialStats} onGameRecorded={() => setRefreshKey((k) => k + 1)} /> <FlappyBird initialStats={initialStats} onGameRecorded={() => setRefreshKey((k) => k + 1)} onPhaseChange={onPhaseChange} />
<FlappyLeaderboard refreshKey={refreshKey} currentUserId={currentUserId} /> <FlappyLeaderboard refreshKey={refreshKey} currentUserId={currentUserId} activePilots={activePilots} />
</div> </div>
); );
} }

View file

@ -1,8 +1,10 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions"; import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { playFlap, playHit, playXp } from "./sounds"; import { playFlap, playHit, playXp } from "./sounds";
const CANVAS_W = 420; const CANVAS_W = 420;
@ -324,10 +326,13 @@ function birdHitsPipe(birdX: number, birdY: number, pipe: Pipe): boolean {
export function FlappyBird({ export function FlappyBird({
initialStats: serverStats, initialStats: serverStats,
onGameRecorded, onGameRecorded,
onPhaseChange,
}: { }: {
initialStats: FlappyStats; initialStats: FlappyStats;
onGameRecorded?: () => void; onGameRecorded?: () => void;
onPhaseChange?: (phase: Phase) => void;
}) { }) {
const router = useRouter();
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const phaseRef = useRef<Phase>("ready"); const phaseRef = useRef<Phase>("ready");
@ -358,7 +363,8 @@ export function FlappyBird({
const setPhaseSafe = useCallback((p: Phase) => { const setPhaseSafe = useCallback((p: Phase) => {
phaseRef.current = p; phaseRef.current = p;
setPhase(p); setPhase(p);
}, []); onPhaseChange?.(p);
}, [onPhaseChange]);
const reset = useCallback(() => { const reset = useCallback(() => {
const g = gameRef.current; const g = gameRef.current;
@ -396,6 +402,12 @@ export function FlappyBird({
onGameRecorded?.(); onGameRecorded?.();
if (res.data.xpEarned > 0) { if (res.data.xpEarned > 0) {
playXp(); playXp();
window.dispatchEvent(
new CustomEvent(PROFILE_UPDATE_EVENT, {
detail: { reason: "xp", xpGained: res.data.xpEarned },
}),
);
window.setTimeout(() => router.refresh(), 1700);
} }
setLastRun({ setLastRun({
score: finalScore, score: finalScore,
@ -411,7 +423,7 @@ export function FlappyBird({
}); });
} }
}, },
[onGameRecorded], [onGameRecorded, router],
); );
const handleGameOverRef = useRef(handleGameOver); const handleGameOverRef = useRef(handleGameOver);

View file

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Trophy } from "lucide-react"; import { Trophy } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
interface ApiUser { interface ApiUser {
id: number; id: number;
@ -35,11 +36,13 @@ interface LeaderboardRow {
bestScore: number; bestScore: number;
xpEarned: number; xpEarned: number;
isCurrentUser: boolean; isCurrentUser: boolean;
isOutsideTopTen?: boolean;
} }
interface FlappyLeaderboardProps { interface FlappyLeaderboardProps {
refreshKey?: number; refreshKey?: number;
currentUserId?: number | string; currentUserId?: number | string;
activePilots?: PresenceParticipant[];
} }
function rankClass(index: number) { function rankClass(index: number) {
@ -49,7 +52,7 @@ function rankClass(index: number) {
return "text-muted-foreground"; return "text-muted-foreground";
} }
export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeaderboardProps) { export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots = [] }: FlappyLeaderboardProps) {
const [rows, setRows] = useState<LeaderboardRow[] | null>(null); const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@ -58,9 +61,10 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
setFailed(false); setFailed(false);
const params = new URLSearchParams({ const params = new URLSearchParams({
"where[progression.minigames.flappyBird.bestScore][greater_than]": "0",
sort: "-progression.minigames.flappyBird.bestScore", sort: "-progression.minigames.flappyBird.bestScore",
limit: "10", // Fetch enough profiles to keep the current pilot visible even when
// they have not completed a scored run yet.
limit: "100",
depth: "1", depth: "1",
}); });
@ -71,8 +75,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
}) })
.then((data) => { .then((data) => {
if (cancelled) return; if (cancelled) return;
setRows( const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
(data.docs ?? []).map((doc) => {
const flappy = doc.progression?.minigames?.flappyBird ?? {}; const flappy = doc.progression?.minigames?.flappyBird ?? {};
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null; const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
const name = const name =
@ -86,8 +89,14 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
xpEarned: flappy.xpEarned ?? 0, xpEarned: flappy.xpEarned ?? 0,
isCurrentUser: user ? String(user.id) === String(currentUserId) : false, isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
}; };
}), });
); const scored = mapped.filter((row) => row.bestScore > 0);
const visible = scored.slice(0, 10);
const current = mapped.find((row) => row.isCurrentUser);
if (current && !visible.some((row) => row.id === current.id)) {
visible.push({ ...current, isOutsideTopTen: true });
}
setRows(visible);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setFailed(true); if (!cancelled) setFailed(true);
@ -105,6 +114,13 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
<h2 className="text-sm font-semibold">Flight Simulator Leaderboard</h2> <h2 className="text-sm font-semibold">Flight Simulator Leaderboard</h2>
<span className="ml-auto text-xs text-muted-foreground">Top 10 · best run (bird-free)</span> <span className="ml-auto text-xs text-muted-foreground">Top 10 · best run (bird-free)</span>
</div> </div>
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
<span className="font-medium text-emerald-400">{activePilots.length}</span>{" "}
{activePilots.length === 1 ? "pilot is" : "pilots are"} flying now
{activePilots.length > 0 && (
<span className="ml-1 text-foreground">({activePilots.map((pilot) => pilot.name).join(", ")})</span>
)}
</div>
{failed ? ( {failed ? (
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p> <p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
@ -132,9 +148,16 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{rows.map((row, i) => ( {rows.map((row, i) => (
<tr key={row.id} className={cn(row.isCurrentUser && "bg-accent/40")}> <tr
key={row.id}
className={cn(
row.isCurrentUser && "bg-accent/40",
row.isOutsideTopTen && "text-muted-foreground opacity-70",
)}
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)}>{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">
@ -142,6 +165,14 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
{row.isCurrentUser && ( {row.isCurrentUser && (
<span className="shrink-0 text-xs text-muted-foreground">(you)</span> <span className="shrink-0 text-xs text-muted-foreground">(you)</span>
)} )}
{row.isOutsideTopTen && (
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
unranked
</span>
)}
{activePilots.some((pilot) => pilot.id === row.id) && (
<span className="size-2 rounded-full bg-emerald-400" title="Playing now" aria-label="Playing now" />
)}
</span> </span>
</td> </td>
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground"> <td className="px-2 py-2 text-right tabular-nums text-muted-foreground">

View file

@ -37,6 +37,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface CreateTicketDialogProps { interface CreateTicketDialogProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
projectId?: number;
} }
function isTicketCategory(value: string): value is TicketCategory { function isTicketCategory(value: string): value is TicketCategory {
@ -50,6 +51,7 @@ function isTicketPriority(value: string): value is TicketPriority {
export function CreateTicketDialog({ export function CreateTicketDialog({
open: externalOpen, open: externalOpen,
onOpenChange: externalOnOpenChange, onOpenChange: externalOnOpenChange,
projectId,
}: CreateTicketDialogProps = {}) { }: CreateTicketDialogProps = {}) {
const router = useRouter(); const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@ -90,6 +92,7 @@ export function CreateTicketDialog({
description: trimmedDescription, description: trimmedDescription,
category, category,
priority, priority,
projectId,
}); });
if (result.success) { if (result.success) {

View file

@ -1,7 +1,13 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Map } from "@/payload-types"; import type { Campaign, Map, Media } from "@/payload-types";
import { cn } from "@/lib/utils";
import {
operationTypeColors,
operationTypeLabels,
statusColors,
} from "@/lib/intelligence/missionColors";
import { import {
Item, Item,
ItemContent, ItemContent,
@ -13,62 +19,63 @@ import {
import { import {
CalendarDaysIcon, CalendarDaysIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon,
CrosshairIcon, CrosshairIcon,
MapPinIcon, MapPinIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
interface MissionCardProps { export interface MissionCardProps {
mission: { readonly mission: {
id: number; readonly id: number;
name: string; readonly name: string;
codeName: string; readonly codeName: string;
summary: string; readonly summary: string;
operationType: "main" | "side" | "external"; readonly operationType: "main" | "side" | "external";
classification: { readonly classification: {
missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special"; readonly missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special";
startDateTime: string; readonly startDateTime: string;
estimatedDuration: number; readonly estimatedDuration: number;
map: number | Map; readonly map: number | Map;
campaign: number | Campaign; readonly campaign: number | Campaign;
}; };
ownershipAndStatus: { readonly ownershipAndStatus: {
status: "Concept" | "Planning" | "Ready" | "Scheduled" | "Active" | "Completed" | "Cancelled"; readonly status:
| "Concept"
| "Planning"
| "Ready"
| "Scheduled"
| "Active"
| "Completed"
| "Cancelled";
}; };
missionRoles?: { readonly missionRoles?: {
maxPlayers: number; readonly maxPlayers: number;
} | null; } | null;
readonly coverImage?: number | Media | null;
}; };
readonly variant?: "default" | "image";
readonly showFullDetails?: boolean;
} }
const statusColors: Record<string, string> = { export function MissionCard({
Concept: "bg-muted text-muted-foreground", mission,
Planning: "bg-muted text-muted-foreground", variant,
Ready: "bg-blue-500/20 text-blue-400", showFullDetails = false,
Scheduled: "bg-amber-500/20 text-amber-400", }: MissionCardProps) {
Active: "bg-green-500/20 text-green-400", const resolvedVariant = variant ?? (mission.coverImage ? "image" : "default");
Completed: "bg-emerald-500/20 text-emerald-400", const coverImage =
Cancelled: "bg-red-500/20 text-red-400", typeof mission.coverImage === "object" && mission.coverImage !== null
}; ? mission.coverImage
: null;
const operationTypeColors: Record<string, string> = { const coverImageUrl = coverImage?.url ?? null;
main: "bg-blue-500/20 text-blue-400",
side: "bg-muted text-muted-foreground",
external: "bg-amber-500/20 text-amber-400",
};
const operationTypeLabels: Record<string, string> = {
main: "Main",
side: "Side",
external: "External",
};
export function MissionCard({ mission }: MissionCardProps) {
const map = const map =
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null; typeof mission.classification.map === "object" && mission.classification.map !== null
? mission.classification.map
: null;
const campaign = const campaign =
typeof mission.classification.campaign === "object" typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
? (mission.classification.campaign as Campaign) ? mission.classification.campaign
: null; : null;
const startDate = new Date(mission.classification.startDateTime); const startDate = new Date(mission.classification.startDateTime);
@ -81,14 +88,36 @@ export function MissionCard({ mission }: MissionCardProps) {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMinutes = mission.classification.estimatedDuration % 60;
const durationLabel =
durationMinutes > 0 ? `${durationHours}h ${durationMinutes}m` : `${durationHours}h`;
const imageCard = resolvedVariant === "image" && coverImageUrl !== null;
return ( return (
<Link href={`/intelligence/missions/${mission.id}`}> <Link
href={`/intelligence/missions/${mission.id}`}
className="block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]" className={cn(
"group relative cursor-pointer overflow-hidden transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]",
imageCard &&
"border border-transparent hover:border-white/75",
)}
> >
<ItemHeader> {resolvedVariant === "image" && coverImageUrl !== null && (
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-25 after:absolute after:inset-0 after:z-10 after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:transition-opacity group-hover:after:opacity-0">
<img
src={coverImageUrl}
alt=""
aria-hidden="true"
className="h-full w-full aspect-video object-cover object-center"
/>
</div>
)}
<ItemHeader className="relative z-20">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<CrosshairIcon /> <CrosshairIcon />
@ -111,32 +140,37 @@ export function MissionCard({ mission }: MissionCardProps) {
</span> </span>
</div> </div>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent className="relative z-20 w-full">
<div className="flex flex-col gap-3 w-full"> <ItemDescription className={showFullDetails ? "line-clamp-2" : "line-clamp-1"}>
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p> {mission.summary}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"> </ItemDescription>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CalendarDaysIcon className="size-3" />
{dateStr} at {timeStr}
</span>
{showFullDetails && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CalendarDaysIcon className="size-3" /> <ClockIcon className="size-3" />
{dateStr} at {timeStr} {durationLabel}
</span> </span>
{map && ( )}
<span className="flex items-center gap-1"> {map && (
<MapPinIcon className="size-3" /> <span className="flex items-center gap-1">
{map.name} <MapPinIcon className="size-3" />
</span> {map.name}
)} </span>
{mission.missionRoles?.maxPlayers && ( )}
<span className="flex items-center gap-1"> {mission.missionRoles?.maxPlayers && (
<UsersIcon className="size-3" /> <span className="flex items-center gap-1">
{mission.missionRoles.maxPlayers} slots <UsersIcon className="size-3" />
</span> {mission.missionRoles.maxPlayers} slots
)} </span>
{campaign && <span className="text-primary">{campaign.name}</span>} )}
</div> {campaign && <span className="text-primary">{campaign.name}</span>}
</div> </div>
</ItemContent> </ItemContent>
<ItemDescription>{mission.summary}</ItemDescription> <span className="relative z-20 flex items-center gap-1 text-xs text-muted-foreground transition-colors group-hover:text-primary">
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" /> View details <ChevronRightIcon className="size-3" />
</span> </span>
</Item> </Item>

View file

@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { notificationLabel } from "@/lib/notifications"; import { notificationLabel } from "@/lib/notifications";
import { NOTIFICATION_EVENT } from "@/hooks/useGameTick";
import { import {
markAllNotificationsRead, markAllNotificationsRead,
markNotificationsRead, markNotificationsRead,
@ -41,7 +42,7 @@ function timeAgo(iso: string): string {
return `${days}d ago`; return `${days}d ago`;
} }
const POLL_MS = 30_000; const POLL_MS = 60_000;
export function NotificationsBell() { export function NotificationsBell() {
const router = useRouter(); const router = useRouter();
@ -74,8 +75,11 @@ export function NotificationsBell() {
useEffect(() => { useEffect(() => {
void fetchNotifications(); void fetchNotifications();
intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS); intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS);
const onNotification = () => void fetchNotifications();
window.addEventListener(NOTIFICATION_EVENT, onNotification);
return () => { return () => {
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
window.removeEventListener(NOTIFICATION_EVENT, onNotification);
}; };
}, [fetchNotifications]); }, [fetchNotifications]);

View file

@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { createProject } from "@/app/(frontend)/projects/actions";
interface CreateProjectDialogProps {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}
export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
const router = useRouter();
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [description, setDescription] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setName("");
setKey("");
setDescription("");
setPending(false);
setError(null);
}
async function handleSubmit() {
const trimmedName = name.trim();
const trimmedKey = key.trim().toUpperCase();
if (!trimmedName) {
setError("Project name is required.");
return;
}
if (!trimmedKey) {
setError("Project key is required.");
return;
}
setPending(true);
setError(null);
try {
const result = await createProject({
name: trimmedName,
key: trimmedKey,
description: description.trim(),
});
if (!result.success) {
setError(result.error ?? "Could not create the project.");
setPending(false);
return;
}
onOpenChange(false);
reset();
toast.success("Project created");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not create the project.");
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
<DialogDescription>Set up a project space for tickets, sprints, and releases.</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="project-name">Name</Label>
<Input
id="project-name"
placeholder="Project name"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="project-key">Key</Label>
<Input
id="project-key"
placeholder="POLARIS"
maxLength={10}
value={key}
onChange={(event) => setKey(event.target.value.toUpperCase().slice(0, 10))}
disabled={pending}
className="font-mono uppercase"
/>
<p className="text-xs text-muted-foreground">Up to 10 characters. Keys are stored in uppercase.</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="project-description">Description</Label>
<Textarea
id="project-description"
rows={4}
placeholder="What is this project about?"
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { createRelease } from "@/app/(frontend)/projects/actions";
interface CreateReleaseDialogProps {
readonly projectId: number;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}
export function CreateReleaseDialog({ projectId, open, onOpenChange }: CreateReleaseDialogProps) {
const router = useRouter();
const [name, setName] = useState("");
const [targetVersion, setTargetVersion] = useState("");
const [description, setDescription] = useState("");
const [releaseDate, setReleaseDate] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setName("");
setTargetVersion("");
setDescription("");
setReleaseDate("");
setPending(false);
setError(null);
}
async function handleSubmit() {
const trimmedName = name.trim();
if (!trimmedName) {
setError("Release name is required.");
return;
}
setPending(true);
setError(null);
try {
const result = await createRelease({
projectId,
name: trimmedName,
targetVersion: targetVersion.trim() || undefined,
description: description.trim() || undefined,
releaseDate: releaseDate || undefined,
});
if (!result.success) {
setError(result.error ?? "Could not create the release.");
setPending(false);
return;
}
onOpenChange(false);
reset();
toast.success("Release created");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not create the release.");
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Create release</DialogTitle>
<DialogDescription>Track a version milestone for this project.</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="release-name">Name</Label>
<Input
id="release-name"
placeholder="Operation Nightfall"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="release-version">Target version</Label>
<Input
id="release-version"
placeholder="v1.0.0"
value={targetVersion}
onChange={(event) => setTargetVersion(event.target.value)}
disabled={pending}
className="font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="release-date">Release date</Label>
<Input
id="release-date"
type="date"
value={releaseDate}
onChange={(event) => setReleaseDate(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="release-description">Description</Label>
<Textarea
id="release-description"
rows={4}
placeholder="What belongs in this release?"
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create release"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { createSprint } from "@/app/(frontend)/projects/actions";
interface CreateSprintDialogProps {
readonly projectId: number;
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}
export function CreateSprintDialog({ projectId, open, onOpenChange }: CreateSprintDialogProps) {
const router = useRouter();
const [name, setName] = useState("");
const [goal, setGoal] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [capacity, setCapacity] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setName("");
setGoal("");
setStartDate("");
setEndDate("");
setCapacity("");
setPending(false);
setError(null);
}
async function handleSubmit() {
const trimmedName = name.trim();
const parsedCapacity = capacity ? Number(capacity) : null;
if (!trimmedName) {
setError("Sprint name is required.");
return;
}
if (parsedCapacity !== null && (!Number.isInteger(parsedCapacity) || parsedCapacity < 0)) {
setError("Capacity must be a whole number of points.");
return;
}
setPending(true);
setError(null);
try {
const result = await createSprint({
projectId,
name: trimmedName,
goal: goal.trim() || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
capacity: parsedCapacity ?? undefined,
});
if (!result.success) {
setError(result.error ?? "Could not create the sprint.");
setPending(false);
return;
}
onOpenChange(false);
reset();
toast.success("Sprint created");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not create the sprint.");
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Create sprint</DialogTitle>
<DialogDescription>Define a focused delivery window for this project.</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sprint-name">Name</Label>
<Input
id="sprint-name"
placeholder="Sprint 01"
value={name}
onChange={(event) => setName(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sprint-goal">Goal</Label>
<Textarea
id="sprint-goal"
rows={3}
placeholder="What should this sprint deliver?"
value={goal}
onChange={(event) => setGoal(event.target.value)}
disabled={pending}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="sprint-start">Start date</Label>
<Input
id="sprint-start"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sprint-end">End date</Label>
<Input
id="sprint-end"
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
disabled={pending}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sprint-capacity">Capacity</Label>
<Input
id="sprint-capacity"
type="number"
min={0}
step={1}
placeholder="40"
value={capacity}
onChange={(event) => setCapacity(event.target.value)}
disabled={pending}
/>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create sprint"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { useMemo, useState } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent, UniqueIdentifier } from "@dnd-kit/core";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FilterIcon } from "lucide-react";
import { updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
import { TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
import { KanbanColumn } from "./KanbanColumn";
import { KanbanFilters } from "./KanbanFilters";
import { KanbanTicketOverlay } from "./KanbanTicketCard";
export interface KanbanTicket {
readonly id: number;
readonly title: string;
readonly status: string;
readonly priority: string;
readonly type: string;
readonly storyPoints?: number | null;
readonly sprintId?: number | null;
readonly sprint?: { readonly id: number } | number | null;
readonly labels: readonly { id: number; name: string; color: string }[];
readonly assignee: { id: number; displayName: string } | null;
readonly reporter: { id: number; displayName: string } | null;
}
export interface KanbanColumn {
readonly status: string;
readonly label: string;
readonly tickets: readonly KanbanTicket[];
}
export type SprintFilter = "all" | number;
export interface KanbanFiltersProps {
readonly sprints: readonly { id: number; name: string; status: string; startDate?: string; endDate?: string }[];
readonly labels: readonly { id: number; name: string; color: string; project?: number | null }[];
readonly sprintFilter: SprintFilter;
readonly selectedLabelIds: readonly number[];
readonly onSprintFilterChange: (value: SprintFilter) => void;
readonly onLabelToggle: (labelId: number) => void;
}
export interface KanbanBoardProps {
readonly project: { id: number; name: string; key: string };
readonly columns: readonly KanbanColumn[];
readonly sprints: readonly { id: number; name: string; status: string; startDate?: string; endDate?: string }[];
readonly labels: readonly { id: number; name: string; color: string; project?: number | null }[];
readonly currentUserId: number;
readonly isAdmin: boolean;
readonly showFilters?: boolean;
readonly sprintFilter?: SprintFilter;
readonly selectedLabelIds?: readonly number[];
readonly onSprintFilterChange?: (value: SprintFilter) => void;
readonly onLabelToggle?: (labelId: number) => void;
}
function toNumber(value: UniqueIdentifier): number | null {
if (typeof value === "number") return Number.isInteger(value) ? value : null;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}
function toTicketStatus(value: UniqueIdentifier): TicketStatus | null {
const status = String(value);
return TICKET_STATUSES.some((option) => option.value === status)
? TICKET_STATUSES.find((option) => option.value === status)?.value ?? null
: null;
}
function ticketSprintId(ticket: KanbanTicket): number | null {
if (ticket.sprintId != null) return ticket.sprintId;
if (typeof ticket.sprint === "number") return ticket.sprint;
return ticket.sprint?.id ?? null;
}
export function KanbanBoard({
project,
columns,
sprints,
labels,
currentUserId: _currentUserId,
isAdmin: _isAdmin,
showFilters = true,
sprintFilter: controlledSprintFilter,
selectedLabelIds: controlledLabelIds,
onSprintFilterChange,
onLabelToggle,
}: KanbanBoardProps) {
const router = useRouter();
const [internalSprintFilter, setInternalSprintFilter] = useState<SprintFilter>("all");
const [internalLabelIds, setInternalLabelIds] = useState<readonly number[]>([]);
const [activeTicket, setActiveTicket] = useState<KanbanTicket | null>(null);
const sprintFilter = controlledSprintFilter ?? internalSprintFilter;
const selectedLabelIds = controlledLabelIds ?? internalLabelIds;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor),
);
const allTickets = useMemo(() => columns.flatMap((column) => column.tickets), [columns]);
const ticketsById = useMemo(() => new Map(allTickets.map((ticket) => [ticket.id, ticket])), [allTickets]);
const filteredTicketsByColumn = useMemo(
() =>
new Map(
columns.map((column) => [
column.status,
column.tickets.filter((ticket) => {
const matchesSprint = sprintFilter === "all" || ticketSprintId(ticket) === sprintFilter;
const matchesLabels =
selectedLabelIds.length === 0 || ticket.labels.some((label) => selectedLabelIds.includes(label.id));
return matchesSprint && matchesLabels;
}),
]),
),
[columns, selectedLabelIds, sprintFilter],
);
function setSprintFilter(value: SprintFilter) {
onSprintFilterChange?.(value);
if (!onSprintFilterChange) setInternalSprintFilter(value);
}
function toggleLabel(labelId: number) {
onLabelToggle?.(labelId);
if (!onLabelToggle) {
setInternalLabelIds((current) =>
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
);
}
}
function handleDragStart(event: DragStartEvent) {
const ticketId = toNumber(event.active.id);
setActiveTicket(ticketId === null ? null : (ticketsById.get(ticketId) ?? null));
}
async function handleDragEnd(event: DragEndEvent) {
const ticketId = toNumber(event.active.id);
const nextStatus = event.over ? toTicketStatus(event.over.id) : null;
setActiveTicket(null);
if (ticketId === null || nextStatus === null) return;
const ticket = ticketsById.get(ticketId);
if (!ticket || ticket.status === nextStatus) return;
try {
const result = await updateTicketStatus(ticketId, nextStatus);
if (!result.success) {
toast.error(result.error ?? "Could not update the ticket status.");
return;
}
toast.success(`Ticket moved to ${nextStatus.replaceAll("_", " ")}`);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not update the ticket status.");
}
}
return (
<div className="flex min-w-0 flex-col gap-4" aria-label={`${project.name} Kanban board`}>
{showFilters && (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FilterIcon className="size-4" data-icon="inline-start" />
<span>Board filters</span>
</div>
<KanbanFilters
sprints={sprints}
labels={labels}
sprintFilter={sprintFilter}
selectedLabelIds={selectedLabelIds}
onSprintFilterChange={setSprintFilter}
onLabelToggle={toggleLabel}
/>
</div>
)}
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={(event) => void handleDragEnd(event)} onDragCancel={() => setActiveTicket(null)}>
<div className="flex min-w-0 gap-4 overflow-x-auto pb-2" role="region" aria-label="Kanban columns" tabIndex={0}>
{columns.map((column) => (
<KanbanColumn key={column.status} column={column} tickets={filteredTicketsByColumn.get(column.status) ?? []} />
))}
</div>
<DragOverlay>{activeTicket ? <KanbanTicketOverlay ticket={activeTicket} /> : null}</DragOverlay>
</DndContext>
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { KanbanTicketCard } from "./KanbanTicketCard";
import type { KanbanColumn as KanbanColumnData, KanbanTicket } from "./KanbanBoard";
interface KanbanColumnProps {
readonly column: KanbanColumnData;
readonly tickets: readonly KanbanTicket[];
}
export function KanbanColumn({ column, tickets }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: column.status });
return (
<section
ref={setNodeRef}
aria-label={`${column.label} column`}
className={cn(
"flex min-h-[24rem] min-w-[300px] w-[300px] flex-shrink-0 flex-col rounded-lg border bg-muted/20 p-3 transition-colors",
isOver && "border-primary ring-2 ring-primary/30",
)}
>
<header className="mb-3 flex items-center justify-between gap-2">
<h3 className="truncate text-sm font-semibold">{column.label}</h3>
<Badge variant="secondary" className="font-mono text-[10px]">
{tickets.length}
</Badge>
</header>
<div className="flex flex-1 flex-col gap-2" role="list" aria-label={`${column.label} tickets`}>
{tickets.map((ticket) => (
<KanbanTicketCard key={ticket.id} ticket={ticket} />
))}
{tickets.length === 0 && (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed border-border/70 p-4 text-center text-xs text-muted-foreground">
Drop a ticket here
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import { TagsIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
function sprintFilterValue(value: string): SprintFilter {
if (value === "all") return "all";
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : "all";
}
function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
const active = sprints.filter((sprint) => ["planned", "active", "in_progress"].includes(sprint.status));
return active.length > 0 ? active : sprints;
}
export function KanbanFilters({
sprints,
labels,
sprintFilter,
selectedLabelIds,
onSprintFilterChange,
onLabelToggle,
}: KanbanFiltersProps) {
const selectedLabelCount = selectedLabelIds.length;
const sprintOptions = activeSprints(sprints);
const sprintLabel =
sprintFilter === "all"
? "All sprints"
: sprintOptions.find((sprint) => sprint.id === sprintFilter)?.name;
return (
<div className="flex flex-wrap items-center gap-2">
<Select
value={String(sprintFilter)}
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
>
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint">
<SelectValue placeholder={sprintLabel ?? "Select sprint"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Sprint</SelectLabel>
<SelectItem value="all">All sprints</SelectItem>
{sprintOptions.map((sprint) => (
<SelectItem key={sprint.id} value={String(sprint.id)}>
{sprint.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Filter by label">
<TagsIcon data-icon="inline-start" />
Labels
{selectedLabelCount > 0 && <Badge variant="secondary">{selectedLabelCount}</Badge>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
<DropdownMenuSeparator />
{labels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
) : (
labels.map((label) => (
<DropdownMenuCheckboxItem
key={label.id}
checked={selectedLabelIds.includes(label.id)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={() => onLabelToggle(label.id)}
>
<span
className="mr-2 size-2 rounded-full border border-border"
style={{ backgroundColor: label.color }}
/>
{label.name}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Item,
ItemContent,
ItemFooter,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import {
PRIORITY_LABEL,
PRIORITY_STYLES,
STATUS_LABEL,
STATUS_STYLES,
type TicketPriority,
type TicketStatus,
} from "@/lib/tickets/ticketMeta";
import { TYPE_LABEL, TYPE_STYLES } from "@/lib/projects/projectMeta";
import { cn } from "@/lib/utils";
import type { KanbanTicket } from "./KanbanBoard";
interface KanbanTicketCardProps {
readonly ticket: KanbanTicket;
readonly overlay?: boolean;
}
function isTicketStatus(value: string): value is TicketStatus {
return value in STATUS_STYLES;
}
function isTicketPriority(value: string): value is TicketPriority {
return value in PRIORITY_STYLES;
}
function initials(displayName: string): string {
const parts = displayName.trim().split(/\s+/).filter(Boolean);
const first = parts[0]?.[0] ?? "?";
const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "";
return `${first}${last}`.toUpperCase();
}
function badgeStyle(value: string, styles: Readonly<Record<string, string>>): string {
return styles[value] ?? "bg-muted/50 text-muted-foreground border-border";
}
function ticketTypeLabel(type: string): string {
return TYPE_LABEL[type] ?? type.replaceAll("_", " ");
}
function KanbanTicketCardBody({ ticket, overlay }: KanbanTicketCardProps) {
const priorityLabel = isTicketPriority(ticket.priority)
? PRIORITY_LABEL[ticket.priority]
: ticket.priority;
const statusStyle = isTicketStatus(ticket.status) ? STATUS_STYLES[ticket.status] : "";
const statusLabel = isTicketStatus(ticket.status)
? STATUS_LABEL[ticket.status]
: ticket.status.replaceAll("_", " ");
return (
<Item
variant="outline"
size="sm"
className={cn(
"items-start gap-3 bg-background/50 transition-colors hover:bg-accent/50",
overlay && "opacity-90 shadow-lg",
)}
>
<ItemHeader className="items-center">
<Badge
variant="outline"
className={cn(
"text-[10px] uppercase tracking-wide",
TYPE_STYLES[ticket.type] ?? "bg-muted/50 text-muted-foreground border-border",
)}
>
{ticketTypeLabel(ticket.type)}
</Badge>
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
</ItemHeader>
<ItemContent className="w-full min-w-0 gap-2">
<ItemTitle className="w-full min-w-0 text-sm font-semibold">
<Link href={`/helpdesk/${ticket.id}`} className="line-clamp-2 hover:underline">
{ticket.title}
</Link>
</ItemTitle>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className={cn("text-[10px] uppercase tracking-wide", statusStyle)}>
{statusLabel}
</Badge>
<Badge
variant="outline"
className={cn("text-[10px] uppercase tracking-wide", badgeStyle(ticket.priority, PRIORITY_STYLES))}
>
{priorityLabel}
</Badge>
{ticket.storyPoints != null && (
<Badge variant="secondary" className="font-mono text-[10px]">
{ticket.storyPoints} pts
</Badge>
)}
</div>
</ItemContent>
<ItemFooter className="w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className="flex min-w-0 flex-1 items-center gap-2">
{ticket.assignee ? (
<>
<Avatar className="size-5">
<AvatarFallback className="text-[9px]">{initials(ticket.assignee.displayName)}</AvatarFallback>
</Avatar>
<span className="truncate">{ticket.assignee.displayName}</span>
</>
) : (
<span>Unassigned</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1" aria-label="Ticket labels">
{ticket.labels.map((label) => (
<span
key={label.id}
title={label.name}
aria-label={label.name}
className="size-2 rounded-full border border-border"
style={{ backgroundColor: label.color }}
/>
))}
</div>
{ticket.reporter && <span className="max-w-full truncate">reported by {ticket.reporter.displayName}</span>}
</ItemFooter>
</Item>
);
}
export function KanbanTicketCard({ ticket, overlay = false }: KanbanTicketCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: ticket.id });
const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={cn("touch-none outline-none", isDragging && "opacity-40")}
>
<KanbanTicketCardBody ticket={ticket} overlay={overlay} />
</div>
);
}
export function KanbanTicketOverlay({ ticket }: { readonly ticket: KanbanTicket }) {
return (
<div className="touch-none">
<KanbanTicketCardBody ticket={ticket} overlay />
</div>
);
}

View file

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowLeftIcon, ListPlusIcon, PlusIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog";
import {
KanbanBoard,
type KanbanColumn,
type SprintFilter,
} from "@/components/frontend/projects/KanbanBoard";
import { KanbanFilters } from "@/components/frontend/projects/KanbanFilters";
export interface ProjectBoardViewProps {
readonly project: { id: number; name: string; key: string };
readonly columns: readonly KanbanColumn[];
readonly sprints: readonly { id: number; name: string; status: string }[];
readonly labels: readonly { id: number; name: string; color: string }[];
readonly currentUserId: number;
readonly isAdmin: boolean;
}
export function ProjectBoardView({
project,
columns,
sprints,
labels,
currentUserId,
isAdmin,
}: ProjectBoardViewProps) {
const [createTicketOpen, setCreateTicketOpen] = useState(false);
const [sprintFilter, setSprintFilter] = useState<SprintFilter>("all");
const [selectedLabelIds, setSelectedLabelIds] = useState<readonly number[]>([]);
function toggleLabel(labelId: number) {
setSelectedLabelIds((current) =>
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
);
}
return (
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-3">
<Button asChild variant="ghost" size="sm" className="w-fit px-0 text-muted-foreground hover:text-foreground">
<Link href="/projects">
<ArrowLeftIcon data-icon="inline-start" />
Projects
</Link>
</Button>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-lg font-semibold">{project.name}</h1>
<Badge variant="outline" className="font-mono text-[10px]">
{project.key}
</Badge>
</div>
<p className="text-sm text-muted-foreground">Organize tickets across the project workflow.</p>
</div>
</div>
</div>
<Button size="sm" onClick={() => setCreateTicketOpen(true)}>
<PlusIcon data-icon="inline-start" />
New ticket
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-y border-border/70 py-3">
<div className="flex items-center gap-2 text-sm font-medium">
<ListPlusIcon className="size-4 text-muted-foreground" />
Board filters
</div>
<KanbanFilters
sprints={sprints}
labels={labels}
sprintFilter={sprintFilter}
selectedLabelIds={selectedLabelIds}
onSprintFilterChange={setSprintFilter}
onLabelToggle={toggleLabel}
/>
</div>
<KanbanBoard
project={project}
columns={columns}
sprints={sprints}
labels={labels}
currentUserId={currentUserId}
isAdmin={isAdmin}
showFilters={false}
sprintFilter={sprintFilter}
selectedLabelIds={selectedLabelIds}
onSprintFilterChange={setSprintFilter}
onLabelToggle={toggleLabel}
/>
<CreateTicketDialog open={createTicketOpen} onOpenChange={setCreateTicketOpen} projectId={project.id} />
</div>
);
}

View file

@ -0,0 +1,280 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { BadgeIcon, SearchIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface SearchTicket {
readonly id: number;
readonly title: string;
readonly status: string;
readonly priority: string;
readonly type: string;
readonly category?: string;
readonly storyPoints?: number | null;
readonly project: { readonly id: number; readonly name: string; readonly key: string };
readonly labels: readonly { id: number; name: string; color: string }[];
readonly assignee: { id: number; displayName: string } | null;
readonly href: string;
}
export interface LabelOption {
readonly id: number;
readonly name: string;
readonly color: string;
}
export interface ProjectSearchViewProps {
readonly tickets: readonly SearchTicket[];
readonly allLabels: readonly LabelOption[];
}
const STATUS_STYLES: Readonly<Record<string, string>> = {
open: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30",
in_review: "bg-amber-500/15 text-amber-400 border-amber-500/30",
in_testing: "bg-purple-500/15 text-purple-400 border-purple-500/30",
resolved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
};
const PRIORITY_STYLES: Readonly<Record<string, string>> = {
low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
medium: "bg-blue-500/15 text-blue-400 border-blue-500/30",
high: "bg-amber-500/15 text-amber-400 border-amber-500/30",
critical: "bg-red-500/15 text-red-400 border-red-500/30",
};
function labelFor(value: string): string {
return value
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps) {
const [search, setSearch] = useState("");
const [selectedLabelIds, setSelectedLabelIds] = useState<readonly number[]>([]);
const [status, setStatus] = useState("all");
const [type, setType] = useState("all");
function toggleLabel(labelId: number) {
setSelectedLabelIds((current) =>
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
);
}
const query = search.trim().toLowerCase();
const filteredTickets = useMemo(() => {
return tickets.filter((ticket) => {
const matchesSearch = !query
|| `${ticket.id} ${ticket.title} ${ticket.status} ${ticket.priority} ${ticket.type} ${ticket.category ?? ""} ${ticket.project.name} ${ticket.project.key} ${ticket.labels.map((label) => label.name).join(" ")}`
.toLowerCase()
.includes(query);
const matchesLabels =
selectedLabelIds.length === 0
|| ticket.labels.some((label) => selectedLabelIds.includes(label.id));
const matchesStatus = status === "all" || ticket.status === status;
const matchesType = type === "all" || ticket.type === type;
return matchesSearch && matchesLabels && matchesStatus && matchesType;
});
}, [tickets, query, selectedLabelIds, status, type]);
const groupedByProject = useMemo(() => {
const groups = new Map<string, { project: SearchTicket["project"]; tickets: SearchTicket[] }>();
for (const ticket of filteredTickets) {
const key = `${ticket.project.id}`;
const existing = groups.get(key);
if (existing) {
existing.tickets.push(ticket);
} else {
groups.set(key, { project: ticket.project, tickets: [ticket] });
}
}
return Array.from(groups.values()).sort((a, b) => a.project.name.localeCompare(b.project.name));
}, [filteredTickets]);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Cross-project search
</h2>
<p className="text-sm text-muted-foreground">
Search tickets across all projects and filter by label.
</p>
</div>
<div className="flex flex-col gap-3">
<div className="relative w-full lg:max-w-md">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search tickets"
className="pl-8"
placeholder="Search by title, project name, or key..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-[160px]" aria-label="Filter by status">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="planning">Planning</SelectItem>
<SelectItem value="in_progress">In progress</SelectItem>
<SelectItem value="in_review">In review</SelectItem>
<SelectItem value="waiting_on_reporter">Waiting on reporter</SelectItem>
<SelectItem value="in_testing">Testing</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={type} onValueChange={setType}>
<SelectTrigger className="w-[150px]" aria-label="Filter by issue type">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="task">Task</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="story">Story</SelectItem>
<SelectItem value="epic">Epic</SelectItem>
<SelectItem value="improvement">Improvement</SelectItem>
</SelectContent>
</Select>
</div>
{allLabels.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<BadgeIcon className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Filter by label:</span>
{allLabels.map((label) => (
<button
key={label.id}
type="button"
onClick={() => toggleLabel(label.id)}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors",
selectedLabelIds.includes(label.id)
? "border-foreground/30 bg-foreground/10 text-foreground"
: "border-border text-muted-foreground hover:bg-accent/50",
)}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</button>
))}
</div>
)}
</div>
{filteredTickets.length === 0 ? (
<Item variant="outline">
<ItemContent className="items-center gap-1">
<ItemTitle>{tickets.length === 0 ? "No tickets found" : "No matching tickets"}</ItemTitle>
<ItemDescription>
{tickets.length === 0
? "There are no project tickets yet."
: "Try a different search or label filter."}
</ItemDescription>
</ItemContent>
</Item>
) : (
<div className="flex flex-col gap-4" role="region" aria-label="Search results">
{groupedByProject.map(({ project, tickets: projectTickets }) => (
<div key={project.id} className="flex flex-col gap-2">
<Link
href={`/projects/${project.key}/board`}
className="group flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground"
>
<Badge variant="outline" className="font-mono text-[10px]">
{project.key}
</Badge>
<span className="group-hover:underline">{project.name}</span>
<span className="text-xs text-muted-foreground">({projectTickets.length})</span>
</Link>
<div className="flex flex-col gap-1 pl-4" role="list">
{projectTickets.map((ticket) => (
<Link href={ticket.href} key={ticket.id} className="block rounded-md">
<Item variant="outline" size="sm" role="listitem" className="transition-colors hover:bg-accent/50">
<ItemHeader className="items-center">
<div className="flex min-w-0 items-center gap-2">
<ItemTitle className="truncate text-sm font-medium">{ticket.title}</ItemTitle>
<Badge
variant="outline"
className={cn(
"shrink-0 text-[10px] uppercase tracking-wide",
STATUS_STYLES[ticket.status] ?? "bg-muted/50 text-muted-foreground border-border",
)}
>
{labelFor(ticket.status)}
</Badge>
<Badge
variant="outline"
className={cn(
"shrink-0 text-[10px] uppercase tracking-wide",
PRIORITY_STYLES[ticket.priority] ?? "bg-muted/50 text-muted-foreground border-border",
)}
>
{labelFor(ticket.priority)}
</Badge>
</div>
</ItemHeader>
<ItemContent className="w-full min-w-0 gap-1">
{ticket.labels.length > 0 && (
<div className="flex flex-wrap gap-1">
{ticket.labels.map((label) => (
<Badge
key={label.id}
variant="secondary"
className="text-[10px]"
>
<span
className="mr-1 size-1.5 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</Badge>
))}
</div>
)}
</ItemContent>
</Item>
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,178 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { FolderKanbanIcon, PlusIcon, SearchIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { CreateProjectDialog } from "@/components/frontend/projects/CreateProjectDialog";
import { cn } from "@/lib/utils";
export interface ProjectListItem {
readonly id: number;
readonly name: string;
readonly key: string;
readonly status: string;
readonly lead: { id: number; displayName: string } | null;
readonly description?: string | null;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface ProjectsViewProps {
readonly projects: readonly ProjectListItem[];
readonly currentUserId: number;
readonly isAdmin: boolean;
}
const PROJECT_STATUS_STYLES: Readonly<Record<string, string>> = {
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
planning: "bg-sky-500/15 text-sky-400 border-sky-500/30",
on_hold: "bg-amber-500/15 text-amber-400 border-amber-500/30",
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
archived: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
};
function labelFor(value: string): string {
return value
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function dateFor(value: string): string {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function ProjectsView({ projects, currentUserId: _currentUserId, isAdmin }: ProjectsViewProps) {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const query = search.trim().toLowerCase();
const filteredProjects = useMemo(
() =>
projects.filter((project) =>
query
? `${project.name} ${project.key} ${project.description ?? ""}`.toLowerCase().includes(query)
: true,
),
[projects, query],
);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Projects
</h2>
<span className="text-xs text-muted-foreground">{projects.length} total</span>
</div>
<p className="text-sm text-muted-foreground">
Plan work, track progress, and ship with your team.
</p>
</div>
{isAdmin && (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Create project
</Button>
)}
</div>
<div className="relative w-full lg:max-w-xs">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search projects"
className="pl-8"
placeholder="Search projects..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
{filteredProjects.length === 0 ? (
<Item variant="outline">
<ItemMedia variant="icon">
<FolderKanbanIcon className="text-muted-foreground" />
</ItemMedia>
<ItemContent className="items-center gap-1">
<ItemTitle>
{projects.length === 0 ? "No projects yet" : "No matching projects"}
</ItemTitle>
<ItemDescription>
{projects.length === 0
? "Create a project to start organizing your work."
: "Try a different search phrase."}
</ItemDescription>
</ItemContent>
</Item>
) : (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{filteredProjects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.key}/board`}
className="group block h-full"
>
<Item
variant="outline"
className="h-full min-h-44 items-start transition-colors group-hover:bg-accent/50"
>
<ItemHeader className="items-center">
<Badge variant="outline" className="font-mono text-[10px] tracking-wide">
{project.key}
</Badge>
<Badge
variant="outline"
className={cn(
"text-[10px] uppercase tracking-wide",
PROJECT_STATUS_STYLES[project.status] ??
"bg-muted/50 text-muted-foreground border-border",
)}
>
{labelFor(project.status)}
</Badge>
</ItemHeader>
<ItemContent className="w-full min-w-0 flex-1 gap-2">
<ItemTitle className="line-clamp-2 text-base font-semibold">
{project.name}
</ItemTitle>
{project.description && typeof project.description === "string" && (
<ItemDescription>{project.description}</ItemDescription>
)}
{!project.description && typeof project.description !== "string" && (
<ItemDescription>No description provided.</ItemDescription>
)}
</ItemContent>
<ItemFooter className="w-full flex-wrap items-end gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
Lead: {project.lead?.displayName ?? "Unassigned"}
</span>
<span className="shrink-0 font-mono text-[10px]">
Created {dateFor(project.createdAt)}
</span>
</ItemFooter>
</Item>
</Link>
))}
</div>
)}
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View file

@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { CalendarIcon, PackageOpenIcon, PlayIcon, RocketIcon, PlusIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Progress } from "@/components/ui/progress";
import { CreateReleaseDialog } from "@/components/frontend/projects/CreateReleaseDialog";
import { startRelease, completeRelease } from "@/app/(frontend)/projects/actions";
import { cn } from "@/lib/utils";
export interface ReleaseItem {
readonly id: number;
readonly name: string;
readonly status: string;
readonly targetVersion?: string | null;
readonly releaseDate?: string | null;
readonly description?: string | null;
readonly createdAt: string;
readonly ticketsTotal: number;
readonly ticketsResolved: number;
}
export interface ReleasesViewProps {
readonly project: { id: number; name: string; key: string };
readonly releases: readonly ReleaseItem[];
readonly isAdmin: boolean;
}
const RELEASE_STATUS_STYLES: Readonly<Record<string, string>> = {
draft: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30",
released: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
};
function labelFor(value: string): string {
return value
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function dateFor(value: string | null | undefined): string {
if (!value) return "TBD";
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function ReleasesView({ project, releases, isAdmin }: ReleasesViewProps) {
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
async function handleStartRelease(releaseId: number) {
const result = await startRelease({ releaseId });
if (result.success) {
toast.success("Release started");
router.refresh();
} else {
toast.error(result.error ?? "Failed to start release");
}
}
async function handleCompleteRelease(releaseId: number) {
const result = await completeRelease({ releaseId });
if (result.success) {
toast.success("Release completed");
router.refresh();
} else {
toast.error(result.error ?? "Failed to complete release");
}
}
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Releases</h2>
<span className="text-xs text-muted-foreground">{releases.length} total</span>
</div>
<p className="text-sm text-muted-foreground">Group project work into clear delivery milestones.</p>
</div>
{isAdmin && (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Create release
</Button>
)}
</div>
{releases.length === 0 ? (
<Item variant="outline">
<ItemMedia variant="icon">
<PackageOpenIcon className="text-muted-foreground" />
</ItemMedia>
<ItemContent className="items-center gap-1">
<ItemTitle>No releases yet</ItemTitle>
<ItemDescription>Create a release to start tracking delivery milestones.</ItemDescription>
</ItemContent>
</Item>
) : (
<div className="flex flex-col gap-2" role="list" aria-label={`${project.name} releases`}>
{releases.map((release) => {
const progressPercent =
release.ticketsTotal > 0
? Math.round((release.ticketsResolved / release.ticketsTotal) * 100)
: 0;
return (
<Item key={release.id} variant="outline" size="sm" role="listitem" className="items-start gap-3">
<ItemHeader className="items-center">
<div className="flex min-w-0 items-center gap-2">
<ItemTitle className="truncate text-base font-semibold">{release.name}</ItemTitle>
{release.targetVersion && (
<Badge variant="secondary" className="shrink-0 font-mono text-[10px]">
{release.targetVersion}
</Badge>
)}
<Badge
variant="outline"
className={cn(
"text-[10px] uppercase tracking-wide",
RELEASE_STATUS_STYLES[release.status] ?? "bg-muted/50 text-muted-foreground border-border",
)}
>
{labelFor(release.status)}
</Badge>
</div>
{isAdmin && (
<div className="flex gap-1">
{release.status === "draft" && (
<Button
variant="ghost"
size="sm"
onClick={() => void handleStartRelease(release.id)}
className="h-6 px-2 text-xs"
>
<PlayIcon className="size-3" />
Start
</Button>
)}
{release.status === "in_progress" && (
<Button
variant="ghost"
size="sm"
onClick={() => void handleCompleteRelease(release.id)}
className="h-6 px-2 text-xs"
>
<RocketIcon className="size-3" />
Release
</Button>
)}
</div>
)}
</ItemHeader>
<ItemContent className="w-full min-w-0 gap-2">
<ItemDescription>{release.description || "No description provided."}</ItemDescription>
<div className="flex flex-col gap-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Tickets: {release.ticketsResolved} / {release.ticketsTotal} resolved</span>
{release.ticketsTotal > 0 && <span>{progressPercent}%</span>}
</div>
{release.ticketsTotal > 0 && <Progress value={progressPercent} className="h-1.5" />}
</div>
</ItemContent>
<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">
<CalendarIcon className="size-3" />
{release.releaseDate ? `Released ${dateFor(release.releaseDate)}` : `Created ${dateFor(release.createdAt)}`}
</span>
</ItemFooter>
</Item>
);
})}
</div>
)}
<CreateReleaseDialog projectId={project.id} open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View file

@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { CalendarIcon, PlayIcon, CheckCircleIcon, PlusIcon, TimerIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Progress } from "@/components/ui/progress";
import { CreateSprintDialog } from "@/components/frontend/projects/CreateSprintDialog";
import { startSprint, completeSprint } from "@/app/(frontend)/projects/actions";
import { cn } from "@/lib/utils";
export interface SprintItem {
readonly id: number;
readonly name: string;
readonly status: string;
readonly goal?: string | null;
readonly startDate?: string | null;
readonly endDate?: string | null;
readonly capacity?: number | null;
readonly storyPointsCompleted: number;
readonly storyPointsTotal: number;
}
export interface SprintsViewProps {
readonly project: { id: number; name: string; key: string };
readonly sprints: readonly SprintItem[];
readonly isAdmin: boolean;
}
const SPRINT_STATUS_STYLES: Readonly<Record<string, string>> = {
planning: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
};
function labelFor(value: string): string {
return value
.replaceAll("_", " ")
.replace(/\b\w/g, (character) => character.toUpperCase());
}
function dateFor(value: string | null | undefined): string {
if (!value) return "TBD";
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function SprintsView({ project, sprints, isAdmin }: SprintsViewProps) {
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
async function handleStartSprint(sprintId: number) {
const result = await startSprint({ sprintId });
if (result.success) {
toast.success("Sprint started");
router.refresh();
} else {
toast.error(result.error ?? "Failed to start sprint");
}
}
async function handleCompleteSprint(sprintId: number) {
const result = await completeSprint({ sprintId });
if (result.success) {
toast.success("Sprint completed");
router.refresh();
} else {
toast.error(result.error ?? "Failed to complete sprint");
}
}
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Sprints</h2>
<span className="text-xs text-muted-foreground">{sprints.length} total</span>
</div>
<p className="text-sm text-muted-foreground">Plan focused delivery windows and track progress.</p>
</div>
{isAdmin && (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Create sprint
</Button>
)}
</div>
{sprints.length === 0 ? (
<Item variant="outline">
<ItemMedia variant="icon">
<TimerIcon className="text-muted-foreground" />
</ItemMedia>
<ItemContent className="items-center gap-1">
<ItemTitle>No sprints yet</ItemTitle>
<ItemDescription>Create a sprint to start planning your work.</ItemDescription>
</ItemContent>
</Item>
) : (
<div className="flex flex-col gap-2" role="list" aria-label={`${project.name} sprints`}>
{sprints.map((sprint) => {
const hasCapacity = sprint.capacity != null && sprint.capacity > 0;
const usedPoints = hasCapacity ? Math.min(sprint.storyPointsTotal, sprint.capacity!) : 0;
const progressPercent = hasCapacity ? Math.round((usedPoints / sprint.capacity!) * 100) : 0;
return (
<Item key={sprint.id} variant="outline" size="sm" role="listitem" className="items-start gap-3">
<ItemHeader className="items-center">
<div className="flex min-w-0 items-center gap-2">
<ItemTitle className="truncate text-base font-semibold">{sprint.name}</ItemTitle>
<Badge
variant="outline"
className={cn(
"text-[10px] uppercase tracking-wide",
SPRINT_STATUS_STYLES[sprint.status] ?? "bg-muted/50 text-muted-foreground border-border",
)}
>
{labelFor(sprint.status)}
</Badge>
</div>
{isAdmin && (
<div className="flex gap-1">
{sprint.status === "planning" && (
<Button
variant="ghost"
size="sm"
onClick={() => void handleStartSprint(sprint.id)}
className="h-6 px-2 text-xs"
>
<PlayIcon className="size-3" />
Start
</Button>
)}
{sprint.status === "active" && (
<Button
variant="ghost"
size="sm"
onClick={() => void handleCompleteSprint(sprint.id)}
className="h-6 px-2 text-xs"
>
<CheckCircleIcon className="size-3" />
Complete
</Button>
)}
</div>
)}
</ItemHeader>
<ItemContent className="w-full min-w-0 gap-2">
{sprint.goal && <ItemDescription>{sprint.goal}</ItemDescription>}
{hasCapacity && (
<div className="flex flex-col gap-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Story points: {sprint.storyPointsTotal} / {sprint.capacity}</span>
<span>{progressPercent}%</span>
</div>
<Progress value={progressPercent} className="h-1.5" />
</div>
)}
{!hasCapacity && (
<div className="text-xs text-muted-foreground">
Story points: {sprint.storyPointsTotal}
</div>
)}
</ItemContent>
<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">
<CalendarIcon className="size-3" />
{dateFor(sprint.startDate)} — {dateFor(sprint.endDate)}
</span>
</ItemFooter>
</Item>
);
})}
</div>
)}
<CreateSprintDialog projectId={project.id} open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View file

@ -2,7 +2,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { GAME_TICK_EVENT, type GameTickDetail } from "@/hooks/useGameTick"; import {
GAME_EVENT_EVENT,
GAME_TICK_EVENT,
NOTIFICATION_EVENT,
PROFILE_UPDATE_EVENT,
type GameTickDetail,
type ProfileUpdateDetail,
} from "@/hooks/useGameTick";
/** /**
* Opens a Server-Sent Events connection to the game tick stream. * Opens a Server-Sent Events connection to the game tick stream.
@ -15,6 +22,14 @@ export function GameTickRealtime() {
useEffect(() => { useEffect(() => {
const source = new EventSource("/api/realtime"); const source = new EventSource("/api/realtime");
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const refresh = (delay = 250) => {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
router.refresh();
}, delay);
};
source.addEventListener("game-tick", (event) => { source.addEventListener("game-tick", (event) => {
let detail: GameTickDetail = {}; let detail: GameTickDetail = {};
try { try {
@ -22,12 +37,32 @@ export function GameTickRealtime() {
} catch { } catch {
// ignore malformed payload // ignore malformed payload
} }
router.refresh(); refresh();
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(GAME_TICK_EVENT, { detail }), new CustomEvent(GAME_TICK_EVENT, { detail }),
); );
}); });
return () => source.close(); source.addEventListener("profile-update", (event) => {
let detail: ProfileUpdateDetail = {};
try {
detail = JSON.parse(event.data ?? "{}");
} catch {
// ignore malformed payload
}
refresh(detail.xpGained ? 1700 : 250);
window.dispatchEvent(new CustomEvent(PROFILE_UPDATE_EVENT, { detail }));
});
source.addEventListener("game-event", () => {
refresh();
window.dispatchEvent(new CustomEvent(GAME_EVENT_EVENT));
});
source.addEventListener("notification", (event) => {
window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT, { detail: event.data }));
});
return () => {
if (refreshTimer) clearTimeout(refreshTimer);
source.close();
};
}, [router]); }, [router]);
return null; return null;

View file

@ -3,11 +3,19 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
export const GAME_TICK_EVENT = "ptf:game-tick"; export const GAME_TICK_EVENT = "ptf:game-tick";
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification";
export interface GameTickDetail { export interface GameTickDetail {
processedAt?: string; processedAt?: string;
} }
export interface ProfileUpdateDetail {
reason?: string;
xpGained?: number;
}
export function useGameTick(onTick?: () => void): number { export function useGameTick(onTick?: () => void): number {
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const callbackRef = useRef(onTick); const callbackRef = useRef(onTick);

View file

@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
export interface PresenceParticipant {
id: number;
name: string;
lastSeen: string;
}
interface PresenceEvent {
channel?: string;
action?: "snapshot" | "join" | "update" | "leave";
participants?: PresenceParticipant[];
participant?: PresenceParticipant;
participantId?: number;
}
const HEARTBEAT_MS = 15_000;
export function useRealtimePresence(channel: string, active: boolean, observe = true): PresenceParticipant[] {
const [participants, setParticipants] = useState<PresenceParticipant[]>([]);
useEffect(() => {
if (!observe) {
setParticipants([]);
return;
}
let cancelled = false;
const endpoint = `/api/realtime/presence?channel=${encodeURIComponent(channel)}`;
const send = (method: "POST" | "DELETE") => {
void fetch(endpoint, { method, keepalive: method === "DELETE" }).catch(() => undefined);
};
const source = new EventSource(`/api/realtime?channel=${encodeURIComponent(channel)}`);
const onPresence = (event: MessageEvent<string>) => {
if (cancelled) return;
let detail: PresenceEvent;
try {
detail = JSON.parse(event.data) as PresenceEvent;
} catch {
return;
}
if (detail.action === "snapshot") setParticipants(detail.participants ?? []);
if ((detail.action === "join" || detail.action === "update") && detail.participant) {
setParticipants((current) => [
...current.filter((item) => item.id !== detail.participant!.id),
detail.participant!,
]);
}
if (detail.action === "leave" && detail.participantId !== undefined) {
setParticipants((current) => current.filter((item) => item.id !== detail.participantId));
}
};
source.addEventListener("presence", onPresence);
if (active) send("POST");
const heartbeat = active ? window.setInterval(() => send("POST"), HEARTBEAT_MS) : undefined;
return () => {
cancelled = true;
if (heartbeat) window.clearInterval(heartbeat);
source.removeEventListener("presence", onPresence);
source.close();
if (active) send("DELETE");
};
}, [channel, active, observe]);
return participants;
}

View file

@ -0,0 +1,21 @@
export const statusColors: Record<string, string> = {
Concept: "bg-muted text-muted-foreground",
Planning: "bg-muted text-muted-foreground",
Ready: "bg-blue-500/20 text-blue-400",
Scheduled: "bg-amber-500/20 text-amber-400",
Active: "bg-green-500/20 text-green-400",
Completed: "bg-emerald-500/20 text-emerald-400",
Cancelled: "bg-red-500/20 text-red-400",
};
export const operationTypeColors: Record<string, string> = {
main: "bg-blue-500/20 text-blue-400",
side: "bg-muted text-muted-foreground",
external: "bg-amber-500/20 text-amber-400",
};
export const operationTypeLabels: Record<string, string> = {
main: "Main",
side: "Side",
external: "External",
};

View file

@ -1,3 +1,5 @@
import { broadcastToUser } from "@/lib/realtime/bus";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>; type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export interface NotifyInput { export interface NotifyInput {
@ -40,6 +42,7 @@ export async function notifyUser(payload: PayloadType, input: NotifyInput): Prom
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
}); });
broadcastToUser("notification", input.userId, { type: input.type ?? null });
} catch (err) { } catch (err) {
console.error("[Notifications] Failed to create notification:", err); console.error("[Notifications] Failed to create notification:", err);
} }

View file

@ -0,0 +1,35 @@
export const PROJECT_TICKET_TYPES = [
{ label: "Task", value: "task" },
{ label: "Bug", value: "bug" },
{ label: "Story", value: "story" },
{ label: "Epic", value: "epic" },
{ label: "Improvement", value: "improvement" },
{ label: "Subtask", value: "subtask" },
] as const;
export const TYPE_LABEL: Readonly<Record<string, string>> = Object.fromEntries(
PROJECT_TICKET_TYPES.map((type) => [type.value, type.label]),
);
export const TYPE_STYLES: Readonly<Record<string, string>> = {
task: "bg-slate-500/15 text-slate-400 border-slate-500/30",
bug: "bg-red-500/15 text-red-400 border-red-500/30",
story: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
epic: "bg-violet-500/15 text-violet-400 border-violet-500/30",
improvement: "bg-amber-500/15 text-amber-400 border-amber-500/30",
subtask: "bg-sky-500/15 text-sky-400 border-sky-500/30",
};
/** Safely extracts readable text from Payload's Lexical rich-text JSON. */
export function richTextToPlaintext(value: unknown): string {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return "";
const node = value as { text?: unknown; children?: unknown };
if (typeof node.text === "string") return node.text;
if (!Array.isArray(node.children)) return "";
return node.children
.map((child) => richTextToPlaintext(child))
.filter(Boolean)
.join(" ");
}

View file

@ -1,5 +1,7 @@
export interface SSEEventClient { export interface SSEEventClient {
send: (data: string) => void; send: (data: string) => void;
events?: Set<string>;
userId?: number;
} }
const clients = new Set<SSEEventClient>(); const clients = new Set<SSEEventClient>();
@ -20,6 +22,22 @@ export function clientCount(): number {
export function broadcast(event: string, data: unknown): number { export function broadcast(event: string, data: unknown): number {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) { for (const client of clients) {
if (client.events && !client.events.has(event)) continue;
try {
client.send(payload);
} catch {
clients.delete(client);
}
}
return clients.size;
}
export function broadcastToUser(event: string, userId: number, data: unknown): number {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const targetUserId = Number(userId);
for (const client of clients) {
if (client.userId !== targetUserId) continue;
if (client.events && !client.events.has(event)) continue;
try { try {
client.send(payload); client.send(payload);
} catch { } catch {

View file

@ -0,0 +1,56 @@
import { broadcast } from "./bus";
export interface PresenceParticipant {
id: number;
name: string;
lastSeen: string;
}
interface PresenceRecord extends PresenceParticipant {
expiresAt: number;
}
export const PRESENCE_TTL_MS = 45_000;
const channels = new Map<string, Map<number, PresenceRecord>>();
function prune(channel: string, now = Date.now()): void {
const entries = channels.get(channel);
if (!entries) return;
for (const [id, entry] of entries) {
if (entry.expiresAt <= now) {
entries.delete(id);
broadcast("presence", { channel, action: "leave", participantId: id });
}
}
if (entries.size === 0) channels.delete(channel);
}
export function getPresence(channel: string): PresenceParticipant[] {
prune(channel);
return [...(channels.get(channel)?.values() ?? [])].map(({ id, name, lastSeen }) => ({ id, name, lastSeen }));
}
export function touchPresence(channel: string, participant: Omit<PresenceParticipant, "lastSeen">): void {
const now = Date.now();
prune(channel, now);
let entries = channels.get(channel);
if (!entries) {
entries = new Map();
channels.set(channel, entries);
}
const wasPresent = entries.has(participant.id);
const record = { ...participant, lastSeen: new Date(now).toISOString(), expiresAt: now + PRESENCE_TTL_MS };
entries.set(participant.id, record);
broadcast("presence", {
channel,
action: wasPresent ? "update" : "join",
participant: { id: record.id, name: record.name, lastSeen: record.lastSeen },
});
}
export function removePresence(channel: string, id: number): void {
const entries = channels.get(channel);
if (!entries?.delete(id)) return;
if (entries.size === 0) channels.delete(channel);
broadcast("presence", { channel, action: "leave", participantId: id });
}

View file

@ -1,65 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_plugin_ai_instructions_field_type" AS ENUM('text', 'textarea', 'upload', 'richText');
CREATE TYPE "public"."enum_plugin_ai_instructions_model_id" AS ENUM('unsloth-text');
CREATE TABLE "plugin_ai_instructions_images" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"image_id" integer
);
CREATE TABLE "plugin_ai_instructions" (
"id" serial PRIMARY KEY NOT NULL,
"schema_path" varchar,
"field_type" "enum_plugin_ai_instructions_field_type" DEFAULT 'text',
"relation_to" varchar,
"model_id" "enum_plugin_ai_instructions_model_id",
"disabled" boolean DEFAULT false,
"prompt" varchar,
"system" varchar DEFAULT 'INSTRUCTIONS:
You are a highly skilled and professional blog writer,
renowned for crafting engaging and well-organized articles.
When given a title, you meticulously create blogs that are not only
informative and accurate but also captivating and beautifully structured.',
"layout" varchar DEFAULT '[paragraph] - Write a concise introduction (2-3 sentences) that outlines the main topic.
[horizontalrule] - Insert a horizontal rule to separate the introduction from the main content.
[list] - Create a list with 3-5 items. Each list item should contain:
a. [heading] - A brief, descriptive heading (up to 5 words)
b. [paragraph] - A short explanation or elaboration (1-2 sentences)
[horizontalrule] - Insert another horizontal rule to separate the main content from the conclusion.
[paragraph] - Compose a brief conclusion (2-3 sentences) summarizing the key points.
[quote] - Include a relevant quote from a famous person, directly related to the topic. Format: "Quote text." - Author Name',
"unsloth_text_settings_model" varchar DEFAULT 'unsloth/gemma-4-E4B-it-qat-GGUF',
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "plugin_ai_instructions_id" integer;
ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "plugin_ai_instructions_images_order_idx" ON "plugin_ai_instructions_images" USING btree ("_order");
CREATE INDEX "plugin_ai_instructions_images_parent_id_idx" ON "plugin_ai_instructions_images" USING btree ("_parent_id");
CREATE INDEX "plugin_ai_instructions_images_image_idx" ON "plugin_ai_instructions_images" USING btree ("image_id");
CREATE UNIQUE INDEX "plugin_ai_instructions_schema_path_idx" ON "plugin_ai_instructions" USING btree ("schema_path");
CREATE INDEX "plugin_ai_instructions_updated_at_idx" ON "plugin_ai_instructions" USING btree ("updated_at");
CREATE INDEX "plugin_ai_instructions_created_at_idx" ON "plugin_ai_instructions" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk" FOREIGN KEY ("plugin_ai_instructions_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx" ON "payload_locked_documents_rels" USING btree ("plugin_ai_instructions_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "plugin_ai_instructions_images" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "plugin_ai_instructions" DISABLE ROW LEVEL SECURITY;
DROP TABLE "plugin_ai_instructions_images" CASCADE;
DROP TABLE "plugin_ai_instructions" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk";
DROP INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "plugin_ai_instructions_id";
DROP TYPE "public"."enum_plugin_ai_instructions_field_type";
DROP TYPE "public"."enum_plugin_ai_instructions_model_id";`)
}

View file

@ -1,87 +1,276 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// Idempotent migration: safe to run on databases where push: true already applied
// the schema (dev), and on fresh production databases that need these objects.
// Each DDL block is wrapped in exception handlers to skip already-existing objects.
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> { export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// --- Enums ---
await db.execute(sql` await db.execute(sql`
CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual'); DO $$ BEGIN
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce'); CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual');
CREATE TABLE "users_rels" ( EXCEPTION WHEN duplicate_object THEN null;
"id" serial PRIMARY KEY NOT NULL, END $$;
"order" integer, `)
"parent_id" integer NOT NULL,
"path" varchar NOT NULL, await db.execute(sql`
"roles_id" integer DO $$ BEGIN
); CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
EXCEPTION WHEN duplicate_object THEN null;
CREATE TABLE "roles_permissions" ( END $$;
"order" integer NOT NULL, `)
"parent_id" integer NOT NULL,
"value" "enum_roles_permissions", // --- Tables ---
"id" serial PRIMARY KEY NOT NULL await db.execute(sql`
); DO $$ BEGIN
CREATE TABLE "users_rels" (
CREATE TABLE "roles" ( "id" serial PRIMARY KEY NOT NULL,
"id" serial PRIMARY KEY NOT NULL, "order" integer,
"name" varchar NOT NULL, "parent_id" integer NOT NULL,
"slug" varchar NOT NULL, "path" varchar NOT NULL,
"description" varchar, "roles_id" integer
"is_system" boolean DEFAULT false, );
"is_superuser" boolean DEFAULT false, EXCEPTION WHEN duplicate_table THEN null;
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, END $$;
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL `)
);
await db.execute(sql`
CREATE TABLE "roles_rels" ( DO $$ BEGIN
"id" serial PRIMARY KEY NOT NULL, CREATE TABLE "roles_permissions" (
"order" integer, "order" integer NOT NULL,
"parent_id" integer NOT NULL, "parent_id" integer NOT NULL,
"path" varchar NOT NULL, "value" "enum_roles_permissions",
"roles_id" integer "id" serial PRIMARY KEY NOT NULL
); );
EXCEPTION WHEN duplicate_table THEN null;
ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead'; END $$;
ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true; `)
ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone NOT NULL;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer; await db.execute(sql`
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; DO $$ BEGIN
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; CREATE TABLE "roles" (
ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; "id" serial PRIMARY KEY NOT NULL,
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; "name" varchar NOT NULL,
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; "slug" varchar NOT NULL,
CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order"); "description" varchar,
CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id"); "is_system" boolean DEFAULT false,
CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path"); "is_superuser" boolean DEFAULT false,
CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id"); "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order"); "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id"); );
CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name"); EXCEPTION WHEN duplicate_table THEN null;
CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug"); END $$;
CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at"); `)
CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at");
CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order"); await db.execute(sql`
CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id"); DO $$ BEGIN
CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path"); CREATE TABLE "roles_rels" (
CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id"); "id" serial PRIMARY KEY NOT NULL,
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; "order" integer,
CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id");`) "parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"roles_id" integer
);
EXCEPTION WHEN duplicate_table THEN null;
END $$;
`)
// --- Columns (ADD COLUMN with DEFAULT for NOT NULL columns to handle existing rows) ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead';
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone DEFAULT now() NOT NULL;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer;
EXCEPTION WHEN duplicate_column THEN null;
END $$;
`)
// --- Foreign keys ---
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object THEN null;
END $$;
`)
// --- Indexes (catch duplicate_table OR duplicate_object — PostgreSQL uses 42P07 for indexes) ---
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
await db.execute(sql`
DO $$ BEGIN
CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id");
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
END $$;
`)
} }
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> { export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql` await db.execute(sql`
ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY; ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY; ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY; ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY; ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "users_rels" CASCADE; DROP TABLE "users_rels" CASCADE;
DROP TABLE "roles_permissions" CASCADE; DROP TABLE "roles_permissions" CASCADE;
DROP TABLE "roles" CASCADE; DROP TABLE "roles" CASCADE;
DROP TABLE "roles_rels" CASCADE; DROP TABLE "roles_rels" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk"; ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk";
DROP INDEX "payload_locked_documents_rels_roles_id_idx"; DROP INDEX "payload_locked_documents_rels_roles_id_idx";
ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual"; ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual";
ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay"; ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay";
ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date"; ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id"; ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id";
DROP TYPE "public"."enum_users_preferences_display_lead_or_actual"; DROP TYPE "public"."enum_users_preferences_display_lead_or_actual";
DROP TYPE "public"."enum_roles_permissions";`) DROP TYPE "public"."enum_roles_permissions";
`)
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_tickets_type" AS ENUM('task', 'bug', 'story', 'epic', 'improvement');
CREATE TYPE "public"."enum_tickets_resolution" AS ENUM('fixed', 'wontfix', 'duplicate', 'invalid');
CREATE TYPE "public"."enum_projects_status" AS ENUM('active', 'archived');
CREATE TYPE "public"."enum_sprints_status" AS ENUM('planning', 'active', 'completed');
CREATE TYPE "public"."enum_releases_status" AS ENUM('draft', 'in_progress', 'released');
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:create' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:read' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:update' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:delete' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:create' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:read' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:update' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:delete' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:create' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:read' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:update' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:delete' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:create' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:read' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:update' BEFORE 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:delete' BEFORE 'tickets:create';
CREATE TABLE "tickets_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"labels_id" integer
);
CREATE TABLE "projects" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"key" varchar NOT NULL,
"description" jsonb,
"lead_id" integer,
"status" "enum_projects_status" DEFAULT 'active',
"default_assignee_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "sprints" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"project_id" integer NOT NULL,
"goal" varchar,
"start_date" timestamp(3) with time zone,
"end_date" timestamp(3) with time zone,
"status" "enum_sprints_status" DEFAULT 'planning',
"capacity" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "releases" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"project_id" integer NOT NULL,
"description" jsonb,
"status" "enum_releases_status" DEFAULT 'draft',
"release_date" timestamp(3) with time zone,
"target_version" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "labels" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"color" varchar DEFAULT '#6366f1',
"project_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "tickets" ADD COLUMN "project_id" integer;
ALTER TABLE "tickets" ADD COLUMN "sprint_id" integer;
ALTER TABLE "tickets" ADD COLUMN "release_id" integer;
ALTER TABLE "tickets" ADD COLUMN "parent_ticket_id" integer;
ALTER TABLE "tickets" ADD COLUMN "type" "enum_tickets_type" DEFAULT 'task';
ALTER TABLE "tickets" ADD COLUMN "story_points" numeric;
ALTER TABLE "tickets" ADD COLUMN "estimate" numeric;
ALTER TABLE "tickets" ADD COLUMN "start_date" timestamp(3) with time zone;
ALTER TABLE "tickets" ADD COLUMN "due_date" timestamp(3) with time zone;
ALTER TABLE "tickets" ADD COLUMN "resolution" "enum_tickets_resolution";
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "projects_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "sprints_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "releases_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "labels_id" integer;
ALTER TABLE "tickets_rels" ADD CONSTRAINT "tickets_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "tickets_rels" ADD CONSTRAINT "tickets_rels_labels_fk" FOREIGN KEY ("labels_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "projects" ADD CONSTRAINT "projects_lead_id_users_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "projects" ADD CONSTRAINT "projects_default_assignee_id_users_id_fk" FOREIGN KEY ("default_assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "sprints" ADD CONSTRAINT "sprints_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "releases" ADD CONSTRAINT "releases_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "labels" ADD CONSTRAINT "labels_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "tickets_rels_order_idx" ON "tickets_rels" USING btree ("order");
CREATE INDEX "tickets_rels_parent_idx" ON "tickets_rels" USING btree ("parent_id");
CREATE INDEX "tickets_rels_path_idx" ON "tickets_rels" USING btree ("path");
CREATE INDEX "tickets_rels_labels_id_idx" ON "tickets_rels" USING btree ("labels_id");
CREATE UNIQUE INDEX "projects_key_idx" ON "projects" USING btree ("key");
CREATE INDEX "projects_lead_idx" ON "projects" USING btree ("lead_id");
CREATE INDEX "projects_default_assignee_idx" ON "projects" USING btree ("default_assignee_id");
CREATE INDEX "projects_updated_at_idx" ON "projects" USING btree ("updated_at");
CREATE INDEX "projects_created_at_idx" ON "projects" USING btree ("created_at");
CREATE INDEX "sprints_project_idx" ON "sprints" USING btree ("project_id");
CREATE INDEX "sprints_updated_at_idx" ON "sprints" USING btree ("updated_at");
CREATE INDEX "sprints_created_at_idx" ON "sprints" USING btree ("created_at");
CREATE INDEX "releases_project_idx" ON "releases" USING btree ("project_id");
CREATE INDEX "releases_updated_at_idx" ON "releases" USING btree ("updated_at");
CREATE INDEX "releases_created_at_idx" ON "releases" USING btree ("created_at");
CREATE INDEX "labels_project_idx" ON "labels" USING btree ("project_id");
CREATE INDEX "labels_updated_at_idx" ON "labels" USING btree ("updated_at");
CREATE INDEX "labels_created_at_idx" ON "labels" USING btree ("created_at");
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_sprint_id_sprints_id_fk" FOREIGN KEY ("sprint_id") REFERENCES "public"."sprints"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_release_id_releases_id_fk" FOREIGN KEY ("release_id") REFERENCES "public"."releases"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_parent_ticket_id_tickets_id_fk" FOREIGN KEY ("parent_ticket_id") REFERENCES "public"."tickets"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_projects_fk" FOREIGN KEY ("projects_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_sprints_fk" FOREIGN KEY ("sprints_id") REFERENCES "public"."sprints"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_releases_fk" FOREIGN KEY ("releases_id") REFERENCES "public"."releases"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_labels_fk" FOREIGN KEY ("labels_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "tickets_project_idx" ON "tickets" USING btree ("project_id");
CREATE INDEX "tickets_sprint_idx" ON "tickets" USING btree ("sprint_id");
CREATE INDEX "tickets_release_idx" ON "tickets" USING btree ("release_id");
CREATE INDEX "tickets_parent_ticket_idx" ON "tickets" USING btree ("parent_ticket_id");
CREATE INDEX "payload_locked_documents_rels_projects_id_idx" ON "payload_locked_documents_rels" USING btree ("projects_id");
CREATE INDEX "payload_locked_documents_rels_sprints_id_idx" ON "payload_locked_documents_rels" USING btree ("sprints_id");
CREATE INDEX "payload_locked_documents_rels_releases_id_idx" ON "payload_locked_documents_rels" USING btree ("releases_id");
CREATE INDEX "payload_locked_documents_rels_labels_id_idx" ON "payload_locked_documents_rels" USING btree ("labels_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "tickets_rels" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "projects" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "sprints" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "releases" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "labels" DISABLE ROW LEVEL SECURITY;
DROP TABLE "tickets_rels" CASCADE;
DROP TABLE "projects" CASCADE;
DROP TABLE "sprints" CASCADE;
DROP TABLE "releases" CASCADE;
DROP TABLE "labels" CASCADE;
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_project_id_projects_id_fk";
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_sprint_id_sprints_id_fk";
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_release_id_releases_id_fk";
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_parent_ticket_id_tickets_id_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_projects_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_sprints_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_releases_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_labels_fk";
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_roles_permissions";
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE "public"."enum_roles_permissions" USING "value"::"public"."enum_roles_permissions";
DROP INDEX "tickets_project_idx";
DROP INDEX "tickets_sprint_idx";
DROP INDEX "tickets_release_idx";
DROP INDEX "tickets_parent_ticket_idx";
DROP INDEX "payload_locked_documents_rels_projects_id_idx";
DROP INDEX "payload_locked_documents_rels_sprints_id_idx";
DROP INDEX "payload_locked_documents_rels_releases_id_idx";
DROP INDEX "payload_locked_documents_rels_labels_id_idx";
ALTER TABLE "tickets" DROP COLUMN "project_id";
ALTER TABLE "tickets" DROP COLUMN "sprint_id";
ALTER TABLE "tickets" DROP COLUMN "release_id";
ALTER TABLE "tickets" DROP COLUMN "parent_ticket_id";
ALTER TABLE "tickets" DROP COLUMN "type";
ALTER TABLE "tickets" DROP COLUMN "story_points";
ALTER TABLE "tickets" DROP COLUMN "estimate";
ALTER TABLE "tickets" DROP COLUMN "start_date";
ALTER TABLE "tickets" DROP COLUMN "due_date";
ALTER TABLE "tickets" DROP COLUMN "resolution";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "projects_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "sprints_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "releases_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "labels_id";
DROP TYPE "public"."enum_tickets_type";
DROP TYPE "public"."enum_tickets_resolution";
DROP TYPE "public"."enum_projects_status";
DROP TYPE "public"."enum_sprints_status";
DROP TYPE "public"."enum_releases_status";`)
}

View file

@ -0,0 +1,9 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// No-op migration: the schema this originally created was already applied by the
// archived migration chain (20251206–20260817) that was tracked in payload_migrations
// before the consolidation. Payload needs to see this name registered so it marks
// the migration as "ran" — but no DDL should execute.
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {}

View file

@ -1,15 +1,16 @@
import * as migration_20260818_024510 from './20260818_024510'; import * as migration_20260818_024510 from './_archived/20260818_024510';
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260821_043007 from './20260821_043007';
export const migrations = [ export const migrations = [
{
up: migration_20260818_024510.up,
down: migration_20260818_024510.down,
name: '20260818_024510',
},
{ {
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_20260821_043007.up,
down: migration_20260821_043007.down,
name: '20260821_043007'
}, },
]; ];

View file

@ -100,6 +100,10 @@ export interface Config {
'market-listings': MarketListing; 'market-listings': MarketListing;
'market-negotiations': MarketNegotiation; 'market-negotiations': MarketNegotiation;
tickets: Ticket; tickets: Ticket;
projects: Project;
sprints: Sprint;
releases: Release;
labels: Label;
maps: Map; maps: Map;
'narrative-events': NarrativeEvent; 'narrative-events': NarrativeEvent;
'mission-files': MissionFile; 'mission-files': MissionFile;
@ -152,6 +156,10 @@ export interface Config {
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>; 'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>; 'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
tickets: TicketsSelect<false> | TicketsSelect<true>; tickets: TicketsSelect<false> | TicketsSelect<true>;
projects: ProjectsSelect<false> | ProjectsSelect<true>;
sprints: SprintsSelect<false> | SprintsSelect<true>;
releases: ReleasesSelect<false> | ReleasesSelect<true>;
labels: LabelsSelect<false> | LabelsSelect<true>;
maps: MapsSelect<false> | MapsSelect<true>; maps: MapsSelect<false> | MapsSelect<true>;
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>; 'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>; 'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
@ -1474,6 +1482,22 @@ export interface Role {
| 'market-negotiations:read' | 'market-negotiations:read'
| 'market-negotiations:update' | 'market-negotiations:update'
| 'market-negotiations:delete' | 'market-negotiations:delete'
| 'projects:create'
| 'projects:read'
| 'projects:update'
| 'projects:delete'
| 'labels:create'
| 'labels:read'
| 'labels:update'
| 'labels:delete'
| 'sprints:create'
| 'sprints:read'
| 'sprints:update'
| 'sprints:delete'
| 'releases:create'
| 'releases:read'
| 'releases:update'
| 'releases:delete'
| 'tickets:create' | 'tickets:create'
| 'tickets:read' | 'tickets:read'
| 'tickets:update' | 'tickets:update'
@ -2509,6 +2533,154 @@ export interface Ticket {
* Set automatically when the ticket reaches a closing status. * Set automatically when the ticket reaches a closing status.
*/ */
closedAt?: string | null; closedAt?: string | null;
/**
* Project this ticket belongs to. Null = standalone/backlog.
*/
project?: (number | null) | Project;
/**
* Sprint this ticket is assigned to.
*/
sprint?: (number | null) | Sprint;
/**
* Release this ticket targets.
*/
release?: (number | null) | Release;
/**
* Parent ticket (for subtasks / epic children).
*/
parentTicket?: (number | null) | Ticket;
/**
* Labels for filtering and organization.
*/
labels?: (number | Label)[] | null;
/**
* Issue type.
*/
type?: ('task' | 'bug' | 'story' | 'epic' | 'improvement') | null;
/**
* Fibonacci story point estimate.
*/
storyPoints?: number | null;
/**
* Time estimate in hours.
*/
estimate?: number | null;
/**
* When work starts (for Gantt).
*/
startDate?: string | null;
/**
* When work is due (for Gantt).
*/
dueDate?: string | null;
/**
* How the ticket was resolved.
*/
resolution?: ('fixed' | 'wontfix' | 'duplicate' | 'invalid') | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "projects".
*/
export interface Project {
id: number;
name: string;
/**
* Short project key (e.g. PTF). Max 10 uppercase alphanumeric characters.
*/
key: string;
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
lead?: (number | null) | User;
status?: ('active' | 'archived') | null;
/**
* Default assignee for new tickets in this project.
*/
defaultAssignee?: (number | null) | User;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sprints".
*/
export interface Sprint {
id: number;
name: string;
project: number | Project;
goal?: string | null;
startDate?: string | null;
endDate?: string | null;
status?: ('planning' | 'active' | 'completed') | null;
/**
* Story point capacity for this sprint.
*/
capacity?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "releases".
*/
export interface Release {
id: number;
name: string;
project: number | Project;
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
status?: ('draft' | 'in_progress' | 'released') | null;
releaseDate?: string | null;
/**
* Semver string, e.g. 2.3.0
*/
targetVersion?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "labels".
*/
export interface Label {
id: number;
name: string;
/**
* Hex color code.
*/
color?: string | null;
/**
* Null for global labels.
*/
project?: (number | null) | Project;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@ -2750,6 +2922,22 @@ export interface PayloadLockedDocument {
relationTo: 'tickets'; relationTo: 'tickets';
value: number | Ticket; value: number | Ticket;
} | null) } | null)
| ({
relationTo: 'projects';
value: number | Project;
} | null)
| ({
relationTo: 'sprints';
value: number | Sprint;
} | null)
| ({
relationTo: 'releases';
value: number | Release;
} | null)
| ({
relationTo: 'labels';
value: number | Label;
} | null)
| ({ | ({
relationTo: 'maps'; relationTo: 'maps';
value: number | Map; value: number | Map;
@ -3845,6 +4033,71 @@ export interface TicketsSelect<T extends boolean = true> {
id?: T; id?: T;
}; };
closedAt?: T; closedAt?: T;
project?: T;
sprint?: T;
release?: T;
parentTicket?: T;
labels?: T;
type?: T;
storyPoints?: T;
estimate?: T;
startDate?: T;
dueDate?: T;
resolution?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "projects_select".
*/
export interface ProjectsSelect<T extends boolean = true> {
name?: T;
key?: T;
description?: T;
lead?: T;
status?: T;
defaultAssignee?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sprints_select".
*/
export interface SprintsSelect<T extends boolean = true> {
name?: T;
project?: T;
goal?: T;
startDate?: T;
endDate?: T;
status?: T;
capacity?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "releases_select".
*/
export interface ReleasesSelect<T extends boolean = true> {
name?: T;
project?: T;
description?: T;
status?: T;
releaseDate?: T;
targetVersion?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "labels_select".
*/
export interface LabelsSelect<T extends boolean = true> {
name?: T;
color?: T;
project?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View file

@ -44,6 +44,10 @@ import { MarketListings } from "@/collections/market/MarketListings";
import { MarketNegotiations } from "@/collections/market/MarketNegotiations"; import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
import { UserNotifications } from "@/collections/users/UserNotifications"; import { UserNotifications } from "@/collections/users/UserNotifications";
import { Tickets } from "@/collections/tickets/Tickets"; import { Tickets } from "@/collections/tickets/Tickets";
import { Projects } from "@/collections/projects/Projects";
import { Sprints } from "@/collections/projects/Sprints";
import { Releases } from "@/collections/projects/Releases";
import { Labels } from "@/collections/projects/Labels";
import { migrations } from "./migrations"; import { migrations } from "./migrations";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@ -230,6 +234,12 @@ export default buildConfig({
// Helpdesk // Helpdesk
Tickets, Tickets,
// Projects
Projects,
Sprints,
Releases,
Labels,
// World // World
Maps, Maps,
NarrativeEvents, NarrativeEvents,
@ -320,6 +330,12 @@ export default buildConfig({
// Helpdesk // Helpdesk
[Tickets.slug]: true, [Tickets.slug]: true,
// Projects
[Projects.slug]: true,
[Sprints.slug]: true,
[Releases.slug]: true,
[Labels.slug]: true,
}, },
debugging: true, debugging: true,
generationModels: [ generationModels: [

View file

@ -278,6 +278,42 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
}, },
// ---- Helpdesk ------------------------------------------------------------ // ---- Helpdesk ------------------------------------------------------------
{
group: "Projects",
permissions: [
{ value: "projects:create", label: "Create" },
{ value: "projects:read", label: "Read" },
{ value: "projects:update", label: "Update" },
{ value: "projects:delete", label: "Delete" },
],
},
{
group: "Labels",
permissions: [
{ value: "labels:create", label: "Create" },
{ value: "labels:read", label: "Read" },
{ value: "labels:update", label: "Update" },
{ value: "labels:delete", label: "Delete" },
],
},
{
group: "Sprints",
permissions: [
{ value: "sprints:create", label: "Create" },
{ value: "sprints:read", label: "Read" },
{ value: "sprints:update", label: "Update" },
{ value: "sprints:delete", label: "Delete" },
],
},
{
group: "Releases",
permissions: [
{ value: "releases:create", label: "Create" },
{ value: "releases:read", label: "Read" },
{ value: "releases:update", label: "Update" },
{ value: "releases:delete", label: "Delete" },
],
},
{ {
group: "Tickets", group: "Tickets",
permissions: [ permissions: [
@ -532,6 +568,22 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"market-negotiations:read", "market-negotiations:read",
"market-negotiations:update", "market-negotiations:update",
"market-negotiations:delete", "market-negotiations:delete",
"projects:create",
"projects:read",
"projects:update",
"projects:delete",
"sprints:create",
"sprints:read",
"sprints:update",
"sprints:delete",
"labels:create",
"labels:read",
"labels:update",
"labels:delete",
"releases:create",
"releases:read",
"releases:update",
"releases:delete",
"tickets:create", "tickets:create",
"tickets:read", "tickets:read",
"tickets:update", "tickets:update",

View file

@ -0,0 +1,348 @@
#!/usr/bin/env bun
/**
* Interactive migration runner — safely run Payload migrations against any
* environment database (dev / stg / prd) from your local machine.
*
* Usage:
* bun run migrate # interactive menu
* bun run migrate dev # skip menu, target dev
* bun run migrate stg # skip menu, target staging
* bun run migrate prd # skip menu, target production
* bun run migrate --status # show migration status only (no changes)
* bun run migrate prd --status # status for a specific env
* bun run migrate --fresh # drop + recreate (dev only!)
*
* Environment files:
* .env — dev (DATABASE_URI for local PostgreSQL)
* .env.stg — staging (DATABASE_URI for staging PostgreSQL)
* .env.prd — production (DATABASE_URI for production PostgreSQL)
*
* These files are gitignored. Copy the DATABASE_URI from Coolify and paste
* into the appropriate file. Template:
* DATABASE_URI=postgres://user:password@host:5432/dbname
*/
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import * as path from "node:path";
// ── Config ──────────────────────────────────────────────────────────────
type EnvName = "dev" | "stg" | "prd";
interface EnvConfig {
label: string;
file: string;
color: string;
}
const ENVS: Record<EnvName, EnvConfig> = {
dev: {
label: "Development",
file: ".env",
color: "\x1b[36m",
},
stg: {
label: "Staging",
file: ".env.stg",
color: "\x1b[33m",
},
prd: {
label: "Production",
file: ".env.prd",
color: "\x1b[31m",
},
};
const ENV_ORDER: EnvName[] = ["dev", "stg", "prd"];
// ── ANSI helpers ────────────────────────────────────────────────────────
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const DIM = "\x1b[2m";
const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const YELLOW = "\x1b[33m";
function log(msg: string) {
console.log(msg);
}
// ── stdin abstraction (handles both TTY and piped) ─────────────────────
const inputLines: string[] = [];
let inputReady: Promise<void>;
function initInput(): Promise<void> {
if (!process.stdin.isTTY) {
// Piped input: buffer everything up front so readline never closes early
inputReady = new Promise((resolve) => {
let buf = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk: string) => {
buf += chunk;
});
process.stdin.on("end", () => {
for (const line of buf.split("\n")) {
const trimmed = line.trim();
if (trimmed) inputLines.push(trimmed);
}
resolve();
});
});
return inputReady;
}
inputReady = Promise.resolve();
return inputReady;
}
async function ask(question: string): Promise<string> {
if (!process.stdin.isTTY) {
// Consume from buffer
if (inputLines.length === 0) return "";
const line = inputLines.shift()!;
process.stdout.write(`${question}${line}\n`);
return line;
}
// Interactive: use readline for proper prompt
const { createInterface } = await import("node:readline");
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// ── Helpers ─────────────────────────────────────────────────────────────
function redactUri(uri: string): string {
return uri.replace(/(\/\/[^:]+:)[^@]+(@)/, "$1****$2");
}
function parseEnvFile(filePath: string): Record<string, string> {
const raw = readFileSync(filePath, "utf-8");
const vars: Record<string, string> = {};
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
vars[key] = val.replace(/^["']|["']$/g, "");
}
return vars;
}
function loadDatabaseUri(envName: EnvName): string | null {
const config = ENVS[envName];
const filePath = path.resolve(process.cwd(), config.file);
if (!existsSync(filePath)) return null;
const vars = parseEnvFile(filePath);
return vars.DATABASE_URI ?? null;
}
function run(
cmd: string,
args: string[],
envOverrides: Record<string, string>,
stdinData?: string,
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
env: { ...process.env, ...envOverrides },
stdio: [stdinData != null ? "pipe" : "ignore", "pipe", "pipe"],
});
if (stdinData != null && child.stdin) {
child.stdin.write(stdinData);
child.stdin.end();
}
let stdout = "";
let stderr = "";
child.stdout?.on("data", (d: Buffer) => {
const s = d.toString();
stdout += s;
process.stdout.write(s);
});
child.stderr?.on("data", (d: Buffer) => {
const s = d.toString();
stderr += s;
process.stderr.write(s);
});
child.on("close", (code) =>
resolve({ code: code ?? 1, stdout, stderr }),
);
});
}
// ── Menu ────────────────────────────────────────────────────────────────
async function pickEnv(forced?: string): Promise<{ env: EnvName; uri: string }> {
if (forced && forced in ENVS) {
const uri = loadDatabaseUri(forced as EnvName);
if (!uri) {
log(
`${RED}Cannot find DATABASE_URI in ${ENVS[forced as EnvName].file}${RESET}`,
);
log(`Create the file with: DATABASE_URI=postgres://user:pass@host:5432/db`);
process.exit(1);
}
return { env: forced as EnvName, uri };
}
log(`\n${BOLD}⚡ Polaris Task Force — Migration Runner${RESET}\n`);
log("Target environment:\n");
for (let i = 0; i < ENV_ORDER.length; i++) {
const key = ENV_ORDER[i];
const cfg = ENVS[key];
const exists = loadDatabaseUri(key) !== null;
const status = exists ? `${GREEN}●${RESET}` : `${DIM}○ missing${RESET}`;
log(
` ${BOLD}${i + 1}${RESET}) ${cfg.color}${cfg.label}${RESET} ${DIM}(${cfg.file})${RESET} ${status}`,
);
}
log("");
const answer = (await ask(` Select (1-${ENV_ORDER.length}): `)).trim();
const idx = parseInt(answer, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= ENV_ORDER.length) {
log(`${RED}Invalid selection.${RESET}`);
process.exit(1);
}
const env = ENV_ORDER[idx];
const uri = loadDatabaseUri(env);
if (!uri) {
log(`\n${RED}DATABASE_URI not found in ${ENVS[env].file}${RESET}`);
log(
`Create the file with: ${BOLD}DATABASE_URI=postgres://user:pass@host:5432/db${RESET}`,
);
process.exit(1);
}
return { env, uri };
}
// ── Main ────────────────────────────────────────────────────────────────
async function main() {
await initInput();
const args = process.argv.slice(2);
const statusOnly = args.includes("--status") || args.includes("-s");
const freshOnly = args.includes("--fresh");
const positionalArgs = args.filter((a) => !a.startsWith("-"));
const forced = positionalArgs[0];
const { env, uri } = await pickEnv(forced);
const cfg = ENVS[env];
const redacted = redactUri(uri);
// Header
log(`\n${cfg.color}${BOLD}━━━ ${cfg.label.toUpperCase()} ━━━${RESET}`);
log(`${DIM} ${redacted}${RESET}\n`);
if (statusOnly) {
log(`${DIM}Migration status:${RESET}\n`);
await run("bun", ["run", "payload", "migrate:status"], {
DATABASE_URI: uri,
});
return;
}
if (freshOnly) {
if (env !== "dev") {
log(`${RED}${BOLD}--fresh is only allowed for dev.${RESET}`);
log(
`${DIM}For staging/production, use migrate (up only) or migrate:fresh manually.${RESET}`,
);
process.exit(1);
}
const confirm = await ask(
`${YELLOW}This will DROP and recreate all tables on ${cfg.label}. Continue? (y/N): ${RESET}`,
);
if (confirm.toLowerCase() !== "y") {
log("Aborted.");
return;
}
log("");
await run("bun", ["run", "payload", "migrate:fresh"], {
DATABASE_URI: uri,
});
log(`\n${GREEN}${BOLD}✓ Fresh migration complete.${RESET}`);
return;
}
// Show current status
log(`${DIM}Migration status:${RESET}\n`);
const statusResult = await run(
"bun",
["run", "payload", "migrate:status"],
{ DATABASE_URI: uri },
);
// Check for pending migrations
const hasPending = statusResult.stdout.includes("│ No │");
if (!hasPending && !statusResult.stderr) {
log(`\n${GREEN}All migrations applied. Nothing to do.${RESET}`);
return;
}
// Confirm
log("");
if (env === "prd") {
log(
`${RED}${BOLD} ╔══════════════════════════════════════════╗${RESET}`,
);
log(
`${RED}${BOLD} ║ YOU ARE ABOUT TO MIGRATE PRODUCTION ║${RESET}`,
);
log(
`${RED}${BOLD} ╚══════════════════════════════════════════╝${RESET}\n`,
);
const confirm = await ask(
`${RED}Type "migrate" to confirm: ${RESET}`,
);
if (confirm.trim() !== "migrate") {
log("Aborted.");
return;
}
} else {
const confirm = await ask(
`${YELLOW}Run migrations on ${cfg.label}? (y/N): ${RESET}`,
);
if (confirm.toLowerCase() !== "y") {
log("Aborted.");
return;
}
}
// Run migration — pipe "y\n" so Payload's dev-mode prompt (triggered by
// push:true's batch=-1 record) is auto-accepted instead of blocking on
// non-interactive stdin.
log(`\n${BOLD}Running migrations...${RESET}\n`);
const result = await run(
"bun",
["run", "payload", "migrate"],
{ DATABASE_URI: uri },
"y\n",
);
if (result.code !== 0) {
log(`\n${RED}${BOLD}✗ Migration failed (exit ${result.code})${RESET}`);
process.exit(result.code);
}
log(`\n${GREEN}${BOLD}✓ Migrations applied successfully.${RESET}`);
}
main().catch((err) => {
console.error(`${RED}Fatal error:${RESET}`, err);
process.exit(1);
});

View file

@ -1,4 +1,5 @@
import type { EventType } from "./eventTypes"; import type { EventType } from "./eventTypes";
import { broadcast } from "@/lib/realtime/bus";
interface GameEventInput { interface GameEventInput {
type: EventType; type: EventType;
@ -52,6 +53,12 @@ export async function emitGameEvent(
}, },
depth: 0, depth: 0,
}); });
broadcast("game-event", {
type: event.type,
actor: event.actor ?? null,
targetCollection: event.targetCollection ?? null,
targetId: event.targetId ?? null,
});
} catch (err) { } catch (err) {
console.error("[EventLog] Failed to emit event:", err); console.error("[EventLog] Failed to emit event:", err);
} }

View file

@ -90,6 +90,18 @@ export const EventTypes = {
TicketReply: "ticket:reply", TicketReply: "ticket:reply",
TicketStatusChange: "ticket:status-change", TicketStatusChange: "ticket:status-change",
TicketAssign: "ticket:assign", TicketAssign: "ticket:assign",
// Projects
ProjectCreate: "project:create",
ProjectUpdate: "project:update",
SprintCreate: "sprint:create",
SprintUpdate: "sprint:update",
SprintStart: "sprint:start",
SprintComplete: "sprint:complete",
ReleaseCreate: "release:create",
ReleaseUpdate: "release:update",
ReleaseStart: "release:start",
ReleaseComplete: "release:complete",
} as const; } as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes]; export type EventType = (typeof EventTypes)[keyof typeof EventTypes];

View file

@ -0,0 +1,52 @@
import { createElement } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
const mission = {
id: 1,
name: "Operation Nightfall",
codeName: "OP:NIGHTFALL",
summary: "Secure the extraction corridor.",
operationType: "main" as const,
classification: {
missionType: "PvE" as const,
startDateTime: "2026-08-20T19:30:00.000Z",
estimatedDuration: 90,
map: 2,
campaign: 3,
},
ownershipAndStatus: {
status: "Scheduled" as const,
},
missionRoles: {
maxPlayers: 12,
},
coverImage: {
id: 8,
updatedAt: "2026-08-20T00:00:00.000Z",
createdAt: "2026-08-20T00:00:00.000Z",
url: "/media/nightfall.webp",
},
};
describe("MissionCard", () => {
afterEach(() => {
cleanup();
});
it("uses the cover image by default and keeps full metadata visible", () => {
render(createElement(MissionCard, { mission, showFullDetails: true }));
const image = document.querySelector("img");
expect(image?.getAttribute("src")).toBe("/media/nightfall.webp");
expect(screen.getByText("1h 30m")).toBeTruthy();
expect(screen.getByText("12 slots")).toBeTruthy();
});
it("suppresses the cover image when the default variant is requested", () => {
render(createElement(MissionCard, { mission, variant: "default" }));
expect(document.querySelector("img")).toBeNull();
});
});