Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ POSTGRES_DB=parcel_society
DATABASE_URL="postgresql://parcel:parcel_password@localhost:5432/parcel_society?schema=public"
APP_SECRET="replace-with-a-long-random-secret"
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
ADMIN_EMAIL="admin@example.org"
ADMIN_PASSWORD="replace-with-a-development-password"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="changeme"
WEB_PORT=3000
NODE_ENV="development"
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ Copy `.env.example` to `.env` and update values as needed.
```bash
DATABASE_URL="postgresql://parcel:parcel_password@localhost:5432/parcel_society?schema=public"
APP_SECRET="replace-with-a-long-random-secret"
ADMIN_EMAIL="admin@example.org"
ADMIN_PASSWORD="replace-with-a-development-password"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="changeme"
NODE_ENV="development"
```

Expand All @@ -100,6 +100,26 @@ NODE_ENV="development"
- `pnpm db:migrate` - run local Prisma migrations
- `pnpm db:studio` - open Prisma Studio
- `pnpm seed` - seed one admin and the four demo treatment servers
- `pnpm seed:demo` - create the full local demo experiment with maps, anonymous players, synthetic decisions, and resolved rounds

## Demo Mode

Create a complete local demo experiment after installing dependencies, generating the Prisma client, and applying migrations:

```bash
pnpm seed:demo
```

The demo seed creates one admin account, four active 2x2 treatment servers, a 10x10 map for each server, 20 anonymous demo participants per server, parcel assignments, a 7-round active season, synthetic decisions for rounds 1-3, resolved round states, contracts, treasury transactions, events, and dashboard-ready analytics. Running it again replaces the existing demo servers with a fresh deterministic demo dataset.

Demo login:

```text
admin@example.com
changeme
```

Warning: Demo credentials are only for local development. Set `ADMIN_EMAIL` and `ADMIN_PASSWORD` for any shared or deployed environment.

## Database Migrations

Expand Down Expand Up @@ -135,6 +155,12 @@ Parcel Society uses Prisma with PostgreSQL. The schema lives at `packages/db/pri
pnpm seed
```

6. Or create the complete demo mode dataset with active servers, players, synthetic decisions, and three resolved rounds:

```bash
pnpm seed:demo
```

For non-development deployments, run Prisma migrations during release using the same schema path, for example `pnpm --filter @parcel-society/db prisma migrate deploy --schema prisma/schema.prisma`.

