diff --git a/.env.example b/.env.example
index 01d3fd1..775cf38 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,10 @@
+# HackVillage environment template. Mirrors lib/env.ts (zod-validated server
+# env) plus the handful of vars read from process.env directly (noted inline).
+# Copy to .env for local dev. NEVER commit a filled-in .env (.gitignore blocks it).
+
# ── Core ────────────────────────────────────────────────────────────────
# PostgreSQL (Neon in prod, Docker locally)
-DATABASE_URL=postgresql://user:password@localhost:5432/hackvillage
-# Direct (non-pooled) connection for migrations — Neon-specific, optional locally
-DIRECT_URL=postgresql://user:password@localhost:5432/hackvillage
+DATABASE_URL=postgresql://user:***@localhost:5432/hackvillage
# Auth.js v5 (Phase 1)
NEXTAUTH_SECRET= # generate: openssl rand -base64 32
@@ -20,25 +22,52 @@ BREVO_API_KEY=
EMAIL_FROM="HackVillage "
# ── Paystack (test keys are sufficient for local development) ────────────
+# Absent key switches payments to the dev simulation port.
PAYSTACK_SECRET_KEY=sk_test_...
-NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_...
-PAYSTACK_WEBHOOK_SECRET=
+# Explicit opt-in to run the SIMULATION payment port in production — the
+# pre-go-live state. Without this a keyless production boot fails closed.
+# Set to "true" ONLY until real Paystack keys go in.
+PAYSTACK_ALLOW_SIMULATION_IN_PROD=
# ── Chain — Polygon (Amoy testnet first; mainnet only after audit) ──────
+# Absent config switches attestations to simulation (marked on /trust).
SMART_CONTRACT_ADDRESS=0x... # PrizeVaultFactory (ADR-007)
-CHAIN_ID=80002
RPC_URL=https://rpc.amoy.polygonscan.com
ATTESTER_PRIVATE_KEY=0x... # platform signer — env-only, NEVER in the repo
POLYGONSCAN_URL=https://amoy.polygonscan.com
-# ── Storage ────────────────────────────────────────────────────────────
+# ── Storage (read from process.env by lib/ports/storage.ts) ──────────────
+# R2/S3-compatible in production; without these, media saves to public/uploads.
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=hackvillage-media
+# Public URL prefix for uploaded media. Defaults to
+# https://pub-.r2.dev/ — set when using a custom domain.
+R2_PUBLIC_BASE=
-# ── Ops (defaults shown; values are enforced by lib/env.ts) ──────────────
+# ── Error tracking (Sentry — all optional; unset = complete no-op) ───────
+# Server + edge runtimes (instrumentation.ts, sentry.*.config.ts)
SENTRY_DSN=
+# Browser runtime (instrumentation-client.ts, app/error.tsx) — public by design
+NEXT_PUBLIC_SENTRY_DSN=
+# Source-map upload at build time (next.config.ts withSentryConfig)
+SENTRY_ORG=
+SENTRY_PROJECT=
+SENTRY_AUTH_TOKEN=
+
+# ── SEO ops ─────────────────────────────────────────────────────────────
+# scripts/ping-indexnow.ts: site root submitted to IndexNow (defaults to
+# https://www.hackvillage.xyz; override for a future domain)
+INDEXNOW_SITE=
+
+# ── Ops (defaults shown; values are enforced by lib/env.ts) ──────────────
PAYOUT_MANUAL_REVIEW_THRESHOLD_KES=500000
PLATFORM_FEE_BPS=500 # 5% organizer-funded fee at deposit (ADR-012)
MIN_PRIZE_POOL_KES=10000 # minimum escrowed pool (Decision D3)
+
+# ── Removed dead vars (2026-09-27 audit; grep confirms zero code refs) ────
+# DIRECT_URL — Prisma datasource uses DATABASE_URL only (no directUrl).
+# PAYSTACK_WEBHOOK_SECRET — webhook HMAC uses PAYSTACK_SECRET_KEY.
+# CHAIN_ID — the chain port hardcodes Amoy; RPC_URL selects the network.
+# NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY — checkout is server-initialized.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e305fde..c5d819b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,8 +61,8 @@ jobs:
- name: Apply database migrations
run: pnpm run db:deploy
- - name: Unit + integration tests
- run: pnpm test
+ - name: Unit + integration tests (coverage gate on services/escrow + services/payout)
+ run: pnpm run test:coverage
- name: Build
run: pnpm run build
diff --git a/.gitignore b/.gitignore
index 9d7384e..df08d10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,9 +26,12 @@ pnpm-debug.log*
*.log
# local env files — never commit credentials (CONTRIBUTING.md)
+# .env.* covers .env.development/.env.test/.env.production etc.; the only
+# committable env file is the documented template.
.env
.env.local
-.env.*.local
+.env.*
+!.env.example
# vercel
.vercel
@@ -46,6 +49,3 @@ next-env.d.ts
# claude-flow local tooling data
.claude-flow/
-
-# production env (pulled from Vercel — contains real credentials)
-.env.production
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da6315c..f05a9be 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -81,14 +81,14 @@ Large features (new escrow flows, DAO mechanics, cross-border payment rails) sho
| Requirement | Minimum Version |
|---|---|
| Node.js | 22.13+ |
-| pnpm | 9.x |
+| pnpm | 11.x (`corepack enable` pins the version from `package.json`) |
| PostgreSQL | 15.x |
| Git | 2.x |
You will also need:
- A **Paystack** account — test keys are sufficient for local development.
-- An RPC endpoint for the target chain (Polygon Mumbai testnet or Solana Devnet) for escrow contract work.
+- An RPC endpoint for the target chain (Polygon Amoy testnet) for escrow contract work.
### Installation
@@ -112,12 +112,16 @@ cp .env.example .env.local
| Variable | Description |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
-| `PAYSTACK_SECRET_KEY` | Paystack secret key (`sk_test_...` for local) |
-| `NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY` | Paystack public key (`pk_test_...` for local) |
-| `SMART_CONTRACT_ADDRESS` | Deployed escrow contract address |
-| `RPC_URL` | JSON-RPC endpoint for the target chain |
| `NEXTAUTH_SECRET` | Random secret for session signing |
| `NEXTAUTH_URL` | Base URL (`http://localhost:3000` locally) |
+| `NEXT_PUBLIC_APP_URL` | Public base URL used in links/metadata |
+| `PAYSTACK_SECRET_KEY` | Paystack secret key (`sk_test_...` for local; absent = simulation) |
+| `SMART_CONTRACT_ADDRESS` | Deployed escrow contract address |
+| `RPC_URL` | JSON-RPC endpoint for the target chain |
+| `ATTESTER_PRIVATE_KEY` | Platform signer for chain attestations (env-only) |
+
+The full, annotated variable list lives in `.env.example` and is validated by
+`lib/env.ts`.
Never commit `.env.local` or any file containing real credentials.
@@ -146,8 +150,12 @@ HackVillage/
├── components/ # Shared React components
├── lib/ # Shared utilities, DB client, API helpers
├── services/
-│ ├── escrow/ # Escrow microservice — Paystack + smart contract calls
-│ └── payout/ # Split disbursement logic
+│ ├── escrow/ # Deposits, vault, webhook, attestations, reconcile
+│ ├── payout/ # Split disbursement engine, tranches, milestones
+│ ├── judging/ # Rubrics, scoring, feedback gate, results
+│ ├── media/ # 48h deadline, trust events, notifications
+│ ├── legacy/ # 3-month check-ins, disputes
+│ └── pow/ # Endorsements, portfolio, hiring intros
├── contracts/ # Smart contract source and ABI
├── db/
│ ├── migrations/ # PostgreSQL migrations
@@ -292,9 +300,10 @@ CI will fail on lint or type errors. Do not disable rules with `eslint-disable`
Tests live in the `tests/` directory, mirroring the source structure.
```bash
-pnpm test # Run all tests
-pnpm run test:unit # Unit tests only
-pnpm run test:e2e # End-to-end tests (requires running dev server)
+pnpm test # Run all tests
+pnpm run test:unit # Unit tests only
+pnpm run test:integration # Integration tests (live Postgres — see tests/integration)
+pnpm run test:coverage # Suite + coverage gate on services/escrow + services/payout
```
### Requirements
diff --git a/README.md b/README.md
index 05fbbdd..536f0d8 100644
--- a/README.md
+++ b/README.md
@@ -135,7 +135,7 @@ HackVillage operates as a three-phase engine:
```
┌───────────────────────────────────────────────────────┐
│ CLIENT LAYER │
-│ Next.js 14 (SEO-optimized, │
+│ Next.js 15 (SEO-optimized, │
│ server components, App Router) │
└────────────────────────┬──────────────────────────────┘
│
@@ -147,19 +147,22 @@ HackVillage operates as a three-phase engine:
└──────────┬─────────────────────────┬──────────────────┘
│ │
┌──────────▼──────────┐ ┌──────────▼──────────────────┐
-│ PostgreSQL DB │ │ Escrow Microservice │
+│ PostgreSQL DB │ │ Escrow service (in-repo) │
│ Structured Proof │ │ Paystack + Smart Contract │
-│ of Work tracking │ │ (Polygon / Solana layer) │
+│ of Work tracking │ │ (Polygon Amoy) │
└─────────────────────┘ └─────────────────────────────┘
```
+> The separate escrow microservice and Paystack Split API were both evaluated
+> and rejected — see ADR-001 (modular monolith) and ADR-006 (direct transfers).
+
| Layer | Technology | Purpose |
|---|---|---|
-| Frontend | Next.js 14 | SEO-optimized project discovery |
+| Frontend | Next.js 15 | SEO-optimized project discovery |
| Backend | Node.js / TypeScript | High-concurrency event-day API |
| Database | PostgreSQL | Structured Proof of Work records |
| Payments | Paystack | Fiat deposits, M-Pesa & bank payouts |
-| Ledger | Smart Contract (Polygon/Solana) | Transparent, tamper-proof transaction log |
+| Ledger | Smart Contract (Polygon Amoy) | Transparent, tamper-proof transaction log |
| Open Source Core | GitHub | Judging logic & escrow mechanics are public |
---
@@ -229,12 +232,13 @@ Paystack API ──── Final transfer ──── Public Ledger entry record
| Phase | Feature | Status |
|---|---|---|
-| v1.0 | Prize Vault & Escrow Engine | In Development |
-| v1.0 | Developer Proof of Work Profiles | In Development |
-| v1.0 | Paystack Split Disbursement | In Development |
-| v1.1 | 48-Hour Media Vault + Trust Penalty | Planned |
-| v1.1 | One-Click Internship Matching | Planned |
-| v1.2 | Legacy Tracker (3-month check-ins) | Planned |
+| v1.0 | Prize Vault & Escrow Engine | Done — live in production |
+| v1.0 | Developer Proof of Work Profiles | Done |
+| v1.0 | Paystack Split Disbursement (50/50 tranches) | Done |
+| v1.1 | 48-Hour Media Vault + Trust Penalty | Done |
+| v1.1 | One-Click Internship Matching | Done |
+| v1.1 | Newsletters (subscribe + organizer blasts) | Done |
+| v1.2 | Legacy Tracker (3-month check-ins + dispute window) | Done |
| v2.0 | **HackVillage DAO** — Community-driven Elite Organizer rankings | Moonshot |
| v2.0 | **Global Node** — Nigeria, Rwanda, cross-border prize handling | Moonshot |
| v2.0 | **AI Judging Co-pilot** — Pre-screen repos for quality & plagiarism | Moonshot |
@@ -260,14 +264,25 @@ pnpm install
### Environment Variables
-Create a `.env.local` file at the project root:
+Copy the template and fill in your values — `.env.example` documents every
+variable the code reads (validated by `lib/env.ts`):
+
+```bash
+cp .env.example .env
+```
+
+The minimum set for a local boot:
```env
-DATABASE_URL=postgresql://user:password@localhost:5432/hackvillage
+DATABASE_URL=postgresql://user:***@localhost:5432/hackvillage
+NEXTAUTH_SECRET= # generate: openssl rand -base64 32
+NEXTAUTH_URL=http://localhost:3000
+NEXT_PUBLIC_APP_URL=http://localhost:3000
+# Optional: without these, payments/chain run in documented simulation modes
PAYSTACK_SECRET_KEY=sk_test_...
-NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_...
SMART_CONTRACT_ADDRESS=0x...
-RPC_URL=https://...
+RPC_URL=https://rpc.amoy.polygonscan.com
+ATTESTER_PRIVATE_KEY=0x...
```
### Development
diff --git a/app/(app)/dashboard/hackathons/page.tsx b/app/(app)/dashboard/hackathons/page.tsx
index f0a4763..f143bbf 100644
--- a/app/(app)/dashboard/hackathons/page.tsx
+++ b/app/(app)/dashboard/hackathons/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { CalendarDays } from "lucide-react";
+import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -65,7 +66,7 @@ export default async function DashboardEventsPage() {
{verified ? (
-
Prize Verified
+
) : (
{STATUS_LABELS[event.status]}
)}
diff --git a/app/(app)/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx
new file mode 100644
index 0000000..348d49f
--- /dev/null
+++ b/app/(app)/dashboard/loading.tsx
@@ -0,0 +1,21 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index 8339a6a..cc1ce35 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -1,11 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { CalendarX2, Rocket } from "lucide-react";
+import { CalendarX2 } from "lucide-react";
import { LegacyCheckinCard } from "@/components/legacy/legacy-cards";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { requireOnboardedUser } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
@@ -71,34 +70,17 @@ export default async function DashboardPage() {
) : null}
-
0 ? "" : "No active hackathons right now"}
- description={
- dueCheckins.length > 0
- ? ""
- : "Prize Verified hackathons appear here the moment you register: active hackathons, teams, and in-flight payouts at a glance."
- }
- action={
- dueCheckins.length > 0 ? undefined : (
+ {dueCheckins.length === 0 ? (
+
Browse Hackathons
- )
- }
- />
-
- {dueCheckins.length === 0 ? (
-
-
- The Full Loop Is Live
-
-
- Escrowed prize pools, 50/50 payouts, structured judge feedback, verified Proof-of-Work
- profiles, hiring intros, the 48-hour media standard, and 3-month legacy tracking. The
- platform runs end to end. Next: production hardening (runbooks, load tests, audit).
-
-
+ }
+ />
) : null}
);
diff --git a/app/(app)/dashboard/winnings/page.tsx b/app/(app)/dashboard/winnings/page.tsx
index 944e2b6..087f087 100644
--- a/app/(app)/dashboard/winnings/page.tsx
+++ b/app/(app)/dashboard/winnings/page.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import { HandCoins } from "lucide-react";
import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { DisputeForm } from "@/components/legacy/legacy-cards";
import { canOpenDispute } from "@/services/legacy/dispute-window";
@@ -96,7 +97,9 @@ export default async function WinningsPage() {
It takes a minute.
-
Set up payouts in Settings →
+
+ Set up payouts in Settings
+
) : null}
@@ -108,7 +111,7 @@ export default async function WinningsPage() {
description="When you win a Prize Verified hackathon, the 50% instant tranche lands here the same day, tracked to the shilling."
action={
-
Find a hackathon
+
Find a hackathon
}
/>
diff --git a/app/(app)/hackathons/[slug]/workspace/loading.tsx b/app/(app)/hackathons/[slug]/workspace/loading.tsx
new file mode 100644
index 0000000..729ad5f
--- /dev/null
+++ b/app/(app)/hackathons/[slug]/workspace/loading.tsx
@@ -0,0 +1,23 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+
+
+ {[0, 1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(app)/hackathons/[slug]/workspace/page.tsx b/app/(app)/hackathons/[slug]/workspace/page.tsx
index 05e51df..9cb8f48 100644
--- a/app/(app)/hackathons/[slug]/workspace/page.tsx
+++ b/app/(app)/hackathons/[slug]/workspace/page.tsx
@@ -90,6 +90,12 @@ export default async function WorkspacePage({
},
});
+ // The endorse page is judge-only; only surface the link to active judges.
+ const activeJudgeship = await prisma.judgeAssignment.findFirst({
+ where: { eventId: event.id, userId: user.id, status: "ACTIVE" },
+ select: { id: true },
+ });
+
const windowOpen = submissionWindowOpen(event);
return (
@@ -201,17 +207,19 @@ export default async function WorkspacePage({
)
) : null}
-
- What Happens After Announcement
-
- Endorse the winners you judged:{" "}
-
- write endorsements
- {" "}
- once results are announced. Your one-liner becomes permanent proof on their
- Proof-of-Work profiles.
-
-
+ {activeJudgeship ? (
+
+ What Happens After Announcement
+
+ Endorse the winners you judged:{" "}
+
+ write endorsements
+ {" "}
+ once results are announced. Your one-liner becomes permanent proof on their
+ Proof-of-Work profiles.
+
+
+ ) : null}
);
}
diff --git a/app/(app)/judge/loading.tsx b/app/(app)/judge/loading.tsx
new file mode 100644
index 0000000..69a20c0
--- /dev/null
+++ b/app/(app)/judge/loading.tsx
@@ -0,0 +1,25 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+
+
+
+
+ {[0, 1, 2].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(app)/organizer/hackathons/[slug]/edit/page.tsx b/app/(app)/organizer/hackathons/[slug]/edit/page.tsx
index 8bb46c8..03daaf1 100644
--- a/app/(app)/organizer/hackathons/[slug]/edit/page.tsx
+++ b/app/(app)/organizer/hackathons/[slug]/edit/page.tsx
@@ -36,8 +36,8 @@ export default async function EditEventPage({
Edit Draft Hackathon
- Drafts stay editable. Once published, the hackathon locks until the vault deposit flow
- arrives.
+ Drafts stay editable. Once published, the hackathon locks and goes live when the vault
+ deposit confirms.
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/(app)/organizer/hackathons/[slug]/page.tsx b/app/(app)/organizer/hackathons/[slug]/page.tsx
index 5a9268c..88590ee 100644
--- a/app/(app)/organizer/hackathons/[slug]/page.tsx
+++ b/app/(app)/organizer/hackathons/[slug]/page.tsx
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { Lock } from "lucide-react";
import { CoverUploader } from "@/components/organizer/cover-uploader";
+import { EventCancelButton } from "@/components/organizer/event-cancel-button";
import { PublishGate } from "@/components/organizer/publish-gate";
import { JudgingSection } from "@/components/judging/judging-section";
import { Badge } from "@/components/ui/badge";
@@ -78,11 +79,16 @@ export default async function EventCommandCenterPage({ params }: PageProps) {
hackvillage.xyz/hackathons/{event.slug}
- {isDraft ? (
-
- Edit Draft
-
- ) : null}
+
+ {isDraft ? (
+
+ Edit Draft
+
+ ) : null}
+ {event.status === "DRAFT" || event.status === "PENDING_DEPOSIT" ? (
+
+ ) : null}
+
diff --git a/app/(app)/organizer/hackathons/[slug]/winners/page.tsx b/app/(app)/organizer/hackathons/[slug]/winners/page.tsx
index 03383d1..648e24b 100644
--- a/app/(app)/organizer/hackathons/[slug]/winners/page.tsx
+++ b/app/(app)/organizer/hackathons/[slug]/winners/page.tsx
@@ -55,6 +55,33 @@ export default async function EventWinnersPage({
const results = event.status === "JUDGING" ? await computeEventResults(event.id) : null;
const scoreFor = (teamId: string) => results?.results.find((r) => r.teamId === teamId) ?? null;
+ /** The final-50% due for a winner: the milestone tranche when it exists, else pool minus instant. */
+ const milestoneDueKes = (winner: (typeof event.winners)[number]): number => {
+ const instant = winner.payouts.find((p) => p.tranche === "INSTANT");
+ const milestonePayout = winner.payouts.find((p) => p.tranche === "MILESTONE");
+ return (
+ milestonePayout?.amountKes ??
+ (winner.milestoneRequired ? winner.amountKes - (instant?.amountKes ?? 0) : 0)
+ );
+ };
+
+ const milestoneDues = event.winners
+ .filter((winner) => winner.milestoneRequired)
+ .map((winner) => {
+ const milestonePayout = winner.payouts.find((p) => p.tranche === "MILESTONE");
+ return {
+ id: winner.id,
+ place: winner.place,
+ teamName: winner.team.name,
+ amountKes: milestoneDueKes(winner),
+ dueAt: winner.milestone?.dueAt ?? null,
+ confirmedAt: winner.milestone?.confirmedAt ?? null,
+ payoutStatus: milestonePayout?.status ?? null,
+ };
+ })
+ .sort((a, b) => a.place - b.place);
+ const outstandingDues = milestoneDues.filter((due) => due.payoutStatus !== "SUCCEEDED");
+
return (
@@ -181,20 +208,68 @@ export default async function EventWinnersPage({
{winner.milestone && !winner.milestone.confirmedAt ? (
-
+
) : null}
);
})}
-
- Milestone Dues
-
- The final 50% releases when you confirm each winner's handover. Confirming
- triggers the milestone payout immediately.
-
-
+ {milestoneDues.length > 0 ? (
+
+ Milestone Dues
+
+ {outstandingDues.length === 0
+ ? "Every milestone tranche is settled."
+ : `${formatKes(outstandingDues.reduce((sum, due) => sum + due.amountKes, 0))} outstanding across ${outstandingDues.length} handover${outstandingDues.length === 1 ? "" : "s"}. Confirming a handover releases the tranche immediately.`}
+
+
+ {milestoneDues.map((due) => (
+
+
+ {due.place}
+ {["st", "nd", "rd"][due.place - 1] ?? "th"} · {due.teamName}
+
+
+
+ {formatKes(due.amountKes)}
+
+
+ {due.payoutStatus
+ ? due.payoutStatus.toLowerCase().replace("_", " ")
+ : due.confirmedAt
+ ? "releasing"
+ : "awaiting handover"}
+
+ {due.dueAt ? (
+
+ due{" "}
+ {new Date(due.dueAt).toLocaleDateString("en-KE", {
+ day: "numeric",
+ month: "short",
+ })}
+
+ ) : null}
+
+
+ ))}
+
+
+ ) : null}
>
) : null}
diff --git a/app/(app)/organizer/loading.tsx b/app/(app)/organizer/loading.tsx
new file mode 100644
index 0000000..18ad7aa
--- /dev/null
+++ b/app/(app)/organizer/loading.tsx
@@ -0,0 +1,21 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/app/(app)/organizer/organization/loading.tsx b/app/(app)/organizer/organization/loading.tsx
new file mode 100644
index 0000000..baabf3d
--- /dev/null
+++ b/app/(app)/organizer/organization/loading.tsx
@@ -0,0 +1,19 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/app/(app)/organizer/organization/page.tsx b/app/(app)/organizer/organization/page.tsx
new file mode 100644
index 0000000..4bdae0b
--- /dev/null
+++ b/app/(app)/organizer/organization/page.tsx
@@ -0,0 +1,197 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ShieldCheck, Users } from "lucide-react";
+import type { TrustEventType } from "@prisma/client";
+
+import { InviteCodeManager } from "@/components/organizer/invite-code-manager";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardTitle } from "@/components/ui/card";
+import { EmptyState } from "@/components/ui/empty-state";
+import { requireSurface } from "@/lib/auth/guards";
+import { prisma } from "@/lib/db";
+
+export const metadata: Metadata = { title: "Organization Settings" };
+
+const KYB_BADGE = {
+ NONE: { label: "KYB: Not Started", tone: "warning" },
+ PENDING: { label: "KYB: In Review", tone: "warning" },
+ VERIFIED: { label: "Verified Organization", tone: "success" },
+ FAILED: { label: "KYB: Needs Attention", tone: "danger" },
+} as const;
+
+const TRUST_EVENT_LABELS: Record = {
+ MEDIA_PENALTY: "Media delivered late",
+ PAYOUT_EXCELLENCE: "Payout excellence",
+ MANUAL_ADJUST: "Trust adjustment",
+ APPEAL_GRANTED: "Appeal granted",
+};
+
+const ROLE_ORDER = { OWNER: 0, ADMIN: 1, MEMBER: 2 } as const;
+
+const dateFormat = new Intl.DateTimeFormat("en-KE", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ timeZone: "Africa/Nairobi",
+});
+
+export default async function OrganizationSettingsPage() {
+ const user = await requireSurface("organizer");
+
+ const membership = await prisma.orgMember.findFirst({
+ where: { userId: user.id, status: "ACTIVE" },
+ include: { org: true },
+ });
+
+ if (!membership) {
+ return (
+
+ Create Organization
+
+ }
+ />
+ );
+ }
+
+ const org = membership.org;
+ const [members, activeInvites, trustEvents] = await Promise.all([
+ prisma.orgMember.findMany({
+ where: { orgId: org.id, status: "ACTIVE" },
+ include: { user: { select: { name: true, email: true, handle: true } } },
+ }),
+ prisma.orgInvitation.findMany({
+ where: { orgId: org.id, acceptedAt: null, expiresAt: { gt: new Date() } },
+ orderBy: { createdAt: "desc" },
+ take: 5,
+ }),
+ prisma.trustEvent.findMany({
+ where: { orgId: org.id },
+ orderBy: { createdAt: "desc" },
+ take: 20,
+ }),
+ ]);
+ const sortedMembers = [...members].sort(
+ (a, b) => ROLE_ORDER[a.role] - ROLE_ORDER[b.role],
+ );
+ const kyb = KYB_BADGE[org.kycStatus];
+
+ return (
+
+
+
+
{org.name}
+
+ Organization settings · public page at{" "}
+
+ /organizers/{org.slug}
+
+
+
+
Trust score {org.trustScore}
+
+
+
+
+
+
+ Business Verification
+
+
+ {org.kycStatus === "VERIFIED"
+ ? "Your organization is verified. Your hackathons carry the Verified Organization badge."
+ : "Verification is required before your first prize-pool deposit. Review takes up to 48 hours."}
+
+
+
+ {kyb.label}
+
+
+ {org.kycStatus === "VERIFIED" ? "View" : "Continue"}
+
+
+
+
+
+
+
+
+ Members
+
+
+ Everyone with organizer access to {org.name}. Owners and admins can manage hackathons,
+ invites and this page.
+
+
+ {sortedMembers.map((member) => (
+
+
+
+ {member.user.name ?? member.user.email}
+ {member.userId === user.id ? (
+ (you)
+ ) : null}
+
+
{member.user.email}
+
+
+ {member.role.toLowerCase()}
+
+
+ ))}
+
+
+
+
({
+ token: invite.token,
+ expiresAt: invite.expiresAt.toISOString(),
+ }))}
+ />
+
+
+ Trust History
+
+ Why the score is where it is. Penalties and awards land here the moment they are
+ recorded; the same history (minus internal notes) is public on your organizer page.
+
+ {trustEvents.length === 0 ? (
+
+ No trust events yet. The score moves with payout speed, media delivery and milestone
+ honesty.
+
+ ) : (
+
+ {trustEvents.map((event) => (
+
+
+
+ {TRUST_EVENT_LABELS[event.type]}
+
+
+ {dateFormat.format(event.createdAt)}
+ {event.reason ? ` · ${event.reason}` : ""}
+
+
+
+ {event.delta > 0 ? `+${event.delta}` : event.delta} points
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/app/(app)/organizer/page.tsx b/app/(app)/organizer/page.tsx
index 1837925..95582d5 100644
--- a/app/(app)/organizer/page.tsx
+++ b/app/(app)/organizer/page.tsx
@@ -87,7 +87,10 @@ export default async function OrganizerPage() {
{org.name}
- {membership.role.toLowerCase()} · hackvillage.xyz/organizers/{org.slug}
+ {membership.role.toLowerCase()} ·{" "}
+
+ /organizers/{org.slug}
+
@@ -123,8 +126,8 @@ export default async function OrganizerPage() {
Run A Hackathon
- Five steps to a draft; publishing declares the prize pool. The vault deposit flow
- (Phase 3) flips it live with the Prize Verified badge.
+ Five steps to a draft; publishing declares the prize pool. Locking the pool in the
+ Prize Vault flips it live with the Prize Verified badge.
diff --git a/app/(auth)/onboarding/choose/page.tsx b/app/(auth)/onboarding/choose/page.tsx
index a55e806..45c8758 100644
--- a/app/(auth)/onboarding/choose/page.tsx
+++ b/app/(auth)/onboarding/choose/page.tsx
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
-import { Building2, Code2 } from "lucide-react";
+import Link from "next/link";
+import { Building2, Code2, LogIn } from "lucide-react";
import { RoleChoiceCards } from "@/components/onboarding/role-choice-cards";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { currentUser } from "@/lib/auth/guards";
export const metadata: Metadata = { title: "Choose Your Path" };
@@ -9,10 +12,27 @@ export const metadata: Metadata = { title: "Choose Your Path" };
export default async function OnboardingChoosePage() {
const user = await currentUser();
if (!user) {
+ // Signed-out visitors get a real way forward, not a dead end. After
+ // sign-in the auth flow returns unonboarded users to this page itself
+ // (lib/auth/actions.ts), so no ?next= round-trip is needed.
return (
-
- Sign in first, then we'll set you up.
-
+
+
+ Sign In To Choose Your Path
+
+
+ Pick organizer or developer right after you sign in — we'll bring you straight back
+ here.
+
+
+
+ Sign In
+
+
+ Create Account
+
+
+
);
}
diff --git a/app/(marketing)/developers/[handle]/loading.tsx b/app/(marketing)/developers/[handle]/loading.tsx
new file mode 100644
index 0000000..9a2c388
--- /dev/null
+++ b/app/(marketing)/developers/[handle]/loading.tsx
@@ -0,0 +1,20 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(marketing)/hackathons/[slug]/loading.tsx b/app/(marketing)/hackathons/[slug]/loading.tsx
new file mode 100644
index 0000000..a97c994
--- /dev/null
+++ b/app/(marketing)/hackathons/[slug]/loading.tsx
@@ -0,0 +1,23 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/app/(marketing)/hackathons/[slug]/page.tsx b/app/(marketing)/hackathons/[slug]/page.tsx
index 3c9f94f..b58dca6 100644
--- a/app/(marketing)/hackathons/[slug]/page.tsx
+++ b/app/(marketing)/hackathons/[slug]/page.tsx
@@ -5,6 +5,7 @@ import { CalendarDays, Clock, MapPin, Users } from "lucide-react";
import { KeyDatesCard } from "@/components/patterns/key-dates-card";
import { OrganizerCard } from "@/components/patterns/organizer-card";
+import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge";
import { StatusTimeline } from "@/components/patterns/status-timeline";
import { JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
@@ -175,6 +176,7 @@ export default async function EventDetailPage({ params, searchParams }: PageProp
{event._count.teams} team
{event._count.teams === 1 ? "" : "s"}
+ {event.prizeVerifiedAt ? : null}
{event.summary ? (
{event.summary}
diff --git a/app/(marketing)/hackathons/loading.tsx b/app/(marketing)/hackathons/loading.tsx
new file mode 100644
index 0000000..4f94f85
--- /dev/null
+++ b/app/(marketing)/hackathons/loading.tsx
@@ -0,0 +1,22 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ {[0, 1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/(marketing)/hackathons/page.tsx b/app/(marketing)/hackathons/page.tsx
index 57bdf5b..d508c2c 100644
--- a/app/(marketing)/hackathons/page.tsx
+++ b/app/(marketing)/hackathons/page.tsx
@@ -73,6 +73,7 @@ const getStoredCards = unstable_cache(
orgTrustScore: event.org.trustScore,
categories: event.categories,
coverUrl: event.coverUrl,
+ prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null,
}));
},
["public-hackathon-listing"],
diff --git a/app/(marketing)/organizers/[slug]/loading.tsx b/app/(marketing)/organizers/[slug]/loading.tsx
new file mode 100644
index 0000000..fe08171
--- /dev/null
+++ b/app/(marketing)/organizers/[slug]/loading.tsx
@@ -0,0 +1,29 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(marketing)/organizers/[slug]/page.tsx b/app/(marketing)/organizers/[slug]/page.tsx
new file mode 100644
index 0000000..f724ba2
--- /dev/null
+++ b/app/(marketing)/organizers/[slug]/page.tsx
@@ -0,0 +1,418 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { CalendarDays, ExternalLink, MapPin, ShieldCheck, Timer } from "lucide-react";
+import type { TrustEventType } from "@prisma/client";
+
+import { JsonLd } from "@/components/seo/json-ld";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardDescription, CardTitle } from "@/components/ui/card";
+import { prisma } from "@/lib/db";
+import { formatEventDates } from "@/lib/events/format";
+import { isPublicHackathon } from "@/lib/events/visibility";
+import { formatOrgLocation, isOrgKind, ORG_KIND_LABELS } from "@/lib/orgs/details";
+import { pageOpenGraph } from "@/lib/seo/metadata";
+import { appUrl } from "@/lib/url";
+import { formatKes } from "@/lib/utils";
+
+import { summarizeMediaTimeliness, summarizePayoutPerformance } from "./performance";
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+/**
+ * Public-safe organization select. contactPhone is deliberately absent — it
+ * is for the HackVillage team only (schema comment) and must never reach a
+ * public page.
+ */
+const PUBLIC_ORG_SELECT = {
+ id: true,
+ name: true,
+ slug: true,
+ about: true,
+ kind: true,
+ city: true,
+ country: true,
+ website: true,
+ socialUrl: true,
+ kycStatus: true,
+ trustScore: true,
+ createdAt: true,
+} as const;
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { slug } = await params;
+ const org = await prisma.organization.findUnique({
+ where: { slug },
+ select: { name: true, about: true, kycStatus: true },
+ });
+ if (!org) return { title: "Organizer Not Found", robots: { index: false } };
+ const verified = org.kycStatus === "VERIFIED";
+ return {
+ title: `${org.name}, ${verified ? "Verified " : ""}Hackathon Organizer`,
+ description:
+ org.about ??
+ `${org.name} hosts hackathons on HackVillage with 100% escrowed prize pools and payouts recorded on a public ledger.`,
+ alternates: { canonical: `/organizers/${slug}` },
+ openGraph: pageOpenGraph(`/organizers/${slug}`),
+ };
+}
+
+const joinedFormat = new Intl.DateTimeFormat("en-KE", {
+ month: "short",
+ year: "numeric",
+ timeZone: "Africa/Nairobi",
+});
+
+const dateFormat = new Intl.DateTimeFormat("en-KE", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ timeZone: "Africa/Nairobi",
+});
+
+const TRUST_EVENT_LABELS: Record = {
+ MEDIA_PENALTY: "Media delivered late",
+ PAYOUT_EXCELLENCE: "Payout excellence",
+ MANUAL_ADJUST: "Trust adjustment",
+ APPEAL_GRANTED: "Appeal granted",
+};
+
+/** "technetium.co.ke" from "https://www.technetium.co.ke/" (same rule as the Organizer card). */
+function linkLabel(url: string): string {
+ try {
+ const { hostname, pathname } = new URL(url);
+ const host = hostname.replace(/^www\./, "");
+ return pathname.length > 1 ? `${host}${pathname.replace(/\/$/, "")}` : host;
+ } catch {
+ return url;
+ }
+}
+
+function formatMedianMinutes(minutes: number): string {
+ if (minutes < 60) return `${Math.round(minutes)} min`;
+ const hours = minutes / 60;
+ return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)} h`;
+}
+
+export default async function OrganizerTrustPage({ params }: PageProps) {
+ const { slug } = await params;
+ const org = await prisma.organization.findUnique({
+ where: { slug },
+ select: PUBLIC_ORG_SELECT,
+ });
+ if (!org) notFound();
+
+ const [events, trustEvents, payouts, media, escrowed] = await Promise.all([
+ prisma.event.findMany({
+ where: { orgId: org.id, publishedAt: { not: null } },
+ select: {
+ slug: true,
+ title: true,
+ status: true,
+ startsAt: true,
+ endsAt: true,
+ publishedAt: true,
+ prizeVerifiedAt: true,
+ prizes: { select: { amountKes: true } },
+ },
+ orderBy: { startsAt: "desc" },
+ take: 12,
+ }),
+ prisma.trustEvent.findMany({
+ where: { orgId: org.id },
+ orderBy: { createdAt: "desc" },
+ take: 10,
+ }),
+ prisma.payout.findMany({
+ where: { winner: { event: { orgId: org.id } } },
+ select: {
+ tranche: true,
+ status: true,
+ paidAt: true,
+ winner: { select: { announcedAt: true } },
+ },
+ }),
+ prisma.mediaAsset.findMany({
+ where: { event: { orgId: org.id }, status: { not: "HIDDEN" } },
+ select: {
+ eventId: true,
+ uploadedAt: true,
+ event: { select: { mediaDeadlineAt: true } },
+ },
+ }),
+ prisma.prizeBreakdown.aggregate({
+ _sum: { amountKes: true },
+ where: { event: { orgId: org.id, prizeVerifiedAt: { not: null } } },
+ }),
+ ]);
+
+ const performance = summarizePayoutPerformance(
+ payouts.map((payout) => ({
+ tranche: payout.tranche,
+ status: payout.status,
+ paidAt: payout.paidAt,
+ announcedAt: payout.winner.announcedAt,
+ })),
+ );
+ const timeliness = summarizeMediaTimeliness(
+ media.map((asset) => ({
+ eventId: asset.eventId,
+ uploadedAt: asset.uploadedAt,
+ mediaDeadlineAt: asset.event.mediaDeadlineAt,
+ })),
+ );
+
+ const location = formatOrgLocation(org);
+ const facts = [isOrgKind(org.kind) ? ORG_KIND_LABELS[org.kind] : null, location].filter(Boolean);
+ const links = [org.website, org.socialUrl].filter((link): link is string => Boolean(link));
+ const verified = org.kycStatus === "VERIFIED";
+
+ const stats = [
+ { label: "Hackathons hosted", value: String(events.length) },
+ { label: "Prizes escrowed", value: formatKes(escrowed._sum.amountKes ?? 0) },
+ { label: "On HackVillage since", value: joinedFormat.format(org.createdAt) },
+ ];
+
+ return (
+ <>
+ 0 ? { sameAs: links } : {}),
+ ...(location
+ ? {
+ address: {
+ "@type": "PostalAddress",
+ ...(org.city ? { addressLocality: org.city } : {}),
+ addressCountry: org.country ?? "KE",
+ },
+ }
+ : {}),
+ }}
+ />
+
+
+
+ {org.name}
+ {facts.length > 0 ? (
+
+ {location ? : null}
+ {facts.join(" · ")}
+
+ ) : null}
+
+ {verified ? (
+
+ Verified Organization
+
+ ) : null}
+ Trust score {org.trustScore}
+
+ {org.about ? (
+ {org.about}
+ ) : null}
+ {links.length > 0 ? (
+
+ ) : null}
+
+ {stats.map((stat) => (
+
+
{stat.label}
+
+ {stat.value}
+
+
+ ))}
+
+
+
+
+
+
+
+ Instant Payouts
+ {performance.instantTotal === 0 ? (
+
+ No payouts yet. The first winners paid will start this record.
+
+ ) : (
+ <>
+
+ {performance.instantSucceeded} of {performance.instantTotal}
+
+
+ instant-tranche payouts confirmed. Winners are promised 50% of their prize
+ within an hour of results.
+
+ >
+ )}
+
+
+
+ Announce→Paid
+
+ {performance.medianInstantMinutes === null ? (
+
+ No confirmed instant payouts yet, so there is no median time to report.
+
+ ) : (
+ <>
+
+ {formatMedianMinutes(performance.medianInstantMinutes)}
+
+
+ median time from winners announced to the instant payout landing.
+
+ >
+ )}
+
+
+
+ Media Delivery
+
+ {timeliness.eventsWithMedia === 0 ? (
+
+ No event media yet. Event photos delivered within 48 hours start this record.
+
+ ) : (
+ <>
+
+ {timeliness.onTime} of {timeliness.eventsWithMedia}
+
+
+ events delivered media inside the 48-hour standard.
+
+ >
+ )}
+
+
+
+
+
+
+ Hackathons
+
+ {events.length === 0 ? (
+
+
+ {org.name} has not published a hackathon yet. When they do, it appears here with
+ its escrow status.
+
+
+ ) : (
+
+ {events.map((event) => {
+ const isPublic = isPublicHackathon(event);
+ const poolKes = event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0);
+ return (
+
+
+
+
+ {isPublic ? (
+
+ {event.title}
+
+ ) : (
+
+ {event.title}
+
+ )}
+
+ {formatEventDates(event.startsAt, event.endsAt)} ·{" "}
+ {formatKes(poolKes)} prize pool
+
+
+
+ {event.prizeVerifiedAt ? (
+
+ Prize Verified
+
+ ) : (
+ {event.status.toLowerCase()}
+ )}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ Trust History
+
+
+ {trustEvents.length === 0 ? (
+
+ No trust events yet. The score moves with payout speed, media delivery and
+ milestone honesty; a quiet history is a good history.
+
+ ) : (
+
+ {trustEvents.map((event) => (
+
+
+
+ {TRUST_EVENT_LABELS[event.type]}
+
+
+ {dateFormat.format(event.createdAt)}
+ {event.type !== "MANUAL_ADJUST" && event.reason
+ ? ` · ${event.reason}`
+ : ""}
+
+
+
+ {event.delta > 0 ? `+${event.delta}` : event.delta} points
+
+
+ ))}
+
+ )}
+
+
+ Trust scores start at 100 and move with payout speed, media delivery and milestone
+ honesty. Every lock and payout by this organizer is on the{" "}
+
+ public ledger
+
+ .
+
+
+
+
+ >
+ );
+}
diff --git a/app/(marketing)/organizers/[slug]/performance.ts b/app/(marketing)/organizers/[slug]/performance.ts
new file mode 100644
index 0000000..11095f2
--- /dev/null
+++ b/app/(marketing)/organizers/[slug]/performance.ts
@@ -0,0 +1,88 @@
+import type { PayoutStatus, PayoutTranche } from "@prisma/client";
+
+/**
+ * Pure math for the /organizers/[slug] payout-performance section, colocated
+ * with the route so it stays unit-testable (Next.js page modules may only
+ * export route entrypoints). Every metric degrades to an explicit empty
+ * state rather than a misleading zero — an organizer with no payouts yet has
+ * a clean record, not a failed one.
+ */
+
+export interface PayoutRecord {
+ tranche: PayoutTranche;
+ status: PayoutStatus;
+ paidAt: Date | null;
+ /** The parent winner's announcedAt — the start of the payout clock. */
+ announcedAt: Date;
+}
+
+export interface PayoutPerformance {
+ /** All instant-tranche payouts ever attempted for the organizer's events. */
+ instantTotal: number;
+ instantSucceeded: number;
+ /** Median minutes from winners announced to instant payout confirmed. */
+ medianInstantMinutes: number | null;
+}
+
+/** Median of a numeric sample; null when empty (never a fake zero). */
+export function median(values: number[]): number | null {
+ if (values.length === 0) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ const middle = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0
+ ? (sorted[middle - 1] + sorted[middle]) / 2
+ : sorted[middle];
+}
+
+export function summarizePayoutPerformance(records: PayoutRecord[]): PayoutPerformance {
+ const instant = records.filter((record) => record.tranche === "INSTANT");
+ const succeeded = instant.filter((record) => record.status === "SUCCEEDED");
+ // Negative durations mean bad clock data upstream; exclude them rather
+ // than silently dragging the median down.
+ const paid = succeeded.filter(
+ (record): record is PayoutRecord & { paidAt: Date } => record.paidAt !== null,
+ );
+ const minutes = paid
+ .map((record) => (record.paidAt.getTime() - record.announcedAt.getTime()) / 60_000)
+ .filter((value) => value >= 0);
+ return {
+ instantTotal: instant.length,
+ instantSucceeded: succeeded.length,
+ medianInstantMinutes: median(minutes),
+ };
+}
+
+export interface MediaRecord {
+ eventId: string;
+ uploadedAt: Date;
+ /** The event's mediaDeadlineAt (endsAt + 48h); null means no deadline was set. */
+ mediaDeadlineAt: Date | null;
+}
+
+export interface MediaTimeliness {
+ /** Events with a media deadline and at least one visible upload. */
+ eventsWithMedia: number;
+ /** Of those, events whose first upload beat the 48-hour deadline. */
+ onTime: number;
+}
+
+export function summarizeMediaTimeliness(records: MediaRecord[]): MediaTimeliness {
+ const firstUploadByEvent = new Map();
+ for (const record of records) {
+ const current = firstUploadByEvent.get(record.eventId);
+ if (!current || record.uploadedAt.getTime() < current.uploadedAt.getTime()) {
+ firstUploadByEvent.set(record.eventId, {
+ uploadedAt: record.uploadedAt,
+ mediaDeadlineAt: record.mediaDeadlineAt,
+ });
+ }
+ }
+ let eventsWithMedia = 0;
+ let onTime = 0;
+ for (const first of firstUploadByEvent.values()) {
+ if (!first.mediaDeadlineAt) continue;
+ eventsWithMedia += 1;
+ if (first.uploadedAt.getTime() <= first.mediaDeadlineAt.getTime()) onTime += 1;
+ }
+ return { eventsWithMedia, onTime };
+}
diff --git a/app/(marketing)/trust/ledger.ts b/app/(marketing)/trust/ledger.ts
new file mode 100644
index 0000000..067391f
--- /dev/null
+++ b/app/(marketing)/trust/ledger.ts
@@ -0,0 +1,45 @@
+import type { LedgerEntryType, Prisma } from "@prisma/client";
+
+/**
+ * Pure display logic for the /trust public ledger page, colocated with the
+ * route (Next.js page modules may only export route entrypoints, so testable
+ * logic lives here — see tests/unit/public-trust-pages.test.ts).
+ */
+
+/** Human labels for the on-chain attestation types (plan §11.3). */
+export const LEDGER_TYPE_LABELS: Record = {
+ VAULT_CREATED: "Vault created",
+ DEPOSIT_LOCKED: "Prize pool locked",
+ INSTANT_PAYOUT: "Instant payout",
+ MILESTONE_PAYOUT: "Milestone payout",
+ VAULT_REFUNDED: "Vault refunded",
+};
+
+/**
+ * Ledger payloads are JSON written by the escrow/payout services: amountKes
+ * is present on every type today, but the page must survive older rows and
+ * future shapes — so extraction is defensive, never a cast.
+ */
+export function payloadAmountKes(payload: Prisma.JsonValue): number | null {
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
+ const value = (payload as Record).amountKes;
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ }
+ return null;
+}
+
+/** The most human-readable reference in a payload: transfer ref, payment ref, refund ref or contract. */
+export function payloadReference(payload: Prisma.JsonValue): string | null {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
+ const record = payload as Record;
+ for (const key of ["txRef", "paystackRef", "refundRef", "contractAddress"] as const) {
+ const value = record[key];
+ if (typeof value === "string" && value.length > 0) return value;
+ }
+ return null;
+}
+
+/** "0x1234…abcd" — enough to recognise a hash, short enough for a table row. */
+export function shortenHash(hash: string): string {
+ return hash.length > 12 ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : hash;
+}
diff --git a/app/(marketing)/trust/loading.tsx b/app/(marketing)/trust/loading.tsx
new file mode 100644
index 0000000..a586993
--- /dev/null
+++ b/app/(marketing)/trust/loading.tsx
@@ -0,0 +1,20 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/app/(marketing)/trust/page.tsx b/app/(marketing)/trust/page.tsx
new file mode 100644
index 0000000..5426bdb
--- /dev/null
+++ b/app/(marketing)/trust/page.tsx
@@ -0,0 +1,281 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ExternalLink, ScrollText, ShieldCheck } from "lucide-react";
+import type { Prisma, VaultChainState } from "@prisma/client";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardTitle } from "@/components/ui/card";
+import { EmptyState } from "@/components/ui/empty-state";
+import { prisma } from "@/lib/db";
+import { getEnv } from "@/lib/env";
+import { getChainPort } from "@/lib/ports/chain";
+import { pageOpenGraph } from "@/lib/seo/metadata";
+import { formatKes } from "@/lib/utils";
+
+import {
+ LEDGER_TYPE_LABELS,
+ payloadAmountKes,
+ payloadReference,
+ shortenHash,
+} from "./ledger";
+
+export const metadata: Metadata = {
+ title: "The Public Ledger: Every Prize Lock And Payout, Verifiable",
+ description:
+ "Every hackathon prize pool locked, paid out or refunded on HackVillage is recorded on a public ledger. Verify each entry yourself — radical transparency is the product.",
+ alternates: { canonical: "/trust" },
+ openGraph: pageOpenGraph("/trust"),
+};
+
+const VAULT_STATE_LABELS: Record = {
+ AWAITING: "Awaiting deposit",
+ LOCKED: "Locked in escrow",
+ HALF_RELEASED: "50% paid out",
+ SETTLED: "Fully settled",
+ REFUNDED: "Refunded",
+};
+
+const timestampFormat = new Intl.DateTimeFormat("en-KE", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ timeZone: "Africa/Nairobi",
+});
+
+interface PageProps {
+ searchParams: Promise<{ event?: string }>;
+}
+
+export default async function TrustPage({ searchParams }: PageProps) {
+ const { event: eventFilter } = await searchParams;
+ const chainMode = getChainPort().mode;
+ const polygonscanUrl = getEnv().POLYGONSCAN_URL;
+ const onChain = chainMode !== "simulation";
+
+ const eventWhere: Prisma.EventWhereInput | undefined = eventFilter
+ ? { slug: eventFilter }
+ : undefined;
+
+ const [entries, vaults, filterEvents] = await Promise.all([
+ prisma.ledgerEntry.findMany({
+ where: eventWhere ? { event: eventWhere } : undefined,
+ include: { event: { select: { title: true, slug: true } } },
+ orderBy: { mirroredAt: "desc" },
+ take: 100,
+ }),
+ prisma.vaultState.findMany({
+ where: eventWhere ? { event: eventWhere } : undefined,
+ include: { event: { select: { title: true, slug: true } } },
+ orderBy: { updatedAt: "desc" },
+ }),
+ // Filter chips always list every event that has a ledger, so switching
+ // away from the current filter is one click.
+ prisma.event.findMany({
+ where: { ledgerEntries: { some: {} } },
+ select: { slug: true, title: true },
+ orderBy: { startsAt: "desc" },
+ }),
+ ]);
+
+ return (
+
+
+
+ The Public Ledger
+
+
+ Every prize pool locked, payout sent and vault refunded is recorded here, entry by
+ entry. Trust on HackVillage is not a promise; it is a record you can check.
+
+
+
+ {!onChain ? (
+
+
+ Simulation Mode
+
+
+ The ledger is running in simulation mode while we complete go-live. Entries are
+ application-verified and recorded in order; on-chain anchoring is pending, so
+ transaction references are not yet verifiable on a block explorer.
+
+
+ ) : null}
+
+ {vaults.length > 0 ? (
+
+
+ Prize Vaults
+
+
+ {vaults.map((vault) => (
+
+
+
+
+ {vault.event.title}
+
+
+
+ {formatKes(vault.amountKes)}
+
+
+
+ {VAULT_STATE_LABELS[vault.chainState]}
+
+
+ {vault.contractAddress ? (
+
+ {onChain ? (
+
+ {shortenHash(vault.contractAddress)}
+
+
+ ) : (
+ shortenHash(vault.contractAddress)
+ )}
+
+ ) : null}
+
+
+ ))}
+
+
+ ) : null}
+
+ {filterEvents.length > 1 ? (
+
+
+ All Hackathons
+
+ {filterEvents.map((event) => (
+
+ {event.title}
+
+ ))}
+
+ ) : null}
+
+ {entries.length === 0 ? (
+
+ Browse Hackathons
+
+ }
+ />
+ ) : (
+
+
+ Ledger Entries
+
+
+
+
+
+
+ Time
+
+
+ Hackathon
+
+
+ Entry
+
+
+ Amount
+
+
+ {onChain ? "Transaction" : "Reference"}
+
+
+
+
+ {entries.map((entry) => {
+ const amount = payloadAmountKes(entry.payload);
+ const reference = payloadReference(entry.payload);
+ return (
+
+
+ {timestampFormat.format(entry.mirroredAt)}
+
+
+
+ {entry.event.title}
+
+
+ {LEDGER_TYPE_LABELS[entry.type]}
+
+ {amount === null ? "—" : formatKes(amount)}
+
+
+ {onChain ? (
+
+ {shortenHash(entry.txHash)}
+
+ (opens on Polygonscan in a new tab)
+
+ ) : (
+
+ {shortenHash(reference ?? entry.txHash)}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ Showing the {entries.length} most recent entries
+ {entries.length === 100 ? " (use the hackathon filter to narrow down)" : ""}.{" "}
+ {onChain
+ ? "Each transaction hash resolves on Polygonscan, the independent block explorer for the Polygon network."
+ : "References are synthetic while the ledger runs in simulation mode."}
+
+
+ )}
+
+ );
+}
diff --git a/app/admin/hackathons/page.tsx b/app/admin/hackathons/page.tsx
index f447a8b..1903e92 100644
--- a/app/admin/hackathons/page.tsx
+++ b/app/admin/hackathons/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
+import { AdminEventCancelForm } from "@/components/admin/event-cancel-form";
import { Badge } from "@/components/ui/badge";
import { Card, CardTitle } from "@/components/ui/card";
import { prisma } from "@/lib/db";
@@ -51,6 +52,11 @@ export default async function AdminEventsPage() {
>
{STATUS_LABELS[event.status]}
+ {["DRAFT", "PENDING_DEPOSIT", "LIVE", "IN_PROGRESS", "JUDGING"].includes(
+ event.status
+ ) ? (
+
+ ) : null}
))}
diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx
new file mode 100644
index 0000000..ae8e096
--- /dev/null
+++ b/app/admin/loading.tsx
@@ -0,0 +1,20 @@
+import { Skeleton, SkeletonCard } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/admin/payments/page.tsx b/app/admin/payments/page.tsx
index 6b757b5..5fa6979 100644
--- a/app/admin/payments/page.tsx
+++ b/app/admin/payments/page.tsx
@@ -83,7 +83,7 @@ export default async function AdminPaymentsPage() {
-
+
))}
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx
index 60f483a..57d2ef3 100644
--- a/app/admin/users/page.tsx
+++ b/app/admin/users/page.tsx
@@ -59,26 +59,39 @@ export default async function AdminUsersPage({
Results ({users.length})
-
- {users.map((user) => (
-
-
- {user.name ?? `@${user.handle}`} {" "}
- @{user.handle}
- {user.email}
-
-
- {user.deletedAt ? deleted : null}
-
- {user.emailVerified ? "verified" : "unverified"}
-
- {user.roleGrants.map((grant) => (
- {grant.role.toLowerCase()}
- ))}
-
-
- ))}
-
+ {users.length === 0 ? (
+
+ {q ? (
+ <>
+ No users match “{q}” . Try a
+ different name, handle, or email.
+ >
+ ) : (
+ "No users yet."
+ )}
+
+ ) : (
+
+ {users.map((user) => (
+
+
+ {user.name ?? `@${user.handle}`} {" "}
+ @{user.handle}
+ {user.email}
+
+
+ {user.deletedAt ? deleted : null}
+
+ {user.emailVerified ? "verified" : "unverified"}
+
+ {user.roleGrants.map((grant) => (
+ {grant.role.toLowerCase()}
+ ))}
+
+
+ ))}
+
+ )}
);
diff --git a/app/api/dev/covers/upload/route.ts b/app/api/dev/covers/upload/route.ts
index 4f4e1e6..29599a1 100644
--- a/app/api/dev/covers/upload/route.ts
+++ b/app/api/dev/covers/upload/route.ts
@@ -1,4 +1,4 @@
-import { requireUser } from "@/lib/auth/guards";
+import { currentUser } from "@/lib/auth/guards";
import { authorizeCoverEdit, CoverError } from "@/lib/events/cover-service";
import { isCoverKeyFor, validateCoverUpload } from "@/lib/events/cover-upload";
@@ -6,16 +6,18 @@ export const dynamic = "force-dynamic";
/**
* DEV MODE cover upload (storage port in local mode): receives the processed
- * cover and saves it under public/uploads. 404 whenever R2 is configured, so
- * there is no dev path in production.
+ * cover and saves it under public/uploads. 404 in production and whenever R2
+ * is configured, so there is no dev path in production.
*/
export async function POST(request: Request) {
+ if (process.env.NODE_ENV === "production") {
+ return new Response("Not Found", { status: 404 });
+ }
const storage = (await import("@/lib/ports/storage")).getStoragePort();
if (storage.mode !== "local") {
return new Response("Not Found", { status: 404 });
}
- const user = await requireUser();
const key = new URL(request.url).searchParams.get("key") ?? "";
const eventId = key.split("/")[1] ?? "";
if (!isCoverKeyFor(eventId, key)) {
@@ -27,8 +29,13 @@ export async function POST(request: Request) {
const problem = validateCoverUpload(contentType, body.length);
if (problem) return Response.json({ error: problem }, { status: 400 });
+ const user = await currentUser();
+ if (!user) return Response.json({ error: "Sign in first." }, { status: 401 });
+
try {
+ // Only organization owners/admins of the target event may write its cover.
await authorizeCoverEdit(user.id, eventId);
+ // saveLocalAt joins under public/uploads and refuses escaping keys.
const url = await storage.saveLocalAt(key, body);
return Response.json({ ok: true, url });
} catch (error) {
diff --git a/app/api/dev/media/upload/[key]/route.ts b/app/api/dev/media/upload/[key]/route.ts
index 420dc2f..4e4942d 100644
--- a/app/api/dev/media/upload/[key]/route.ts
+++ b/app/api/dev/media/upload/[key]/route.ts
@@ -1,47 +1,57 @@
-import { registerMediaAsset } from "@/services/media/service";
-import { requireUser } from "@/lib/auth/guards";
+import { currentUser } from "@/lib/auth/guards";
+import { validateMediaUpload } from "@/lib/ports/storage";
+import { MediaError, requireOrgAdmin } from "@/services/media/service";
export const dynamic = "force-dynamic";
+/** keys are issued as events//- — anything else is rejected. */
+const MEDIA_KEY_PATTERN = /^events\/([a-z0-9]+)\/[a-zA-Z0-9._-]+$/;
+
/**
- * DEV MODE media upload (storage port in local mode): receives the file
- * directly and registers the asset in one step. 404 whenever R2 is
- * configured (P4 — no dev paths in production).
+ * DEV MODE media upload (storage port in local mode): receives the file and
+ * saves it under public/uploads. The MediaAsset row is NOT created here —
+ * the client confirms via confirmMediaUploadAction, identical to the R2
+ * flow. 404 in production and whenever R2 is configured (P4 — no dev paths
+ * in production).
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
+ if (process.env.NODE_ENV === "production") {
+ return new Response("Not Found", { status: 404 });
+ }
const storage = (await import("@/lib/ports/storage")).getStoragePort();
if (storage.mode !== "local") {
return new Response("Not Found", { status: 404 });
}
+ // Key shape first — traversal attempts get a cheap 400 before any auth or IO.
const { key } = await params;
- const user = await requireUser();
+ const match = MEDIA_KEY_PATTERN.exec(key);
+ if (!match || key.includes("..")) {
+ return Response.json({ error: "Malformed key." }, { status: 400 });
+ }
+ const eventId = match[1];
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
const body = Buffer.from(await request.arrayBuffer());
- if (body.length === 0) {
- return Response.json({ error: "Empty upload." }, { status: 400 });
- }
+ const problem = validateMediaUpload(contentType, body.length);
+ if (problem) return Response.json({ error: problem }, { status: 400 });
- // The key embeds the event: events//
- const eventId = key.split("/")[1];
- if (!eventId) {
- return Response.json({ error: "Malformed key." }, { status: 400 });
- }
+ const user = await currentUser();
+ if (!user) return Response.json({ error: "Sign in first." }, { status: 401 });
try {
- await storage.saveLocal(key, body);
- await registerMediaAsset({
- eventId,
- userId: user.id,
- key,
- kind: contentType.startsWith("video/") ? "VIDEO" : "PHOTO",
- });
- return Response.json({ ok: true, url: storage.publicUrlFor(key) });
+ // Only organization owners/admins of the target event may write its files.
+ await requireOrgAdmin(eventId, user.id);
+ // saveLocalAt joins under public/uploads and refuses escaping keys.
+ const url = await storage.saveLocalAt(key, body);
+ return Response.json({ ok: true, url });
} catch (error) {
+ if (error instanceof MediaError) {
+ return Response.json({ error: error.message }, { status: 403 });
+ }
console.error("[media:dev] upload failed", error);
return Response.json({ error: "Upload failed." }, { status: 500 });
}
diff --git a/app/api/events/[slug]/cancel/route.ts b/app/api/events/[slug]/cancel/route.ts
index 7db27b4..c23ea4e 100644
--- a/app/api/events/[slug]/cancel/route.ts
+++ b/app/api/events/[slug]/cancel/route.ts
@@ -1,12 +1,29 @@
import { NextResponse } from "next/server";
+import { currentUser } from "@/lib/auth/guards";
import { cancelRegistrationAction } from "@/lib/events/registration-actions";
+import { rateLimit } from "@/lib/rate-limit";
+/**
+ * Authenticated callers are throttled (30 mutations/hour shared across the
+ * mutation routes); unauthenticated requests fall through to the action,
+ * which keeps its existing redirect-to-sign-in behavior.
+ */
export async function POST(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
+ const user = await currentUser();
+ if (user) {
+ const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000);
+ if (!limit.ok) {
+ return NextResponse.json(
+ { error: "Too many attempts. Try again later." },
+ { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } }
+ );
+ }
+ }
await cancelRegistrationAction(slug);
return NextResponse.redirect(new URL("/dashboard/hackathons", _request.url), 303);
}
diff --git a/app/api/events/[slug]/register/route.ts b/app/api/events/[slug]/register/route.ts
index 4d5ccb7..0557a6b 100644
--- a/app/api/events/[slug]/register/route.ts
+++ b/app/api/events/[slug]/register/route.ts
@@ -1,17 +1,33 @@
import { NextResponse } from "next/server";
+import { currentUser } from "@/lib/auth/guards";
import { registerForEventAction } from "@/lib/events/registration-actions";
+import { rateLimit } from "@/lib/rate-limit";
/**
* Plain-form-friendly registration endpoint (P6: no client JS required).
* The server action owns the logic; this handler only exists because HTML
* forms cannot invoke server actions with dynamic arguments.
+ *
+ * Authenticated callers are throttled (30 mutations/hour shared across the
+ * mutation routes); unauthenticated requests fall through to the action,
+ * which keeps its existing redirect-to-sign-in behavior.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
+ const user = await currentUser();
+ if (user) {
+ const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000);
+ if (!limit.ok) {
+ return NextResponse.json(
+ { error: "Too many attempts. Try again later." },
+ { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } }
+ );
+ }
+ }
await registerForEventAction(slug);
return NextResponse.redirect(new URL(`/hackathons/${slug}/workspace`, _request.url), 303);
}
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
new file mode 100644
index 0000000..9dd983a
--- /dev/null
+++ b/app/api/health/route.ts
@@ -0,0 +1,70 @@
+import { NextResponse } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { getChainPort } from "@/lib/ports/chain";
+import { getPaystackPort } from "@/lib/ports/paystack";
+
+/**
+ * Liveness/readiness probe (no auth — uptime monitors and load balancers
+ * poll it). Reports component status only: no versions, hosts, or secret
+ * presence beyond the already-public port modes. 200 when the app can serve
+ * real traffic (database reachable, queue schema present), 503 otherwise.
+ */
+export const dynamic = "force-dynamic";
+
+interface ComponentHealth {
+ ok: boolean;
+ db: "ok" | "down";
+ queue: "ok" | "down";
+ paystackMode: string;
+ chainMode: string;
+ uptime: number;
+}
+
+async function databaseOk(): Promise {
+ try {
+ await prisma.$queryRaw`SELECT 1`;
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** pg-boss creates its schema on first start; without it no job will run. */
+async function queueOk(): Promise {
+ try {
+ // ::text — Prisma cannot deserialize the regclass type from to_regclass.
+ const rows = await prisma.$queryRaw>`
+ SELECT to_regclass('pgboss.job')::text AS name
+ `;
+ return rows[0]?.name != null;
+ } catch {
+ return false;
+ }
+}
+
+export async function GET(): Promise> {
+ const [db, queue] = await Promise.all([databaseOk(), queueOk()]);
+
+ // Port getters never reach the network (simulation fallbacks are in-process);
+ // if env validation itself throws, the whole app is down — report honestly.
+ let paystackMode = "unknown";
+ let chainMode = "unknown";
+ try {
+ paystackMode = getPaystackPort().mode;
+ chainMode = getChainPort().mode;
+ } catch {
+ // Modes are informational; db/queue decide the status code.
+ }
+
+ const ok = db && queue;
+ const body: ComponentHealth = {
+ ok,
+ db: db ? "ok" : "down",
+ queue: queue ? "ok" : "down",
+ paystackMode,
+ chainMode,
+ uptime: Math.floor(process.uptime()),
+ };
+ return NextResponse.json(body, { status: ok ? 200 : 503 });
+}
diff --git a/app/api/judge/invites/[assignmentId]/route.ts b/app/api/judge/invites/[assignmentId]/route.ts
index ca8020c..5b79973 100644
--- a/app/api/judge/invites/[assignmentId]/route.ts
+++ b/app/api/judge/invites/[assignmentId]/route.ts
@@ -1,8 +1,15 @@
import { NextResponse } from "next/server";
+import { currentUser } from "@/lib/auth/guards";
+import { rateLimit } from "@/lib/rate-limit";
import { respondToJudgeInviteAction } from "@/services/judging/actions";
-/** Plain-form judge invite responses (no client JS required — P6). */
+/**
+ * Plain-form judge invite responses (no client JS required — P6).
+ * Authenticated callers are throttled (30 mutations/hour shared across the
+ * mutation routes); unauthenticated requests fall through to the action,
+ * which keeps its existing redirect-to-sign-in behavior.
+ */
export async function POST(
request: Request,
{ params }: { params: Promise<{ assignmentId: string }> }
@@ -13,6 +20,16 @@ export async function POST(
if (decision !== "accept" && decision !== "decline") {
return NextResponse.redirect(new URL("/judge", request.url), 303);
}
+ const user = await currentUser();
+ if (user) {
+ const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000);
+ if (!limit.ok) {
+ return NextResponse.json(
+ { error: "Too many attempts. Try again later." },
+ { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } }
+ );
+ }
+ }
await respondToJudgeInviteAction(assignmentId, decision === "accept");
return NextResponse.redirect(new URL("/judge", request.url), 303);
}
diff --git a/app/error.tsx b/app/error.tsx
index 567a311..1d2a21f 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -13,7 +13,13 @@ export default function Error({
reset: () => void;
}) {
useEffect(() => {
- // Sentry wiring lands in Phase 9 (plan §16); console for now.
+ // Report to Sentry only when a public DSN is configured; the SDK is
+ // loaded lazily so error reporting never blocks the error UI itself.
+ if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ void import("@sentry/nextjs")
+ .then((Sentry) => Sentry.captureException(error))
+ .catch(() => undefined);
+ }
console.error("[app-error]", error);
}, [error]);
diff --git a/app/global-error.tsx b/app/global-error.tsx
index 212c3f9..972a471 100644
--- a/app/global-error.tsx
+++ b/app/global-error.tsx
@@ -15,6 +15,13 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
+ // Report to Sentry only when a public DSN is configured; the SDK is
+ // loaded lazily so error reporting never blocks the error UI itself.
+ if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ void import("@sentry/nextjs")
+ .then((Sentry) => Sentry.captureException(error))
+ .catch(() => undefined);
+ }
console.error("[global-error]", error);
}, [error]);
diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts
index 737a081..0d74261 100644
--- a/app/llms.txt/route.ts
+++ b/app/llms.txt/route.ts
@@ -75,9 +75,11 @@ HackVillage ("Hack Village") is open-source infrastructure for high-impact hacka
- [How it works](${appUrl("/how-it-works")}): the full lifecycle: escrowed prize, team formation, structured judging, instant payouts.
- [How it works for organizers](${appUrl("/for-organizers")}): hosting a hackathon step by step: setup, verification, the 5% fee, funding the prize vault, judging, payouts, milestones and the trust score.
- [How escrow works](${appUrl("/how-escrow-works")}): exactly how prize money is deposited, locked, held and paid, step by step.
+- [Public ledger](${appUrl("/trust")}): every vault lock, payout and refund recorded entry by entry, with the per-hackathon vault state.
- [Blog](${appUrl("/blog")}): guides and stories on escrow, payouts, judging and building hackathons builders trust.
- [Contribute](${appUrl("/contribute")}): how to contribute to the open-source platform.
- Developer profiles: every builder who takes part has a public Proof-of-Work profile at ${appUrl("/developers/")}.
+- Organizer trust pages: every organizer has a public record at ${appUrl("/organizers/")}: verification status, trust score, payout performance and hosted hackathons.
## Current hackathons
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 0424adc..1b2ab57 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -34,6 +34,8 @@ export default async function sitemap(): Promise {
images: imagesIn("hero/kenya", "journey", "trust"),
},
{ url: appUrl("/hackathons"), changeFrequency: "daily", priority: 0.9 },
+ // The public ledger is the trust pillar — crawlers should find it easily.
+ { url: appUrl("/trust"), changeFrequency: "hourly", priority: 0.8 },
{
url: appUrl("/how-it-works"),
changeFrequency: "weekly",
@@ -72,7 +74,7 @@ export default async function sitemap(): Promise {
// unreachable during a build, static and blog entries still ship and the
// rest return at the next regeneration.
try {
- const [events, developers] = await Promise.all([
+ const [events, developers, organizers] = await Promise.all([
prisma.event.findMany({
// Demo hackathons are noindex, so they don't belong in the sitemap.
where: { ...PUBLIC_HACKATHON_WHERE, isDemo: false },
@@ -83,6 +85,12 @@ export default async function sitemap(): Promise {
where: INDEXABLE_DEVELOPER_WHERE,
select: { handle: true, email: true, updatedAt: true },
}),
+ // Public organizer trust pages: only organizations with at least one
+ // published hackathon earn a sitemap entry, matching page prominence.
+ prisma.organization.findMany({
+ where: { events: { some: { publishedAt: { not: null } } } },
+ select: { slug: true, updatedAt: true },
+ }),
]);
for (const event of events) {
entries.push({
@@ -106,6 +114,14 @@ export default async function sitemap(): Promise {
priority: 0.5,
});
}
+ for (const organizer of organizers) {
+ entries.push({
+ url: appUrl(`/organizers/${organizer.slug}`),
+ lastModified: organizer.updatedAt,
+ changeFrequency: "weekly",
+ priority: 0.6,
+ });
+ }
} catch (error) {
// Keep the sitemap useful with what we have, but say why it's short.
console.error("[sitemap] database sections skipped", error);
diff --git a/components/admin/event-cancel-form.tsx b/components/admin/event-cancel-form.tsx
new file mode 100644
index 0000000..7d1ea89
--- /dev/null
+++ b/components/admin/event-cancel-form.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { Ban } from "lucide-react";
+
+import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action";
+import { Input, Label } from "@/components/ui/input";
+import { adminCancelEventAction } from "@/lib/admin/event-actions";
+
+/**
+ * Admin cancels an event with a reason (the organizer sees it). Calls Agent
+ * C's contract `adminCancelEventAction(eventId, reason)` from
+ * lib/admin/event-actions.ts.
+ */
+export function AdminEventCancelForm({
+ eventId,
+ eventTitle,
+}: {
+ eventId: string;
+ eventTitle: string;
+}) {
+ return (
+
+ Cancel
+ >
+ }
+ triggerVariant="ghost"
+ triggerSize="sm"
+ onConfirm={async (formData) => {
+ const reason = String(formData.get("reason") ?? "").trim();
+ if (reason.length < 4) {
+ return { error: "Give a reason (at least 4 characters) — the organizer sees it." };
+ }
+ return adminCancelEventAction(eventId, reason);
+ }}
+ >
+
+ Reason (sent to the organizer)
+
+
+
+ );
+}
diff --git a/components/admin/payout-ops-actions.tsx b/components/admin/payout-ops-actions.tsx
index 289425e..734108d 100644
--- a/components/admin/payout-ops-actions.tsx
+++ b/components/admin/payout-ops-actions.tsx
@@ -1,23 +1,22 @@
"use client";
-import { useActionState, useState, useTransition } from "react";
+import { useState, useTransition } from "react";
import { RotateCcw } from "lucide-react";
+import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action";
import { Button } from "@/components/ui/button";
-import { FormError, FormSuccess, Input } from "@/components/ui/input";
-import {
- adminMarkPaidAction,
- adminRetryPayoutAction,
- type PayoutActionState,
-} from "@/services/payout/actions";
+import { Input, Label } from "@/components/ui/input";
+import { adminMarkPaidAction, adminRetryPayoutAction } from "@/services/payout/actions";
-export function PayoutOpsActions({ payoutId }: { payoutId: string }) {
+export function PayoutOpsActions({
+ payoutId,
+ amountKes,
+}: {
+ payoutId: string;
+ amountKes: number;
+}) {
const [retry, startRetry] = useTransition();
const [retryError, setRetryError] = useState(null);
- const [markState, markPaid, marking] = useActionState(
- adminMarkPaidAction,
- {}
- );
return (
@@ -43,24 +42,36 @@ export function PayoutOpsActions({ payoutId }: { payoutId: string }) {
) : null}
-