Skip to content
Merged
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
443 changes: 404 additions & 39 deletions app/admin/hall-of-fame/page.tsx

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions app/api/admin/hall-of-fame/[id]/points/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* PATCH /api/admin/hall-of-fame/[id]/points - Manually adjust points for a hall of fame entry
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { getDb, getCfEnv } from '@/lib/db';
import { hallOfFame, researcherStats, auditLogs, hacktivity } from '@/lib/db/schema';
import { eq, sum } from 'drizzle-orm';
import { z } from 'zod';

const AdjustPointsSchema = z.object({
points: z.number().int().min(0).max(10000),
reason: z.string().trim().min(1).max(500),
});

export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireRole('ADMIN');
const { id } = await params;

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const parsed = AdjustPointsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten().fieldErrors },
{ status: 422 }
);
}

const { points, reason } = parsed.data;
const db = getDb(getCfEnv().DB);
const now = Date.now();

const entry = await db
.select()
.from(hallOfFame)
.where(eq(hallOfFame.id, id))
.get();

if (!entry) {
return NextResponse.json({ error: 'Hall of fame entry not found' }, { status: 404 });
}

const oldPoints = entry.pointsAwarded;

await db
.update(hallOfFame)
.set({ pointsAwarded: points })
.where(eq(hallOfFame.id, id));

await db
.update(hacktivity)
.set({ points })
.where(eq(hacktivity.reportId, entry.reportId));

// Recalculate researcher total from all their entries to avoid drift
const result = await db
.select({ total: sum(hallOfFame.pointsAwarded) })
.from(hallOfFame)
.where(eq(hallOfFame.researcherId, entry.researcherId))
.get();

const newTotal = Number(result?.total ?? 0);

await db
.update(researcherStats)
.set({ totalPoints: newTotal, updatedAt: now })
.where(eq(researcherStats.researcherId, entry.researcherId));

await db.insert(auditLogs).values({
id: crypto.randomUUID(),
reportId: entry.reportId,
entityType: 'report',
entityId: entry.reportId,
actorId: userId,
actorEmail: null,
action: 'points_manually_adjusted',
oldValue: String(oldPoints),
newValue: JSON.stringify({ points, reason }),
ipHash: null,
isInternal: 1,
timestamp: now,
});

return NextResponse.json({ success: true, points, researcherTotalPoints: newTotal });
} catch (error) {
if (error instanceof Response) return error;
console.error('[PATCH /api/admin/hall-of-fame/[id]/points] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
78 changes: 78 additions & 0 deletions app/api/admin/hall-of-fame/[id]/title/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* PATCH /api/admin/hall-of-fame/[id]/title - Override public-facing title for a hall of fame entry
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { getDb, getCfEnv } from '@/lib/db';
import { hallOfFame, auditLogs } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { z } from 'zod';

const UpdateTitleSchema = z.object({
publicTitle: z.string().max(500).nullable(),
});

export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireRole('ADMIN');
const { id } = await params;

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const parsed = UpdateTitleSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten().fieldErrors },
{ status: 422 }
);
}

const { publicTitle } = parsed.data;
const db = getDb(getCfEnv().DB);
const now = Date.now();

const entry = await db
.select()
.from(hallOfFame)
.where(eq(hallOfFame.id, id))
.get();

if (!entry) {
return NextResponse.json({ error: 'Hall of fame entry not found' }, { status: 404 });
}

await db
.update(hallOfFame)
.set({ publicTitle })
.where(eq(hallOfFame.id, id));

await db.insert(auditLogs).values({
id: crypto.randomUUID(),
reportId: entry.reportId,
entityType: 'report',
entityId: entry.reportId,
actorId: userId,
actorEmail: null,
action: 'hall_of_fame_title_updated',
oldValue: entry.publicTitle ?? entry.title,
newValue: publicTitle ?? entry.title,
ipHash: null,
isInternal: 1,
timestamp: now,
});