## Roadmap
Expand Down
57 changes: 56 additions & 1 deletion apps/web/app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,59 @@
"use client";
import { useState } from "react";
import { AdminPageHeader, Card } from "../_components/ui";
export default function AdminLoginPage() { const [email,setEmail]=useState(""); const [password,setPassword]=useState(""); const [saved,setSaved]=useState(false); return <><AdminPageHeader title="Admin API login" description="Stores Basic Auth credentials locally for admin action buttons and API downloads. Server-rendered dashboard data uses server-side fetching." /><Card><form onSubmit={(e)=>{e.preventDefault(); window.localStorage.setItem("parcel_admin_basic", btoa(`${email}:${password}`)); setSaved(true);}} className="max-w-md space-y-4"><label className="block text-sm font-medium">Email<input value={email} onChange={e=>setEmail(e.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2" /></label><label className="block text-sm font-medium">Password<input type="password" value={password} onChange={e=>setPassword(e.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2" /></label><button className="rounded-lg bg-slate-950 px-4 py-2 text-sm font-semibold text-white">Save credentials</button>{saved?<p className="text-sm text-emerald-700">Credentials saved in this browser.</p>:null}</form></Card></>; }

export default function AdminLoginPage() {
const [email, setEmail] = useState("admin@example.com");
const [password, setPassword] = useState("changeme");
const [saved, setSaved] = useState(false);
return (
<>
<AdminPageHeader
title="Admin API login"
description="Stores Basic Auth credentials locally for admin action buttons and API downloads. Demo defaults are for local development only."
/>
<Card>
<form
onSubmit={(event) => {
event.preventDefault();
window.localStorage.setItem(
"parcel_admin_basic",
btoa(`${email}:${password}`),
);
setSaved(true);
}}
className="max-w-md space-y-4"
>
<label className="block text-sm font-medium">
Email
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm font-medium">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
/>
</label>
<p className="text-xs text-amber-700">
Demo credentials are only for local development.
</p>
<button className="rounded-lg bg-slate-950 px-4 py-2 text-sm font-semibold text-white">
Save credentials
</button>
{saved ? (
<p className="text-sm text-emerald-700">
Credentials saved in this browser.
</p>
) : null}
</form>
</Card>
</>
);
}
157 changes: 146 additions & 11 deletions apps/web/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,171 @@
import Link from "next/link";
import { prisma } from "@parcel-society/db";
import { AdminActions } from "./_components/AdminActions";
import { BarMetricChart, PieMetricChart } from "./_components/AdminCharts";
import { AdminPageHeader, Card, StatCard, ButtonLink } from "./_components/ui";
import { formatDate, formatNumber } from "./_components/format";
import { getAnalyticsOverview } from "../../lib/services/adminAnalytics";

export default async function AdminPage() {
const [servers, totalPlayers, activePlayers, exitedPlayers, totalDecisions, recentEvents] = await Promise.all([
const [
servers,
totalPlayers,
activePlayers,
exitedPlayers,
totalDecisions,
recentEvents,
actionMix,
analyticsOverview,
] = await Promise.all([
prisma.server.groupBy({ by: ["status"], _count: { _all: true } }),
prisma.player.count(),
prisma.player.count({ where: { exited: false } }),
prisma.player.count({ where: { exited: true } }),
prisma.decision.count(),
prisma.serverEvent.findMany({ take: 8, orderBy: { createdAt: "desc" }, include: { server: { select: { id: true, name: true } } } }),
prisma.serverEvent.findMany({
take: 8,
orderBy: { createdAt: "desc" },
include: { server: { select: { id: true, name: true } } },
}),
prisma.decision.groupBy({ by: ["actionType"], _count: { _all: true } }),
getAnalyticsOverview(),
]);
const count = (status?: string) => status ? servers.find((row) => row.status === status)?._count._all ?? 0 : servers.reduce((total, row) => total + row._count._all, 0);
const count = (status?: string) =>
status
? (servers.find((row) => row.status === status)?._count._all ?? 0)
: servers.reduce((total, row) => total + row._count._all, 0);
const overviewChart = analyticsOverview.servers.map((server) => ({
round: server.name.replace("Demo ", ""),
exitRate: Number(((server.latest?.exitRate ?? 0) * 100).toFixed(1)),
investmentShare: Number(
((server.latest?.productiveInvestmentShare ?? 0) * 100).toFixed(1),
),
publicShare: Number(
((server.latest?.publicContributionShare ?? 0) * 100).toFixed(1),
),
}));
const actionMixChart = actionMix.map((row) => ({
name: row.actionType.replaceAll("_", " "),
value: row._count._all,
}));
const headerActions = (
<>
{process.env.NODE_ENV === "development" ? (
<AdminActions
actions={[
{
label: "Create Demo Experiment",
url: "/api/admin/demo",
confirm:
"Replace any existing demo servers with a fresh four-server demo experiment?",
},
]}
/>
) : null}
<ButtonLink href="/admin/servers/new">Create server</ButtonLink>
</>
);
return (
<>
<AdminPageHeader title="Admin dashboard" description="Operational overview of Parcel Society servers, participation, decisions, and events." actions={<ButtonLink href="/admin/servers/new">Create server</ButtonLink>} />
<AdminPageHeader
title="Admin dashboard"
description="Operational overview of Parcel Society servers, participation, decisions, and events."
actions={headerActions}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="Total servers" value={formatNumber(count())} />
<StatCard label="Active servers" value={formatNumber(count("ACTIVE"))} />
<StatCard label="Waiting servers" value={formatNumber(count("WAITING"))} />
<StatCard label="Completed servers" value={formatNumber(count("COMPLETED"))} />
<StatCard
label="Active servers"
value={formatNumber(count("ACTIVE"))}
/>
<StatCard
label="Waiting servers"
value={formatNumber(count("WAITING"))}
/>
<StatCard
label="Completed servers"
value={formatNumber(count("COMPLETED"))}
/>
<StatCard label="Total players" value={formatNumber(totalPlayers)} />
<StatCard label="Active players" value={formatNumber(activePlayers)} />
<StatCard label="Exited players" value={formatNumber(exitedPlayers)} />
<StatCard label="Total decisions" value={formatNumber(totalDecisions)} />
<StatCard
label="Total decisions"
value={formatNumber(totalDecisions)}
/>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<Card>
<h2 className="text-lg font-semibold">Demo outcomes by server</h2>
<p className="mt-1 text-sm text-slate-500">
Latest available exit, investment, and public-good contribution
rates.
</p>
{overviewChart.length > 0 ? (
<BarMetricChart
data={overviewChart}
bars={["exitRate", "investmentShare", "publicShare"]}
/>
) : (
<p className="mt-4 text-sm text-slate-500">
Seed demo data to populate this chart.
</p>
)}
</Card>
<Card>
<h2 className="text-lg font-semibold">Decision mix</h2>
<p className="mt-1 text-sm text-slate-500">
Synthetic and participant decisions grouped by action type.
</p>
{actionMixChart.length > 0 ? (
<PieMetricChart data={actionMixChart} />
) : (
<p className="mt-4 text-sm text-slate-500">
No decisions have been recorded yet.
</p>
)}
</Card>
</div>
<Card className="mt-6">
<div className="mb-4 flex items-center justify-between"><h2 className="text-lg font-semibold">Recent server events</h2><Link className="text-sm font-semibold text-slate-700" href="/admin/servers">View servers</Link></div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Recent server events</h2>
<Link
className="text-sm font-semibold text-slate-700"
href="/admin/servers"
>
View servers
</Link>
</div>
<div className="space-y-3">
{recentEvents.map((event) => <div className="flex items-start justify-between gap-4 rounded-lg border border-slate-100 p-3" key={event.id}><div><p className="font-medium text-slate-950">{event.eventType} <span className="text-slate-500">round {event.roundNumber}</span></p><Link className="text-sm text-slate-600 hover:text-slate-950" href={`/admin/servers/${event.server.id}`}>{event.server.name}</Link></div><p className="text-xs text-slate-500">{formatDate(event.createdAt)}</p></div>)}
{recentEvents.length === 0 ? <p className="text-sm text-slate-500">No events have been recorded yet.</p> : null}
{recentEvents.map((event) => (
<div
className="flex items-start justify-between gap-4 rounded-lg border border-slate-100 p-3"
key={event.id}
>
<div>
<p className="font-medium text-slate-950">
{event.eventType}{" "}
<span className="text-slate-500">
round {event.roundNumber}
</span>
</p>
<Link
className="text-sm text-slate-600 hover:text-slate-950"
href={`/admin/servers/${event.server.id}`}
>
{event.server.name}
</Link>
</div>
<p className="text-xs text-slate-500">
{formatDate(event.createdAt)}
</p>
</div>
))}
{recentEvents.length === 0 ? (
<p className="text-sm text-slate-500">
No events have been recorded yet.
</p>
) : null}
</div>
</Card>
</>
Expand Down
33 changes: 33 additions & 0 deletions apps/web/app/api/admin/demo/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { prisma, seedDemo } from "@parcel-society/db";
import { recordAdminAction } from "../../../../lib/api/audit";
import { requireAdminAuth } from "../../../../lib/api/auth";
import {
apiOk,
handleApiError,
ApiException,
} from "../../../../lib/api/responses";

export async function POST(request: Request) {
try {
if (process.env.NODE_ENV === "production") {
throw new ApiException(
403,
"DEMO_SEED_DISABLED",
"Demo experiment creation is disabled in production.",
);
}

const auth = await requireAdminAuth(request);
const result = await seedDemo(prisma);
await recordAdminAction({
auth,
action: "CREATE_DEMO_EXPERIMENT",
entityType: "demo",
entityId: "demo-seed",
after: result,
});
return apiOk({ demo: result });
} catch (error) {
return handleApiError(error, { route: "POST /api/admin/demo" });
}
}
Loading
Loading