return NextResponse.json({ success: true, publicTitle });
} catch (error) {
if (error instanceof Response) return error;
console.error('[PATCH /api/admin/hall-of-fame/[id]/title] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
77 changes: 77 additions & 0 deletions app/api/admin/hall-of-fame/bulk-visibility/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* PATCH /api/admin/hall-of-fame/bulk-visibility - Set visibility for multiple entries at once
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth';
import { getDb, getCfEnv } from '@/lib/db';
import { hallOfFame, auditLogs } from '@/lib/db/schema';
import { inArray } from 'drizzle-orm';
import { z } from 'zod';

const BulkVisibilitySchema = z.object({
ids: z.array(z.string()).min(1).max(250),
isPublic: z.boolean(),
});

export async function PATCH(request: NextRequest) {
try {
const { userId } = await requireRole('ADMIN');

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const parsed = BulkVisibilitySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten().fieldErrors },
{ status: 422 }
);
}

const { ids, isPublic } = parsed.data;
const db = getDb(getCfEnv().DB);
const now = Date.now();

const existingEntries = await db
.select()
.from(hallOfFame)
.where(inArray(hallOfFame.id, ids))
.all();

if (existingEntries.length === 0) {
return NextResponse.json({ error: 'No entries found' }, { status: 404 });
}

await db
.update(hallOfFame)
.set({ isPublic: isPublic ? 1 : 0 })
.where(inArray(hallOfFame.id, ids));

for (const entry of existingEntries) {
await db.insert(auditLogs).values({
id: crypto.randomUUID(),
reportId: entry.reportId,
entityType: 'report',
entityId: entry.reportId,
actorId: userId,
actorEmail: null,
action: 'hall_of_fame_visibility_toggled',
oldValue: entry.isPublic ? 'public' : 'hidden',
newValue: isPublic ? 'public' : 'hidden',
ipHash: null,
isInternal: 0,
timestamp: now,
});
}

return NextResponse.json({ success: true, updated: existingEntries.length });
} catch (error) {
if (error instanceof Response) return error;
console.error('[PATCH /api/admin/hall-of-fame/bulk-visibility] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
1 change: 1 addition & 0 deletions app/api/admin/hall-of-fame/entries/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function GET(_request: NextRequest) {
researcherName,
avatarUrl,
title: entry.title,
publicTitle: entry.publicTitle ?? null,
severity: entry.severity,
pointsAwarded: entry.pointsAwarded,
acceptedAt: entry.acceptedAt,
Expand Down
17 changes: 11 additions & 6 deletions app/api/admin/hall-of-fame/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ import { desc } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import { getDisplayName } from '@/lib/redact';

export async function GET(_request: NextRequest) {
export async function GET(request: NextRequest) {
try {
await requireRole('ADMIN');

const db = getDb(getCfEnv().DB);

const leaders = await db
const limitParam = request.nextUrl.searchParams.get('limit');
const shouldExportAll = limitParam === 'all';
const limit = Math.min(Math.max(Number(limitParam) || 100, 1), 1000);

const query = db
.select()
.from(researcherStats)
.orderBy(desc(researcherStats.totalPoints))
.limit(100)
.all();
.orderBy(desc(researcherStats.totalPoints));

const leaders = shouldExportAll
? await query.all()
: await query.limit(limit).all();

// Fetch Clerk data for all researchers
const clerk = await clerkClient();
Expand Down
6 changes: 4 additions & 2 deletions app/api/hacktivity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ export async function GET(_request: NextRequest) {
researcherId: hacktivity.researcherId,
action: hacktivity.action,
title: hacktivity.title,
publicTitle: hallOfFame.publicTitle,
hallOfFameTitle: hallOfFame.title,
severity: hacktivity.severity,
points: hacktivity.points,
points: hallOfFame.pointsAwarded,
titleDisclosed: hacktivity.titleDisclosed,
timestamp: hacktivity.timestamp,
})
Expand Down Expand Up @@ -55,7 +57,7 @@ export async function GET(_request: NextRequest) {
// SECURITY: Only show title if explicitly disclosed by researcher
// titleDisclosed: 1 = visible, 0 = redacted
const displayTitle = activity.titleDisclosed === 1
? activity.title
? activity.publicTitle ?? activity.hallOfFameTitle
: '[Undisclosed]';

return {
Expand Down
Loading
Loading