From 50f84d1b686b3806efa546220d42a52a9f9d3bea Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sat, 2 May 2026 21:49:25 +0800 Subject: [PATCH 1/8] fix: switch HackAgent AI calls to ZenMux payg --- .env.example | 11 +++- app/api/ai/generate-event/route.ts | 5 +- app/api/csv-detect/route.ts | 5 +- .../events/[eventId]/generate-banner/route.ts | 63 +++++++++---------- app/api/teams/auto-match/route.ts | 7 ++- docs/deployment.md | 2 +- lib/ai.ts | 10 +-- lib/code-analysis.ts | 12 ++-- lib/zenmux.ts | 43 +++++++++++++ 9 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 lib/zenmux.ts diff --git a/.env.example b/.env.example index 79eb9ad..8b8504e 100644 --- a/.env.example +++ b/.env.example @@ -23,17 +23,24 @@ WORKER_SECRET=replace-with-a-long-random-string CRON_SECRET=replace-with-a-long-random-string # AI / model gateways, optional unless using AI-assisted review features +# ZenMux Pay As You Go uses the same OpenAI-compatible base URL with a production PAYG key. +ZENMUX_PAY2GO_API_URL=https://zenmux.ai/api/v1 +ZENMUX_PAY2GO_API_KEY= +ZENMUX_PAY2GO_VERTEX_API_URL=https://zenmux.ai/api/vertex-ai +ZENMUX_IMAGE_MODEL=openai/gpt-image-2 +ZENMUX_IMAGE_SIZE=1536x864 +ZENMUX_IMAGE_QUALITY=high +# Backward-compatible fallbacks ZENMUX_API_URL=https://zenmux.ai/api/v1 ZENMUX_API_KEY= COMMONSTACK_API_URL= COMMONSTACK_API_KEY= OPENROUTER_API_KEY= -JINA_API_KEY= +JINA_API_KEY=*** RAPIDAPI_KEY= WEB3INSIGHT_TOKEN= SONAR_PROXY_URL= SONAR_PROXY_SECRET= -ZENMUX_IMAGE_MODEL=google/gemini-3-pro-image-preview # GitHub integrations, optional GITHUB_TOKEN= diff --git a/app/api/ai/generate-event/route.ts b/app/api/ai/generate-event/route.ts index 7757111..78cf6a1 100644 --- a/app/api/ai/generate-event/route.ts +++ b/app/api/ai/generate-event/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' +import { getZenmuxApiKey, getZenmuxChatApiBase } from '@/lib/zenmux' interface GeneratedEvent { name: string @@ -19,8 +20,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'prompt is required' }, { status: 400 }) } - const apiUrl = process.env.ZENMUX_API_URL || process.env.COMMONSTACK_API_URL || 'https://zenmux.ai/api/v1' - const apiKey = process.env.ZENMUX_API_KEY || process.env.COMMONSTACK_API_KEY + const apiUrl = getZenmuxChatApiBase() + const apiKey = getZenmuxApiKey() if (!apiKey) return NextResponse.json({ error: 'AI service not configured' }, { status: 500 }) const systemPrompt = `你是一个 Hackathon 组织顾问。根据用户的描述,生成一份结构化的活动方案。 diff --git a/app/api/csv-detect/route.ts b/app/api/csv-detect/route.ts index 902de1d..e7164f2 100644 --- a/app/api/csv-detect/route.ts +++ b/app/api/csv-detect/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' +import { getZenmuxApiKey, getZenmuxChatApiBase } from '@/lib/zenmux' -const API_BASE = process.env.ZENMUX_API_URL || 'https://zenmux.ai/api/v1' -const API_KEY = process.env.ZENMUX_API_KEY || process.env.COMMONSTACK_API_KEY +const API_BASE = getZenmuxChatApiBase() +const API_KEY = getZenmuxApiKey() const MODEL = 'openai/gpt-4.1-nano' export interface ColumnMapping { diff --git a/app/api/events/[eventId]/generate-banner/route.ts b/app/api/events/[eventId]/generate-banner/route.ts index 9fb2035..7c3feb3 100644 --- a/app/api/events/[eventId]/generate-banner/route.ts +++ b/app/api/events/[eventId]/generate-banner/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getSessionUserWithRole } from '@/lib/session' +import { getZenmuxApiKey, getZenmuxVertexApiBase } from '@/lib/zenmux' const MAX_GENERATIONS_PER_EVENT = 3 -const MODEL = process.env.ZENMUX_IMAGE_MODEL || 'google/gemini-3-pro-image-preview' +const MODEL = process.env.ZENMUX_IMAGE_MODEL || 'openai/gpt-image-2' +const IMAGE_SIZE = process.env.ZENMUX_IMAGE_SIZE || '1536x864' +const IMAGE_QUALITY = process.env.ZENMUX_IMAGE_QUALITY || 'high' export async function POST( req: NextRequest, @@ -48,24 +51,24 @@ export async function POST( eventDesc ? `Event context: ${eventDesc}` : '', userPrompt ? `Creative direction from organizer: ${userPrompt}` : '', 'Style: modern tech aesthetic, vibrant gradients, abstract geometric shapes, clean typography space on the left, no text rendered in the image.', - 'High quality, professional, 1792x1024 pixels.', + `High quality, professional, ${IMAGE_SIZE} pixels.`, ] .filter(Boolean) .join('\n') - const rawApiUrl = process.env.ZENMUX_API_URL || process.env.COMMONSTACK_API_URL || 'https://zenmux.ai/api' - const apiKey = process.env.ZENMUX_API_KEY || process.env.COMMONSTACK_API_KEY + const apiKey = getZenmuxApiKey() if (!apiKey) { return NextResponse.json({ error: 'AI not configured' }, { status: 500 }) } - // Zenmux Vertex AI proxy: generateContent with IMAGE response modality. - // NOTE: Zenmux's OpenAI-compatible /chat/completions endpoint does NOT - // support image output for any listed model (verified via /v1/models — - // zero entries have output_modalities:['image']). The Vertex proxy is the - // only working path for gemini-3-pro-image-preview. - const vertexBase = rawApiUrl.replace(/\/v1\/?$/, '').replace(/\/+$/, '') - const url = `${vertexBase}/vertex-ai/v1/models/${MODEL}:generateContent` + // GPT Image 2 is exposed by ZenMux through the Vertex-compatible predict API. + // Direct handle verified before implementation: openai/gpt-image-2. + const vertexBase = getZenmuxVertexApiBase() + const [publisher, modelName] = MODEL.split('/') + if (!publisher || !modelName) { + return NextResponse.json({ error: 'invalid_image_model', message: `Invalid image model: ${MODEL}` }, { status: 500 }) + } + const url = `${vertexBase}/v1/publishers/${publisher}/models/${modelName}:predict` const res = await fetch(url, { method: 'POST', @@ -74,14 +77,19 @@ export async function POST( 'Content-Type': 'application/json', }, body: JSON.stringify({ - contents: [{ role: 'user', parts: [{ text: promptText }] }], - generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }, + instances: [{ prompt: promptText }], + parameters: { + sampleCount: 1, + outputOptions: { mimeType: 'image/png' }, + }, + imageSize: IMAGE_SIZE, + quality: IMAGE_QUALITY, }), }) if (!res.ok) { const errText = await res.text() - console.error('[generate-banner] zenmux error', res.status, errText.slice(0, 500)) + console.error('[generate-banner] zenmux image error', res.status, errText.slice(0, 500)) return NextResponse.json( { error: 'image_generation_failed', message: `AI 生成失败 (${res.status})`, detail: errText.slice(0, 200) }, { status: 502 } @@ -89,26 +97,15 @@ export async function POST( } const json = await res.json() - // Extract first inline image from Vertex candidates[0].content.parts[].inlineData - type Part = { - text?: string - thought?: boolean - inlineData?: { mimeType?: string; data?: string } - inline_data?: { mime_type?: string; data?: string } - } - const parts: Part[] = json?.candidates?.[0]?.content?.parts ?? [] - let b64: string | undefined - let mime = 'image/png' - for (const p of parts) { - const inline = (p.inlineData || p.inline_data) as - | { mimeType?: string; mime_type?: string; data?: string } - | undefined - if (inline?.data) { - b64 = inline.data - mime = inline.mimeType || inline.mime_type || mime - break - } + type Prediction = { + bytesBase64Encoded?: string + mimeType?: string + image?: { imageBytes?: string; mimeType?: string } } + const predictions: Prediction[] = json?.predictions ?? json?.generatedImages ?? [] + const first = predictions[0] + const b64 = first?.bytesBase64Encoded || first?.image?.imageBytes + const mime = first?.mimeType || first?.image?.mimeType || 'image/png' if (!b64) { console.error('[generate-banner] no image in response', JSON.stringify(json).slice(0, 500)) diff --git a/app/api/teams/auto-match/route.ts b/app/api/teams/auto-match/route.ts index 37fb05f..734dd0c 100644 --- a/app/api/teams/auto-match/route.ts +++ b/app/api/teams/auto-match/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase' +import { getZenmuxApiKey, getZenmuxChatApiBase } from '@/lib/zenmux' -const AI_API_BASE = process.env.ZENMUX_API_URL || 'https://zenmux.ai/api/v1' +const AI_API_BASE = getZenmuxChatApiBase() const AI_API_URL = `${AI_API_BASE}/chat/completions` -const AI_API_KEY = process.env.ZENMUX_API_KEY || process.env.COMMONSTACK_API_KEY +const AI_API_KEY = getZenmuxApiKey() const AI_MODEL = 'z-ai/glm-4.5-air' type Participant = { @@ -38,7 +39,7 @@ export async function POST(req: NextRequest) { const supabase = createServiceClient() if (!AI_API_KEY) { - return NextResponse.json({ error: 'AI service not configured (ZENMUX_API_KEY missing)' }, { status: 503 }) + return NextResponse.json({ error: 'AI service not configured (ZENMUX_PAY2GO_API_KEY missing)' }, { status: 503 }) } // Verify event exists and user is owner diff --git a/docs/deployment.md b/docs/deployment.md index 7a23a20..270bf3e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -31,7 +31,7 @@ npx vercel --prod Depending on enabled features, also configure: -- `COMMONSTACK_API_KEY`, `OPENROUTER_API_KEY`, or `ZENMUX_API_KEY` +- `ZENMUX_PAY2GO_API_KEY` (preferred for production), or legacy fallback `ZENMUX_API_KEY` / `COMMONSTACK_API_KEY` - `MAILGUN_DOMAIN`, `MAILGUN_API_KEY`, `MAIL_FROM` - `CRON_SECRET` - `INTERNAL_API_URL`, `INTERNAL_API_SECRET` diff --git a/lib/ai.ts b/lib/ai.ts index 30b1f39..c031c54 100644 --- a/lib/ai.ts +++ b/lib/ai.ts @@ -1,3 +1,5 @@ +import { getTemperatureForModel, getZenmuxApiKey, getZenmuxChatApiBase } from './zenmux' + console.log('[ai.ts] module loaded OK') const MODEL_MAP: Record = { @@ -102,10 +104,10 @@ export async function scoreProject( codeAnalysis?: { is_real_code?: boolean; business_match_score?: number; code_quality_summary?: string } | null ): Promise { console.log('[scoreProject] start', modelKey, project.name) - const apiUrl = process.env.ZENMUX_API_URL || process.env.COMMONSTACK_API_URL || 'https://zenmux.ai/api/v1' - const apiKey = process.env.ZENMUX_API_KEY || process.env.COMMONSTACK_API_KEY + const apiUrl = getZenmuxChatApiBase() + const apiKey = getZenmuxApiKey() - if (!apiKey) throw new Error('ZENMUX_API_KEY not set') + if (!apiKey) throw new Error('ZENMUX_PAY2GO_API_KEY not set') const modelId = MODEL_MAP[modelKey] if (!modelId) throw new Error(`Unknown model: ${modelKey}`) @@ -192,7 +194,7 @@ ${sonarPrompt}${web3Prompt} body: JSON.stringify({ model: modelId, messages: [{ role: 'user', content: prompt }], - temperature: 0.3, + temperature: getTemperatureForModel(modelId, 0.3), max_tokens: 8000, }), }) diff --git a/lib/code-analysis.ts b/lib/code-analysis.ts index a246405..0326e5a 100644 --- a/lib/code-analysis.ts +++ b/lib/code-analysis.ts @@ -1,3 +1,5 @@ +import { getZenmuxApiKey, getZenmuxChatApiBase } from './zenmux' + /** * Code analysis via GitHub API + LLM. * Mirrors hackathon-analyzer's repomix+LLM approach, but uses GitHub API @@ -9,8 +11,8 @@ const GITHUB_API = 'https://api.github.com' function getEnv() { return { GITHUB_TOKEN: process.env.GITHUB_TOKEN || '', - COMMONSTACK_API_URL: process.env.ZENMUX_API_URL || process.env.COMMONSTACK_API_URL || 'https://zenmux.ai/api/v1', - COMMONSTACK_API_KEY: process.env.ZENMUX_API_KEY || process.env.COMMONSTACK_API_KEY || '', + ZENMUX_API_URL: getZenmuxChatApiBase(), + ZENMUX_API_KEY: getZenmuxApiKey(), } } @@ -111,7 +113,7 @@ export async function analyzeCodeWithLLM( code_quality_summary: '', } - if (!getEnv().COMMONSTACK_API_KEY) return { ...defaultResult, llm_error: 'no api key' } + if (!getEnv().ZENMUX_API_KEY) return { ...defaultResult, llm_error: 'no api key' } const filesToFetch = pickCodeFiles(tree) if (filesToFetch.length === 0) { @@ -149,11 +151,11 @@ ${codeBundle} // Retry up to 2 times, mirroring hackathon-analyzer's error handling for (let attempt = 0; attempt < 2; attempt++) { try { - const res = await fetch(`${getEnv().COMMONSTACK_API_URL}/chat/completions`, { + const res = await fetch(`${getEnv().ZENMUX_API_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${getEnv().COMMONSTACK_API_KEY}`, + Authorization: `Bearer ${getEnv().ZENMUX_API_KEY}`, }, body: JSON.stringify({ model: 'minimax/minimax-m2.5', diff --git a/lib/zenmux.ts b/lib/zenmux.ts new file mode 100644 index 0000000..73bdb61 --- /dev/null +++ b/lib/zenmux.ts @@ -0,0 +1,43 @@ +const DEFAULT_CHAT_API_BASE = 'https://zenmux.ai/api/v1' +const DEFAULT_VERTEX_API_BASE = 'https://zenmux.ai/api/vertex-ai' + +export function getZenmuxApiKey(): string { + return ( + process.env.ZENMUX_PAY2GO_API_KEY || + process.env.ZENMUX_API_KEY || + process.env.COMMONSTACK_API_KEY || + '' + ) +} + +export function getZenmuxChatApiBase(): string { + return ( + process.env.ZENMUX_PAY2GO_API_URL || + process.env.ZENMUX_API_URL || + process.env.COMMONSTACK_API_URL || + DEFAULT_CHAT_API_BASE + ).replace(/\/+$/, '') +} + +export function getZenmuxVertexApiBase(): string { + const configured = + process.env.ZENMUX_PAY2GO_VERTEX_API_URL || + process.env.ZENMUX_VERTEX_API_URL || + process.env.ZENMUX_PAY2GO_API_URL || + process.env.ZENMUX_API_URL || + process.env.COMMONSTACK_API_URL || + DEFAULT_VERTEX_API_BASE + + return configured + .replace(/\/v1\/?$/, '') + .replace(/\/chat\/completions\/?$/, '') + .replace(/\/+$/, '') + .replace(/\/api$/, '/api/vertex-ai') +} + +export function getTemperatureForModel(modelId: string, preferred: number): number { + // Kimi K2.5 currently rejects arbitrary temperature values on ZenMux Pay As You Go. + // Error observed: "invalid temperature: only 1 is allowed for this model". + if (modelId === 'moonshotai/kimi-k2.5') return 1 + return preferred +} From 5a8601b3cb5e15c83ced28aebe4d039f5197bef2 Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sat, 2 May 2026 23:44:19 +0800 Subject: [PATCH 2/8] feat(OPE-100): implement status machine v1.1 core --- .../public/[eventId]/EventDetailClient.tsx | 2 +- app/api/admin/scores/[scoreId]/route.ts | 62 ++++++ app/api/cron/transition-status/route.ts | 80 +++----- .../events/[eventId]/reviewer-submit/route.ts | 9 + app/api/events/[eventId]/status/route.ts | 128 ++++++------- app/api/teams/[id]/join/route.ts | 49 ++--- app/api/teams/[id]/leave/route.ts | 45 +---- app/api/teams/[id]/requests/[reqId]/route.ts | 43 +++-- app/api/teams/[id]/route.ts | 13 +- app/api/teams/[id]/transfer/route.ts | 49 +++++ app/api/teams/route.ts | 61 ++---- app/api/v1/events/[id]/cancel/route.ts | 58 +++--- app/api/v1/events/[id]/register/route.ts | 7 +- app/api/v1/events/[id]/submit/route.ts | 179 +++++++++++------- lib/event-status.ts | 78 ++++++++ lib/i18n/en.ts | 10 +- lib/i18n/zh.ts | 12 +- lib/mail.ts | 15 ++ supabase/migrations/018_status_v1_1.sql | 49 ++++- 19 files changed, 577 insertions(+), 372 deletions(-) create mode 100644 app/api/admin/scores/[scoreId]/route.ts create mode 100644 app/api/teams/[id]/transfer/route.ts create mode 100644 lib/event-status.ts diff --git a/app/(public)/events/public/[eventId]/EventDetailClient.tsx b/app/(public)/events/public/[eventId]/EventDetailClient.tsx index fc3bbbc..099d469 100644 --- a/app/(public)/events/public/[eventId]/EventDetailClient.tsx +++ b/app/(public)/events/public/[eventId]/EventDetailClient.tsx @@ -109,7 +109,7 @@ export default function EventDetailClient({ event }: { event: EventDetail }) { return formatDeterministic(dateStr, locale) } - const isRegPhase = event.status === 'recruiting' || event.status === 'hacking' + const isRegPhase = event.status === 'recruiting' const regDeadlinePassed = !!event.registration_deadline && new Date(event.registration_deadline) < new Date() const isRegOpen = !!event.registration_config?.open && isRegPhase && !regDeadlinePassed const isDone = event.status === 'done' diff --git a/app/api/admin/scores/[scoreId]/route.ts b/app/api/admin/scores/[scoreId]/route.ts new file mode 100644 index 0000000..355e316 --- /dev/null +++ b/app/api/admin/scores/[scoreId]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createServiceClient } from '@/lib/supabase' +import { getSessionUserWithRole } from '@/lib/session' + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ scoreId: string }> } +) { + const session = await getSessionUserWithRole() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!session.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const { scoreId } = await params + const body = await req.json().catch(() => ({})) as { + final_overall_score?: number + final_dimension_scores?: Record + reason?: string + } + + const update: Record = {} + if (typeof body.final_overall_score === 'number') update.final_overall_score = body.final_overall_score + if (body.final_dimension_scores && typeof body.final_dimension_scores === 'object') update.final_dimension_scores = body.final_dimension_scores + if (Object.keys(update).length === 0) return NextResponse.json({ error: 'No score fields to update' }, { status: 400 }) + + const db = createServiceClient() + const { data: score, error: scoreError } = await db + .from('reviewer_final_scores') + .select('*, events!inner(status)') + .eq('id', scoreId) + .single() + + if (scoreError || !score) return NextResponse.json({ error: 'Score not found' }, { status: 404 }) + const eventRow = Array.isArray(score.events) ? score.events[0] : score.events + if (eventRow?.status !== 'done') { + return NextResponse.json({ error: 'Admin score edits are only allowed after event is done' }, { status: 409 }) + } + + const before = { + final_overall_score: score.final_overall_score, + final_dimension_scores: score.final_dimension_scores, + } + const { data: updated, error: updateError } = await db + .from('reviewer_final_scores') + .update(update) + .eq('id', scoreId) + .select() + .single() + + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + + await db.from('admin_audit_log').insert({ + admin_user_id: session.userId, + action: 'score.update_after_done', + target_type: 'reviewer_final_scores', + target_id: scoreId, + before_data: before, + after_data: update, + metadata: { reason: body.reason ?? null }, + }) + + return NextResponse.json({ score: updated }) +} diff --git a/app/api/cron/transition-status/route.ts b/app/api/cron/transition-status/route.ts index ab125c2..9efbd4e 100644 --- a/app/api/cron/transition-status/route.ts +++ b/app/api/cron/transition-status/route.ts @@ -1,18 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' - -// GET /api/cron/transition-status — Vercel cron handler for OPE-100 status v1.1. -// Auto-transitions events through the lifecycle based on time fields. -// recruiting → hacking : registration_deadline < now AND submission_deadline set -// recruiting → judging : registration_deadline < now AND no submission_deadline -// hacking → judging : submission_deadline < now -// judging → done : judging_end < now (preferred) OR result_announced_at < now -// On every transition we also force registration_config.open = false so a stale -// `open: true` flag can never let new registrations slip in after the deadline. +import { canTransitionEventStatus, deriveEventStatus, type EventStatus } from '@/lib/event-status' type EventRow = { id: string status: string + start_time: string | null registration_deadline: string | null submission_deadline: string | null judging_end: string | null @@ -20,15 +13,14 @@ type EventRow = { registration_config: Record | null } +type CountKey = `${EventStatus}_to_${EventStatus}` + export async function GET(request: NextRequest) { const expected = process.env.CRON_SECRET - if (!expected) { - return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }) - } + if (!expected) return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }) + const auth = request.headers.get('authorization') ?? '' - if (auth !== `Bearer ${expected}`) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (auth !== `Bearer ${expected}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const db = createServiceClient() const now = new Date() @@ -36,32 +28,26 @@ export async function GET(request: NextRequest) { const { data: events, error } = await db .from('events') - .select('id, status, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') + .select('id, status, start_time, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') .is('deleted_at', null) - .not('status', 'in', '(draft,done,cancelled)') + .not('status', 'in', '(done,cancelled)') - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const counts = { - recruiting_to_hacking: 0, - recruiting_to_judging: 0, - hacking_to_judging: 0, - judging_to_done: 0, - scanned: events?.length ?? 0, - failed: 0, - } + const counts: Record = { scanned: events?.length ?? 0, failed: 0 } const errors: Array<{ id: string; message: string }> = [] for (const event of (events ?? []) as EventRow[]) { - const target = nextStatus(event, now) - if (!target) continue + const target = deriveEventStatus(event, now) + if (!target || target === event.status) continue + if (!canTransitionEventStatus(event.status, target)) continue const prevConfig = (event.registration_config ?? {}) as Record - const update: Record = { status: target } - if (event.registration_config !== null && event.registration_config !== undefined) { - update.registration_config = { ...prevConfig, open: false } + const update: Record = { + status: target, + registration_config: target === 'recruiting' + ? { ...prevConfig, open: true } + : { ...prevConfig, open: false }, } const { error: updateError } = await db @@ -76,29 +62,13 @@ export async function GET(request: NextRequest) { continue } - if (event.status === 'recruiting' && target === 'hacking') counts.recruiting_to_hacking += 1 - else if (event.status === 'recruiting' && target === 'judging') counts.recruiting_to_judging += 1 - else if (event.status === 'hacking' && target === 'judging') counts.hacking_to_judging += 1 - else if (event.status === 'judging' && target === 'done') counts.judging_to_done += 1 + const key: CountKey = `${event.status as EventStatus}_to_${target}` + counts[key] = (counts[key] ?? 0) + 1 + + if (target === 'judging') { + await db.from('teams').update({ status: 'locked' }).eq('event_id', event.id).eq('status', 'open') + } } return NextResponse.json({ ok: true, ranAt: nowIso, counts, errors }) } - -function nextStatus(event: EventRow, now: Date): string | null { - const regPassed = event.registration_deadline !== null && new Date(event.registration_deadline) < now - const subPassed = event.submission_deadline !== null && new Date(event.submission_deadline) < now - const judgingEndPassed = event.judging_end !== null && new Date(event.judging_end) < now - const resultAnnouncedPassed = event.result_announced_at !== null && new Date(event.result_announced_at) < now - - if (event.status === 'recruiting' && regPassed) { - return event.submission_deadline ? 'hacking' : 'judging' - } - if (event.status === 'hacking' && subPassed) { - return 'judging' - } - if (event.status === 'judging' && (judgingEndPassed || resultAnnouncedPassed)) { - return 'done' - } - return null -} diff --git a/app/api/events/[eventId]/reviewer-submit/route.ts b/app/api/events/[eventId]/reviewer-submit/route.ts index e5b6dc3..639d78c 100644 --- a/app/api/events/[eventId]/reviewer-submit/route.ts +++ b/app/api/events/[eventId]/reviewer-submit/route.ts @@ -18,6 +18,15 @@ export async function POST( const { eventId } = await params const db = createServiceClient() + const { data: event } = await db + .from('events') + .select('status') + .eq('id', eventId) + .single() + + if (!event) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + if (event.status === 'done') return NextResponse.json({ error: 'Event is done; reviewers are read-only' }, { status: 403 }) + const { data: reviewer } = await db .from('event_reviewers') .select('id') diff --git a/app/api/events/[eventId]/status/route.ts b/app/api/events/[eventId]/status/route.ts index 20ad728..372672a 100644 --- a/app/api/events/[eventId]/status/route.ts +++ b/app/api/events/[eventId]/status/route.ts @@ -1,11 +1,19 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getSessionUserWithRole } from '@/lib/session' +import { canTransitionEventStatus, deriveEventStatus, type EventStatus } from '@/lib/event-status' +import { sendEventCancelledEmail } from '@/lib/mail' + +const ACTION_TARGET: Record = { + publish: 'recruiting', + schedule: 'upcoming', + close_registration: 'hacking', + merge_open: 'open', + start_review: 'judging', + publish_result: 'done', + cancel: 'cancelled', +} -// POST /api/events/[eventId]/status -// Body: { action: "publish" | "close_registration" | "start_review" | "publish_result" | "cancel", reason?: string } -// Transitions: draft → recruiting → hacking → judging → done -// ↘ cancelled (from any non-terminal state) export async function POST( req: NextRequest, { params }: { params: Promise<{ eventId: string }> } @@ -18,100 +26,84 @@ export async function POST( const { data: event } = await db .from('events') - .select('id, user_id, status, models, mode, registration_config') + .select('id, user_id, name, status, models, mode, start_time, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') .eq('id', eventId) .single() if (!event) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) if (event.user_id !== session.userId && !session.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - const body = await req.json().catch(() => ({})) as { action?: string; reason?: string } - const { action } = body + const body = await req.json().catch(() => ({})) as { action?: string; status?: EventStatus; reason?: string } + const target = body.status ?? (body.action ? ACTION_TARGET[body.action] : undefined) + if (!target) return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) - if (action === 'publish') { - if (event.status !== 'draft') { - return NextResponse.json({ error: 'Event must be in draft status to publish' }, { status: 400 }) - } - const { error } = await db.from('events').update({ status: 'recruiting' }).eq('id', eventId) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ status: 'recruiting' }) + if (!canTransitionEventStatus(event.status, target)) { + return NextResponse.json({ error: `Illegal status transition: ${event.status} -> ${target}` }, { status: 409 }) } - if (action === 'close_registration') { - if (event.status !== 'recruiting') { - return NextResponse.json({ error: 'Event must be in recruiting status to close registration' }, { status: 400 }) - } - const { error } = await db.from('events').update({ status: 'hacking' }).eq('id', eventId) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ status: 'hacking' }) + const derived = deriveEventStatus(event) + if (target === 'open' && derived !== 'open') { + return NextResponse.json({ error: 'open requires registration_deadline == submission_deadline' }, { status: 409 }) } - if (action === 'start_review') { - if (event.status !== 'recruiting' && event.status !== 'hacking') { - return NextResponse.json({ error: 'Event must be in recruiting or hacking status to start review' }, { status: 400 }) - } + const prevConfig = (event.registration_config ?? {}) as Record + const update: Record = { status: target } + if (target === 'cancelled') { + update.cancelled_at = new Date().toISOString() + update.cancelled_reason = body.reason?.trim() || null + update.registration_config = { ...prevConfig, open: false } + } else if (target === 'recruiting') { + update.registration_config = { ...prevConfig, open: true } + } else { + update.registration_config = { ...prevConfig, open: false } + } + + const { error: updateError } = await db.from('events').update(update).eq('id', eventId).eq('status', event.status) + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + + let enqueued = 0 + let cancelledNotified = 0 + + if (target === 'cancelled') { + const { data: registrations } = await db + .from('registrations') + .select('users!inner(email)') + .eq('event_id', eventId) + .neq('status', 'rejected') + + const results = await Promise.allSettled((registrations ?? []).map(async row => { + const userRow = Array.isArray(row.users) ? row.users[0] : row.users + const email = userRow?.email + if (!email) return false + await sendEventCancelledEmail(email, event.name, body.reason?.trim() || null) + return true + })) + cancelledNotified = results.filter(r => r.status === 'fulfilled' && r.value).length + } - // Update event status to judging - const { error: updateError } = await db.from('events').update({ status: 'judging' }).eq('id', eventId) - if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + if (target === 'judging') { + await db.from('teams').update({ status: 'locked' }).eq('event_id', eventId).eq('status', 'open') - // Enqueue all unanalyzed projects for background analysis const { data: projects } = await db .from('projects') .select('id') .eq('event_id', eventId) .or('analysis_status.is.null,analysis_status.eq.error,analysis_status.eq.pending') - let enqueued = 0 if (projects && projects.length > 0) { const projectIds = projects.map(p => p.id) - // Remove existing pending jobs for these projects await db.from('analysis_queue').delete().in('project_id', projectIds).eq('status', 'pending') - // Insert new queue entries - const entries = projectIds.map(pid => ({ + await db.from('analysis_queue').insert(projectIds.map(pid => ({ project_id: pid, event_id: eventId, status: 'pending', models: (event.models as string[]) ?? [], sonar_enabled: false, - })) - await db.from('analysis_queue').insert(entries) + }))) await db.from('projects').update({ analysis_status: 'pending' }).in('id', projectIds) enqueued = projectIds.length } - - return NextResponse.json({ status: 'judging', enqueued }) - } - - if (action === 'publish_result') { - if (event.status !== 'judging') { - return NextResponse.json({ error: 'Event must be in judging status to publish results' }, { status: 400 }) - } - const { error } = await db.from('events').update({ status: 'done' }).eq('id', eventId) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ status: 'done' }) - } - - if (action === 'cancel') { - if (event.status === 'done') { - return NextResponse.json({ error: 'Cannot cancel a completed event' }, { status: 400 }) - } - if (event.status === 'cancelled') { - return NextResponse.json({ error: 'Event is already cancelled' }, { status: 400 }) - } - const prevConfig = (event.registration_config ?? {}) as Record - const update: Record = { - status: 'cancelled', - cancelled_at: new Date().toISOString(), - cancelled_reason: body.reason?.trim() || null, - } - if (event.registration_config !== null && event.registration_config !== undefined) { - update.registration_config = { ...prevConfig, open: false } - } - const { error } = await db.from('events').update(update).eq('id', eventId) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ status: 'cancelled' }) } - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + return NextResponse.json({ status: target, enqueued, cancelledNotified }) } diff --git a/app/api/teams/[id]/join/route.ts b/app/api/teams/[id]/join/route.ts index 7f2f300..7650a34 100644 --- a/app/api/teams/[id]/join/route.ts +++ b/app/api/teams/[id]/join/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase' +import { teamMutableStatus } from '@/lib/event-status' -// POST /api/teams/[id]/join — apply to join a team export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -13,40 +13,34 @@ export async function POST( const { id: teamId } = await params const supabase = createServiceClient() - // Verify team exists and is open const { data: team, error: teamError } = await supabase .from('teams') - .select('id, leader_id, max_members, status') + .select('id, event_id, leader_id, max_members, status, events!inner(status)') .eq('id', teamId) .single() if (teamError || !team) return NextResponse.json({ error: 'Team not found' }, { status: 404 }) - if (team.status === 'disbanded') { - return NextResponse.json({ error: 'Team has been disbanded' }, { status: 400 }) - } - if (team.status === 'locked') { - return NextResponse.json({ error: 'Team is locked' }, { status: 400 }) - } - if (team.status !== 'open') { - return NextResponse.json({ error: 'Team is not accepting new members' }, { status: 400 }) - } - if (team.leader_id === user.userId) { - return NextResponse.json({ error: 'You are the leader of this team' }, { status: 400 }) + const eventRow = Array.isArray(team.events) ? team.events[0] : team.events + if (!teamMutableStatus(eventRow?.status)) { + return NextResponse.json({ error: 'Team membership is locked for this event stage' }, { status: 409 }) } + if (team.status === 'disbanded') return NextResponse.json({ error: 'Team has been disbanded' }, { status: 400 }) + if (team.status === 'locked') return NextResponse.json({ error: 'Team is locked' }, { status: 409 }) + if (team.status !== 'open') return NextResponse.json({ error: 'Team is not accepting new members' }, { status: 400 }) + if (team.leader_id === user.userId) return NextResponse.json({ error: 'You are the leader of this team' }, { status: 400 }) - // Check if already a member - const { data: existingMember } = await supabase + const { data: existingInEvent } = await supabase .from('team_members') - .select('id') - .eq('team_id', teamId) + .select('team_id, teams!inner(event_id, status)') .eq('user_id', user.userId) + .eq('teams.event_id', team.event_id) + .neq('teams.status', 'disbanded') .maybeSingle() - if (existingMember) { - return NextResponse.json({ error: 'Already a member of this team' }, { status: 400 }) + if (existingInEvent) { + return NextResponse.json({ error: 'User already belongs to a team in this event' }, { status: 409 }) } - // Check existing pending request const { data: existingRequest } = await supabase .from('team_join_requests') .select('id, status') @@ -55,10 +49,7 @@ export async function POST( .maybeSingle() if (existingRequest) { - return NextResponse.json( - { error: `Already have a ${existingRequest.status} request for this team` }, - { status: 400 } - ) + return NextResponse.json({ error: `Already have a ${existingRequest.status} request for this team` }, { status: 400 }) } const body = await req.json().catch(() => ({})) @@ -66,16 +57,10 @@ export async function POST( const { data: request, error } = await supabase .from('team_join_requests') - .insert({ - team_id: teamId, - user_id: user.userId, - message: message || null, - status: 'pending', - }) + .insert({ team_id: teamId, user_id: user.userId, message: message || null, status: 'pending' }) .select() .single() if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ request }, { status: 201 }) } diff --git a/app/api/teams/[id]/leave/route.ts b/app/api/teams/[id]/leave/route.ts index 4a804b9..8b94c6c 100644 --- a/app/api/teams/[id]/leave/route.ts +++ b/app/api/teams/[id]/leave/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase' +import { teamMutableStatus } from '@/lib/event-status' -// POST /api/teams/[id]/leave — member leaves team export async function POST( _req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -13,21 +13,20 @@ export async function POST( const { id: teamId } = await params const supabase = createServiceClient() - // Fetch team const { data: team, error: teamError } = await supabase .from('teams') - .select('id, leader_id, status') + .select('id, leader_id, status, events!inner(status)') .eq('id', teamId) .single() if (teamError || !team) return NextResponse.json({ error: 'Team not found' }, { status: 404 }) - if (team.status === 'disbanded') { - return NextResponse.json({ error: 'Team has been disbanded' }, { status: 400 }) + const eventRow = Array.isArray(team.events) ? team.events[0] : team.events + if (!teamMutableStatus(eventRow?.status)) { + return NextResponse.json({ error: 'Team membership is locked for this event stage' }, { status: 409 }) } + if (team.status === 'disbanded') return NextResponse.json({ error: 'Team has been disbanded' }, { status: 400 }) const isLeader = team.leader_id === user.userId - - // Check membership const { data: member } = await supabase .from('team_members') .select('id') @@ -35,52 +34,25 @@ export async function POST( .eq('user_id', user.userId) .maybeSingle() - if (!member && !isLeader) { - return NextResponse.json({ error: 'You are not a member of this team' }, { status: 400 }) - } + if (!member && !isLeader) return NextResponse.json({ error: 'You are not a member of this team' }, { status: 400 }) - // Count remaining members (including leader) const { data: allMembers } = await supabase .from('team_members') .select('user_id') .eq('team_id', teamId) - const memberCount = allMembers?.length ?? 0 - // If leader and last member, disband instead if (isLeader && memberCount <= 1) { - // Disband: delete members, requests, set status await supabase.from('team_members').delete().eq('team_id', teamId) await supabase.from('team_join_requests').delete().eq('team_id', teamId).eq('status', 'pending') await supabase.from('teams').update({ status: 'disbanded' }).eq('id', teamId) return NextResponse.json({ ok: true, disbanded: true }) } - // Leader leaving but other members exist — transfer leadership first if (isLeader) { - // Pick the earliest joined non-leader member as new leader - const { data: nextLeader } = await supabase - .from('team_members') - .select('user_id') - .eq('team_id', teamId) - .neq('user_id', user.userId) - .order('joined_at', { ascending: true }) - .limit(1) - .maybeSingle() - - if (nextLeader) { - // Update team leader - await supabase.from('teams').update({ leader_id: nextLeader.user_id }).eq('id', teamId) - // Update member role - await supabase - .from('team_members') - .update({ role: 'leader' }) - .eq('team_id', teamId) - .eq('user_id', nextLeader.user_id) - } + return NextResponse.json({ error: 'Team leader must transfer leadership before leaving' }, { status: 409 }) } - // Remove the member const { error: deleteError } = await supabase .from('team_members') .delete() @@ -88,6 +60,5 @@ export async function POST( .eq('user_id', user.userId) if (deleteError) return NextResponse.json({ error: deleteError.message }, { status: 500 }) - return NextResponse.json({ ok: true }) } diff --git a/app/api/teams/[id]/requests/[reqId]/route.ts b/app/api/teams/[id]/requests/[reqId]/route.ts index 3bc8a62..34bebec 100644 --- a/app/api/teams/[id]/requests/[reqId]/route.ts +++ b/app/api/teams/[id]/requests/[reqId]/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase' +import { teamMutableStatus } from '@/lib/event-status' -// PUT /api/teams/[id]/requests/[reqId] — approve or reject a join request (leader only) export async function PUT( req: NextRequest, { params }: { params: Promise<{ id: string; reqId: string }> } @@ -13,19 +13,19 @@ export async function PUT( const { id: teamId, reqId } = await params const supabase = createServiceClient() - // Verify leader const { data: team, error: teamError } = await supabase .from('teams') - .select('id, leader_id, max_members') + .select('id, event_id, leader_id, max_members, events!inner(status)') .eq('id', teamId) .single() if (teamError || !team) return NextResponse.json({ error: 'Team not found' }, { status: 404 }) - if (team.leader_id !== user.userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + if (team.leader_id !== user.userId) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const eventRow = Array.isArray(team.events) ? team.events[0] : team.events + if (!teamMutableStatus(eventRow?.status)) { + return NextResponse.json({ error: 'Team membership is locked for this event stage' }, { status: 409 }) } - // Get the request const { data: joinReq, error: reqError } = await supabase .from('team_join_requests') .select('id, user_id, status') @@ -33,32 +33,35 @@ export async function PUT( .eq('team_id', teamId) .single() - if (reqError || !joinReq) { - return NextResponse.json({ error: 'Request not found' }, { status: 404 }) - } - if (joinReq.status !== 'pending') { - return NextResponse.json({ error: 'Request already processed' }, { status: 400 }) - } + if (reqError || !joinReq) return NextResponse.json({ error: 'Request not found' }, { status: 404 }) + if (joinReq.status !== 'pending') return NextResponse.json({ error: 'Request already processed' }, { status: 400 }) const body = await req.json() - const { action } = body // 'approve' | 'reject' - + const { action } = body if (action !== 'approve' && action !== 'reject') { return NextResponse.json({ error: 'action must be approve or reject' }, { status: 400 }) } if (action === 'approve') { - // Check member count + const { data: existingInEvent } = await supabase + .from('team_members') + .select('team_id, teams!inner(event_id, status)') + .eq('user_id', joinReq.user_id) + .eq('teams.event_id', team.event_id) + .neq('teams.status', 'disbanded') + .maybeSingle() + + if (existingInEvent) { + return NextResponse.json({ error: 'User already belongs to a team in this event' }, { status: 409 }) + } + const { count } = await supabase .from('team_members') .select('id', { count: 'exact', head: true }) .eq('team_id', teamId) - if ((count ?? 0) >= team.max_members) { - return NextResponse.json({ error: 'Team is full' }, { status: 400 }) - } + if ((count ?? 0) >= team.max_members) return NextResponse.json({ error: 'Team is full' }, { status: 400 }) - // Add as member const { error: memberError } = await supabase .from('team_members') .insert({ team_id: teamId, user_id: joinReq.user_id, role: 'member' }) @@ -66,7 +69,6 @@ export async function PUT( if (memberError) return NextResponse.json({ error: memberError.message }, { status: 500 }) } - // Update request status const { data: updated, error: updateError } = await supabase .from('team_join_requests') .update({ status: action === 'approve' ? 'approved' : 'rejected' }) @@ -75,6 +77,5 @@ export async function PUT( .single() if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) - return NextResponse.json({ request: updated }) } diff --git a/app/api/teams/[id]/route.ts b/app/api/teams/[id]/route.ts index 5efce73..0b9759c 100644 --- a/app/api/teams/[id]/route.ts +++ b/app/api/teams/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase' +import { teamMutableStatus } from '@/lib/event-status' // GET /api/teams/[id] — team detail with members and pending requests // @@ -89,7 +90,7 @@ export async function PUT( // Verify ownership const { data: team, error: fetchError } = await supabase .from('teams') - .select('leader_id, status') + .select('leader_id, status, events!inner(status)') .eq('id', id) .single() @@ -97,6 +98,10 @@ export async function PUT( if (team.leader_id !== user.userId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const eventRow = Array.isArray(team.events) ? team.events[0] : team.events + if (!teamMutableStatus(eventRow?.status)) { + return NextResponse.json({ error: 'Team membership is locked for this event stage' }, { status: 409 }) + } if (team.status === 'disbanded') { return NextResponse.json({ error: 'Team has been disbanded' }, { status: 400 }) } @@ -145,7 +150,7 @@ export async function DELETE( const { data: team, error: fetchError } = await supabase .from('teams') - .select('leader_id, status') + .select('leader_id, status, events!inner(status)') .eq('id', id) .single() @@ -153,6 +158,10 @@ export async function DELETE( if (team.leader_id !== user.userId) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + const eventRow = Array.isArray(team.events) ? team.events[0] : team.events + if (!teamMutableStatus(eventRow?.status)) { + return NextResponse.json({ error: 'Team membership is locked for this event stage' }, { status: 409 }) + } if (team.status === 'disbanded') { return NextResponse.json({ error: 'Team already disbanded' }, { status: 400 }) } diff --git a/app/api/teams/[id]/transfer/route.ts b/app/api/teams/[id]/transfer/route.ts new file mode 100644 index 0000000..1ddc520 --- /dev/null +++ b/app/api/teams/[id]/transfer/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionUser } from '@/lib/session' +import { createServiceClient } from '@/lib/supabase' +import { teamMutableStatus } from '@/lib/event-status' + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { id: teamId } = await params + const { user_id: nextLeaderId } = await req.json().catch(() => ({})) as { user_id?: string } + if (!nextLeaderId) return NextResponse.json({ error: 'user_id required' }, { status: 400 }) + if (nextLeaderId === user.userId) return NextResponse.json({ error: 'Already leader' }, { status: 400 }) + + const supabase = createServiceClient() + const { data: team, error: teamError } = await supabase + .from('teams') + .select('id, leader_id, status, events!inner(status)') + .eq('id', teamId) + .single() + + if (teamError || !team) return NextResponse.json({ error: 'Team not found' }, { status: 404 }) + if (team.leader_id !== user.userId) return NextResponse.json({ error: 'Only the team leader can transfer leadership' }, { status: 403 }) + const eventRow = Array.isArray(team.events) ? team.events[0] : team.events + if (!teamMutableStatus(eventRow?.status)) { + return NextResponse.json({ error: 'Team membership is locked for this event stage' }, { status: 409 }) + } + if (team.status === 'disbanded') return NextResponse.json({ error: 'Team has been disbanded' }, { status: 400 }) + + const { data: targetMember } = await supabase + .from('team_members') + .select('id') + .eq('team_id', teamId) + .eq('user_id', nextLeaderId) + .maybeSingle() + + if (!targetMember) return NextResponse.json({ error: 'New leader must be an existing team member' }, { status: 400 }) + + const { error: updateError } = await supabase.from('teams').update({ leader_id: nextLeaderId }).eq('id', teamId) + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + + await supabase.from('team_members').update({ role: 'member' }).eq('team_id', teamId).eq('user_id', user.userId) + await supabase.from('team_members').update({ role: 'leader' }).eq('team_id', teamId).eq('user_id', nextLeaderId) + + return NextResponse.json({ ok: true, leader_id: nextLeaderId }) +} diff --git a/app/api/teams/route.ts b/app/api/teams/route.ts index e639b11..aa6e182 100644 --- a/app/api/teams/route.ts +++ b/app/api/teams/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase' +import { teamMutableStatus } from '@/lib/event-status' -// GET /api/teams?event_id=xxx — list teams for an event -// POST /api/teams — create a new team export async function GET(req: NextRequest) { const user = await getSessionUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -12,7 +11,6 @@ export async function GET(req: NextRequest) { if (!eventId) return NextResponse.json({ error: 'event_id required' }, { status: 400 }) const supabase = createServiceClient() - const { data: teams, error } = await supabase .from('teams') .select(` @@ -26,7 +24,6 @@ export async function GET(req: NextRequest) { .order('created_at', { ascending: false }) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ teams }) } @@ -36,14 +33,9 @@ export async function POST(req: NextRequest) { const body = await req.json() const { event_id, name, description, max_members = 4, skills_needed = [] } = body - - if (!event_id || !name) { - return NextResponse.json({ error: 'event_id and name are required' }, { status: 400 }) - } + if (!event_id || !name) return NextResponse.json({ error: 'event_id and name are required' }, { status: 400 }) const supabase = createServiceClient() - - // 1. Event must exist and be in a team-creation-allowed phase const { data: event, error: eventError } = await supabase .from('events') .select('id, status, deleted_at') @@ -51,18 +43,11 @@ export async function POST(req: NextRequest) { .is('deleted_at', null) .single() - if (eventError || !event) { - return NextResponse.json({ error: 'Event not found' }, { status: 404 }) - } - - if (!['recruiting', 'hacking', 'judging'].includes(event.status)) { - return NextResponse.json( - { error: 'Team creation is not allowed for this event stage' }, - { status: 403 } - ) + if (eventError || !event) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + if (!teamMutableStatus(event.status)) { + return NextResponse.json({ error: 'Team creation is not allowed for this event stage' }, { status: 409 }) } - // 2. Caller must have an approved registration for this event const { data: reg } = await supabase .from('registrations') .select('id') @@ -72,39 +57,33 @@ export async function POST(req: NextRequest) { .maybeSingle() if (!reg) { - return NextResponse.json( - { error: 'You must have an approved registration for this event' }, - { status: 403 } - ) + return NextResponse.json({ error: 'You must have an approved registration for this event' }, { status: 403 }) + } + + const { data: existingTeamMember } = await supabase + .from('team_members') + .select('team_id, teams!inner(event_id, status)') + .eq('user_id', user.userId) + .eq('teams.event_id', event_id) + .neq('teams.status', 'disbanded') + .maybeSingle() + + if (existingTeamMember) { + return NextResponse.json({ error: 'User already belongs to a team in this event' }, { status: 409 }) } - // Create the team const { data: team, error: teamError } = await supabase .from('teams') - .insert({ - event_id, - name, - description, - leader_id: user.userId, - max_members, - skills_needed, - status: 'open', - }) + .insert({ event_id, name, description, leader_id: user.userId, max_members, skills_needed, status: 'open' }) .select() .single() if (teamError) return NextResponse.json({ error: teamError.message }, { status: 500 }) - // Auto-add leader as a member with role 'leader' const { error: memberError } = await supabase .from('team_members') - .insert({ - team_id: team.id, - user_id: user.userId, - role: 'leader', - }) + .insert({ team_id: team.id, user_id: user.userId, role: 'leader' }) if (memberError) return NextResponse.json({ error: memberError.message }, { status: 500 }) - return NextResponse.json({ team }, { status: 201 }) } diff --git a/app/api/v1/events/[id]/cancel/route.ts b/app/api/v1/events/[id]/cancel/route.ts index 1214640..591b794 100644 --- a/app/api/v1/events/[id]/cancel/route.ts +++ b/app/api/v1/events/[id]/cancel/route.ts @@ -1,70 +1,66 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getAgentUser } from '@/lib/agentAuth' +import { canTransitionEventStatus } from '@/lib/event-status' +import { sendEventCancelledEmail } from '@/lib/mail' -// POST /api/v1/events/[id]/cancel — organizer/admin agent cancels a non-terminal event (OPE-100 status v1.1) -// Body: { reason?: string } export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params - const user = await getAgentUser(request) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!user.role.includes('admin') && !user.role.includes('organizer')) { return NextResponse.json({ error: 'Forbidden: requires admin or organizer role' }, { status: 403 }) } const db = createServiceClient() - const { data: event, error: fetchError } = await db .from('events') - .select('id, user_id, status, registration_config') + .select('id, user_id, name, status, registration_config') .eq('id', id) .is('deleted_at', null) .single() - if (fetchError || !event) { - return NextResponse.json({ error: 'Event not found' }, { status: 404 }) - } - - if (event.user_id !== user.userId) { - return NextResponse.json({ error: 'Forbidden: not the event owner' }, { status: 403 }) - } - - if (event.status === 'done') { - return NextResponse.json({ error: 'EVENT_CANCEL_ALREADY_DONE' }, { status: 409 }) - } - if (event.status === 'cancelled') { - return NextResponse.json({ error: 'EVENT_CANCEL_ALREADY_CANCELLED' }, { status: 409 }) + if (fetchError || !event) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + if (event.user_id !== user.userId) return NextResponse.json({ error: 'Forbidden: not the event owner' }, { status: 403 }) + if (!canTransitionEventStatus(event.status, 'cancelled')) { + return NextResponse.json({ error: `Illegal status transition: ${event.status} -> cancelled` }, { status: 409 }) } const body = (await request.json().catch(() => ({}))) as { reason?: string } const reason = typeof body.reason === 'string' ? body.reason.trim() : '' - const prevConfig = (event.registration_config ?? {}) as Record const update: Record = { status: 'cancelled', cancelled_at: new Date().toISOString(), cancelled_reason: reason || null, - } - if (event.registration_config !== null && event.registration_config !== undefined) { - update.registration_config = { ...prevConfig, open: false } + registration_config: { ...prevConfig, open: false }, } const { error: updateError } = await db .from('events') .update(update) .eq('id', id) - .neq('status', 'done') - .neq('status', 'cancelled') + .in('status', ['draft', 'upcoming', 'recruiting']) - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } + if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 }) + + const { data: registrations } = await db + .from('registrations') + .select('users!inner(email)') + .eq('event_id', id) + .neq('status', 'rejected') + + const results = await Promise.allSettled((registrations ?? []).map(async row => { + const userRow = Array.isArray(row.users) ? row.users[0] : row.users + const email = userRow?.email + if (!email) return false + await sendEventCancelledEmail(email, event.name, reason || null) + return true + })) + const cancelledNotified = results.filter(r => r.status === 'fulfilled' && r.value).length - return NextResponse.json({ id: event.id, status: 'cancelled' }) + return NextResponse.json({ id: event.id, status: 'cancelled', cancelledNotified }) } diff --git a/app/api/v1/events/[id]/register/route.ts b/app/api/v1/events/[id]/register/route.ts index 2d99c97..6ca38bd 100644 --- a/app/api/v1/events/[id]/register/route.ts +++ b/app/api/v1/events/[id]/register/route.ts @@ -75,10 +75,9 @@ export async function POST( return NextResponse.json({ error: 'Event not found' }, { status: 404 }) } - // Only 'recruiting' or 'hacking' events accept registrations. draft/judging/done/cancelled all - // refuse, regardless of registration_config.open — a draft event must not leak via public POST - // even if the organizer flipped open=true. - if (event.status !== 'recruiting' && event.status !== 'hacking') { + // Only recruiting events accept registrations. draft/upcoming/hacking/open/judging/done/cancelled all + // refuse, regardless of registration_config.open. + if (event.status !== 'recruiting') { return NextResponse.json( { error: 'Event is not accepting registrations', status: event.status }, { status: 400 } diff --git a/app/api/v1/events/[id]/submit/route.ts b/app/api/v1/events/[id]/submit/route.ts index 20a7e37..965e930 100644 --- a/app/api/v1/events/[id]/submit/route.ts +++ b/app/api/v1/events/[id]/submit/route.ts @@ -1,40 +1,36 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getAgentUser } from '@/lib/agentAuth' -import { validateProjectInput } from '@/lib/validate-project' +import { validateProjectInput, type ValidationResult } from '@/lib/validate-project' +import { submissionAllowedStatus } from '@/lib/event-status' -// POST /api/v1/events/[id]/submit — 需要 API key 鉴权 export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const user = await getAgentUser(request) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { id: eventId } = await params const db = createServiceClient() - // 检查活动是否存在并获取 submission_deadline const { data: event } = await db .from('events') - .select('id, submission_deadline') + .select('id, status, submission_deadline') .eq('id', eventId) .is('deleted_at', null) .single() - if (!event) { - return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + if (!event) return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + if (!submissionAllowedStatus(event.status)) { + return NextResponse.json({ error: 'Submissions are only accepted during hacking/open stages' }, { status: 403 }) } - // 检查提交截止时间 - const submissionDeadline = (event as Record)['submission_deadline'] as string | null + const submissionDeadline = event.submission_deadline as string | null if (submissionDeadline && new Date(submissionDeadline) < new Date()) { return NextResponse.json({ error: 'Submission deadline has passed' }, { status: 400 }) } - // 前置检查:该用户在该活动必须有 approved 的 registration const { data: reg } = await db .from('registrations') .select('id, status, team_name') @@ -43,30 +39,27 @@ export async function POST( .single() if (!reg) { - return NextResponse.json( - { error: 'You are not registered for this event. Please register first.' }, - { status: 403 } - ) + return NextResponse.json({ error: 'You are not registered for this event. Please register first.' }, { status: 403 }) } if (reg.status === 'pending') { - return NextResponse.json( - { error: 'Your registration is still pending approval. Please wait for the organizer to approve your registration before submitting a project.' }, - { status: 403 } - ) + return NextResponse.json({ error: 'Your registration is still pending approval. Please wait for the organizer to approve your registration before submitting a project.' }, { status: 403 }) } if (reg.status === 'rejected') { - return NextResponse.json( - { error: 'Your registration was not approved. You cannot submit a project.' }, - { status: 403 } - ) + return NextResponse.json({ error: 'Your registration was not approved. You cannot submit a project.' }, { status: 403 }) } if (reg.status !== 'approved') { - return NextResponse.json( - { error: 'Registration not approved.' }, - { status: 403 } - ) + return NextResponse.json({ error: 'Registration not approved.' }, { status: 403 }) } + const { data: teamMember } = await db + .from('team_members') + .select('team_id, teams!inner(event_id, status)') + .eq('user_id', user.userId) + .eq('teams.event_id', eventId) + .neq('teams.status', 'disbanded') + .maybeSingle() + const teamId = teamMember?.team_id ?? null + const body = await request.json() as { project_name: string github_url: string @@ -76,39 +69,42 @@ export async function POST( } const { project_name, github_url, description, demo_url } = body - const v = validateProjectInput({ name: project_name, github_url, description, demo_url }) if (!v.ok) return NextResponse.json({ error: 'Validation failed', details: v.errors }, { status: 400 }) - // 通过 registration_id 查找该用户在本活动的已有 project(幂等 key) const { data: existingProject } = await db .from('projects') - .select('id, name, github_url, status') + .select('id, name, github_url, status, team_id') .eq('event_id', eventId) .eq('registration_id', reg.id) .maybeSingle() if (existingProject) { - // UPDATE const updatePayload: Record = { name: v.sanitized.name, github_url: v.sanitized.github_url, description: v.sanitized.description, } - if (demo_url !== undefined) { - updatePayload['demo_url'] = v.sanitized.demo_url - } + if (demo_url !== undefined) updatePayload.demo_url = v.sanitized.demo_url const { data: updated, error } = await db .from('projects') .update(updatePayload) .eq('id', existingProject.id) - .select('id, name, github_url, status') + .select('id, name, github_url, status, team_id') .single() - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + const version = await recordSubmissionVersion(db, { + eventId, + projectId: updated.id, + registrationId: reg.id, + teamId: existingProject.team_id ?? teamId, + userId: user.userId, + body, + sanitized: v.sanitized, + }) return NextResponse.json({ id: updated.id, @@ -116,38 +112,85 @@ export async function POST( github_url: updated.github_url, status: updated.status, updated: true, + version, }) - } else { - // INSERT - const insertPayload: Record = { - event_id: eventId, - registration_id: reg.id, - name: v.sanitized.name, - team_name: reg.team_name, - github_url: v.sanitized.github_url, - description: v.sanitized.description, - status: 'pending', - } - if (demo_url !== undefined) { - insertPayload['demo_url'] = v.sanitized.demo_url - } + } - const { data: inserted, error } = await db - .from('projects') - .insert(insertPayload) - .select('id, name, github_url, status') - .single() + const insertPayload: Record = { + event_id: eventId, + registration_id: reg.id, + team_id: teamId, + name: v.sanitized.name, + team_name: reg.team_name, + github_url: v.sanitized.github_url, + description: v.sanitized.description, + status: 'pending', + } + if (demo_url !== undefined) insertPayload.demo_url = v.sanitized.demo_url - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + const { data: inserted, error } = await db + .from('projects') + .insert(insertPayload) + .select('id, name, github_url, status, team_id') + .single() - return NextResponse.json({ - id: inserted.id, - project_name: inserted.name, - github_url: inserted.github_url, - status: inserted.status, - updated: false, - }, { status: 200 }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + const version = await recordSubmissionVersion(db, { + eventId, + projectId: inserted.id, + registrationId: reg.id, + teamId, + userId: user.userId, + body, + sanitized: v.sanitized, + }) + + return NextResponse.json({ + id: inserted.id, + project_name: inserted.name, + github_url: inserted.github_url, + status: inserted.status, + updated: false, + version, + }, { status: 200 }) +} + +async function recordSubmissionVersion( + db: ReturnType, + input: { + eventId: string + projectId: string + registrationId: string + teamId: string | null + userId: string + body: Record + sanitized: ValidationResult['sanitized'] } +): Promise { + const { data: latest } = await db + .from('submissions') + .select('version') + .eq('project_id', input.projectId) + .order('version', { ascending: false }) + .limit(1) + .maybeSingle() + + const version = ((latest?.version as number | undefined) ?? 0) + 1 + const { error } = await db.from('submissions').insert({ + event_id: input.eventId, + project_id: input.projectId, + registration_id: input.registrationId, + team_id: input.teamId, + user_id: input.userId, + version, + name: input.sanitized.name, + github_url: input.sanitized.github_url, + demo_url: input.sanitized.demo_url, + description: input.sanitized.description, + payload: input.body, + }) + + if (error) throw new Error(error.message) + return version } diff --git a/lib/event-status.ts b/lib/event-status.ts new file mode 100644 index 0000000..45ff96b --- /dev/null +++ b/lib/event-status.ts @@ -0,0 +1,78 @@ +export const EVENT_STATUSES = [ + 'draft', + 'upcoming', + 'recruiting', + 'hacking', + 'open', + 'judging', + 'done', + 'cancelled', +] as const + +export type EventStatus = (typeof EVENT_STATUSES)[number] + +export const STATUS_TRANSITIONS: Record = { + draft: ['upcoming', 'recruiting', 'cancelled'], + upcoming: ['recruiting', 'cancelled'], + recruiting: ['hacking', 'open', 'cancelled'], + hacking: ['judging'], + open: ['judging'], + judging: ['done'], + done: [], + cancelled: [], +} + +export type EventTiming = { + start_time?: string | null + registration_deadline?: string | null + submission_deadline?: string | null + judging_end?: string | null + result_announced_at?: string | null +} + +export function isEventStatus(value: string): value is EventStatus { + return (EVENT_STATUSES as readonly string[]).includes(value) +} + +export function canTransitionEventStatus(from: string, to: string): boolean { + if (!isEventStatus(from) || !isEventStatus(to)) return false + return STATUS_TRANSITIONS[from].includes(to) +} + +export function isMergedOpenWindow(input: EventTiming): boolean { + if (!input.registration_deadline || !input.submission_deadline) return false + return new Date(input.registration_deadline).getTime() === new Date(input.submission_deadline).getTime() +} + +export function deriveEventStatus(input: EventTiming & { status?: string | null }, now = new Date()): EventStatus | null { + if (input.status === 'cancelled' || input.status === 'done') return input.status + + const start = input.start_time ? new Date(input.start_time) : null + const regDeadline = input.registration_deadline ? new Date(input.registration_deadline) : null + const submitDeadline = input.submission_deadline ? new Date(input.submission_deadline) : null + const judgingEnd = input.judging_end ? new Date(input.judging_end) : null + const resultAnnouncedAt = input.result_announced_at ? new Date(input.result_announced_at) : null + + if (judgingEnd && now >= judgingEnd) return 'done' + if (resultAnnouncedAt && now >= resultAnnouncedAt && input.status === 'judging') return 'done' + if (submitDeadline && now >= submitDeadline) return 'judging' + if (regDeadline && submitDeadline && regDeadline.getTime() === submitDeadline.getTime()) { + if (!start || now >= start) return 'open' + } + if (regDeadline && now >= regDeadline) return 'hacking' + if (start && now < start) return 'upcoming' + if (start && now >= start) return 'recruiting' + return null +} + +export function teamMutableStatus(status: string | null | undefined): boolean { + return status === 'recruiting' || status === 'hacking' +} + +export function submissionAllowedStatus(status: string | null | undefined): boolean { + return status === 'hacking' || status === 'open' +} + +export function registrationAllowedStatus(status: string | null | undefined): boolean { + return status === 'recruiting' +} diff --git a/lib/i18n/en.ts b/lib/i18n/en.ts index 0e5df9e..7897303 100644 --- a/lib/i18n/en.ts +++ b/lib/i18n/en.ts @@ -19,8 +19,10 @@ const en = { 'events.access.organizerOnly.body': 'The management page is only visible to the event organizer and reviewers. You can still view the public event page below.', 'events.access.viewPublic': 'View public event page', 'event.status.draft': 'Draft', + 'event.status.upcoming': 'Upcoming', 'event.status.recruiting': 'Applications Open', - 'event.status.hacking': 'In Progress', + 'event.status.hacking': 'Hacking', + 'event.status.open': 'Open', 'event.status.judging': 'Judging', 'event.status.done': 'Done', 'event.status.cancelled': 'Cancelled', @@ -629,8 +631,10 @@ const en = { 'vote.closed.banner': 'Voting has closed', 'vote.closed.bannerWithDate': 'Voting closed on {date}', 'pub.status.active': 'Active', + 'pub.status.upcoming': 'Upcoming', 'pub.status.recruiting': 'Applications Open', 'pub.status.hacking': 'Building', + 'pub.status.open': 'Open', 'pub.status.done': 'Ended', 'pub.status.judging': 'Judging', 'pub.status.cancelled': 'Cancelled', @@ -877,8 +881,10 @@ const en = { 'dashboard.report': 'Report', 'dashboard.status.draft': 'Draft', 'dashboard.status.inactive': 'Inactive', + 'dashboard.status.upcoming': 'Upcoming', 'dashboard.status.recruiting': 'Applications Open', - 'dashboard.status.hacking': 'In Progress', + 'dashboard.status.hacking': 'Hacking', + 'dashboard.status.open': 'Open', 'dashboard.status.judging': 'Judging', 'dashboard.status.done': 'Done', 'dashboard.status.cancelled': 'Cancelled', diff --git a/lib/i18n/zh.ts b/lib/i18n/zh.ts index 50001a8..4c6ebee 100644 --- a/lib/i18n/zh.ts +++ b/lib/i18n/zh.ts @@ -19,8 +19,10 @@ const zh = { 'events.access.organizerOnly.body': '管理页仅对活动主办方和评委可见,您可以查看下方公开的活动详情页。', 'events.access.viewPublic': '查看公开活动页', 'event.status.draft': '草稿', + 'event.status.upcoming': '即将开始', 'event.status.recruiting': '报名中', - 'event.status.hacking': '提交阶段', + 'event.status.hacking': '开发中', + 'event.status.open': '开放中', 'event.status.judging': '评审中', 'event.status.done': '已完成', 'event.status.cancelled': '已取消', @@ -629,8 +631,10 @@ const zh = { 'vote.closed.banner': '本次投票已截止', 'vote.closed.bannerWithDate': '本次投票已于 {date} 截止', 'pub.status.active': '进行中', + 'pub.status.upcoming': '即将开始', 'pub.status.recruiting': '报名中', - 'pub.status.hacking': '提交阶段', + 'pub.status.hacking': '开发中', + 'pub.status.open': '开放中', 'pub.status.done': '已结束', 'pub.status.judging': '评审中', 'pub.status.cancelled': '已取消', @@ -877,8 +881,10 @@ const zh = { 'dashboard.report': '报告', 'dashboard.status.draft': '草稿', 'dashboard.status.inactive': '未开放', + 'dashboard.status.upcoming': '即将开始', 'dashboard.status.recruiting': '报名中', - 'dashboard.status.hacking': '提交阶段', + 'dashboard.status.hacking': '开发中', + 'dashboard.status.open': '开放中', 'dashboard.status.judging': '评审中', 'dashboard.status.done': '已完成', 'dashboard.status.cancelled': '已取消', diff --git a/lib/mail.ts b/lib/mail.ts index 32cb420..4a2170d 100644 --- a/lib/mail.ts +++ b/lib/mail.ts @@ -111,6 +111,21 @@ export async function sendReviewerNotifyEmail( ) } +export async function sendEventCancelledEmail(email: string, eventName: string, reason?: string | null) { + await sendMail( + email, + `HackAgent 活动已取消:${eventName}`, + ` +
+

活动已取消

+

您报名的活动 ${eventName} 已被主办方取消。

+ ${reason ? `

取消原因:${reason}

` : ''} +

如有疑问,请联系活动主办方。

+
+ ` + ) +} + export async function sendWelcomeEmail(email: string) { await sendMail( email, diff --git a/supabase/migrations/018_status_v1_1.sql b/supabase/migrations/018_status_v1_1.sql index 61976ed..f66c5f4 100644 --- a/supabase/migrations/018_status_v1_1.sql +++ b/supabase/migrations/018_status_v1_1.sql @@ -1,13 +1,48 @@ -- 018_status_v1_1.sql --- OPE-100: Status Machine v1.1 — add cancelled state support --- State flow: draft → recruiting → hacking → judging → done --- ↘ cancelled (from any non-terminal) +-- OPE-100: Status Machine v1.1 +-- Public states: draft, upcoming, recruiting, hacking, open, judging, done +-- Back-office terminal state: cancelled -ALTER TABLE events ADD COLUMN IF NOT EXISTS judging_end TIMESTAMPTZ; -ALTER TABLE events ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ; -ALTER TABLE events ADD COLUMN IF NOT EXISTS cancelled_reason TEXT; -UPDATE events SET status = 'draft' WHERE status IS NULL AND deleted_at IS NULL; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS start_time TIMESTAMPTZ; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS judging_end TIMESTAMPTZ; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ; +ALTER TABLE public.events ADD COLUMN IF NOT EXISTS cancelled_reason TEXT; +UPDATE public.events SET status = 'recruiting' WHERE status = 'active'; +UPDATE public.events SET status = 'hacking' WHERE status = 'closed'; +UPDATE public.events SET status = 'judging' WHERE status = 'reviewing'; +UPDATE public.events SET status = 'draft' WHERE status IS NULL AND deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS public.submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + registration_id uuid REFERENCES public.registrations(id) ON DELETE SET NULL, + team_id uuid REFERENCES public.teams(id) ON DELETE SET NULL, + user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + version int NOT NULL, + name text NOT NULL, + github_url text NOT NULL, + demo_url text, + description text, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(project_id, version) +); + +CREATE INDEX IF NOT EXISTS submissions_event_id_idx ON public.submissions(event_id); +CREATE INDEX IF NOT EXISTS submissions_project_latest_idx ON public.submissions(project_id, version DESC); +CREATE UNIQUE INDEX IF NOT EXISTS submissions_team_version_uidx + ON public.submissions(team_id, version) + WHERE team_id IS NOT NULL; + +GRANT ALL ON public.submissions TO service_role; +GRANT SELECT, INSERT ON public.submissions TO authenticated; + +COMMENT ON COLUMN public.events.status IS + 'v1.1 statuses: draft | upcoming | recruiting | hacking | open | judging | done | cancelled'; +COMMENT ON COLUMN public.events.start_time IS 'Registration start; future start_time derives upcoming state'; COMMENT ON COLUMN public.events.judging_end IS 'When judging phase ends; used by cron to auto-transition judging→done'; COMMENT ON COLUMN public.events.cancelled_at IS 'Timestamp when the event was cancelled'; COMMENT ON COLUMN public.events.cancelled_reason IS 'Optional reason provided by the organizer for cancellation'; +COMMENT ON TABLE public.submissions IS 'Versioned project submissions; reviewers should use the highest version per project'; From e1fd20fcd1fa4620e03f1c883ed65d16ec054e88 Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sun, 3 May 2026 00:03:45 +0800 Subject: [PATCH 3/8] fix(OPE-100): close submission versioning gaps --- .../agent/events/[eventId]/submit/route.ts | 155 +++++++++--------- app/api/events/[eventId]/reviewers/route.ts | 94 +---------- app/api/reviewer-invite/accept/route.ts | 2 +- app/api/reviewer-invite/confirm/route.ts | 2 +- app/api/v1/events/[id]/publish/route.ts | 13 +- app/api/v1/events/[id]/submit/route.ts | 68 +++----- lib/reviewer-scores.ts | 91 ++++++++++ lib/submissions.ts | 49 ++++++ supabase/migrations/018_status_v1_1.sql | 2 +- supabase/migrations/027_admin_audit_log.sql | 2 +- .../migrations/032_ensure_submissions.sql | 53 ++++++ 11 files changed, 305 insertions(+), 226 deletions(-) create mode 100644 lib/reviewer-scores.ts create mode 100644 lib/submissions.ts create mode 100644 supabase/migrations/032_ensure_submissions.sql diff --git a/app/api/agent/events/[eventId]/submit/route.ts b/app/api/agent/events/[eventId]/submit/route.ts index 922e9a8..7e58381 100644 --- a/app/api/agent/events/[eventId]/submit/route.ts +++ b/app/api/agent/events/[eventId]/submit/route.ts @@ -2,38 +2,36 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { authenticateApiKey } from '@/lib/agent-auth' import { validateProjectInput } from '@/lib/validate-project' +import { submissionAllowedStatus } from '@/lib/event-status' +import { recordSubmissionVersion } from '@/lib/submissions' -// POST /api/agent/events/[eventId]/submit — submit a project for an event export async function POST( request: NextRequest, { params }: { params: Promise<{ eventId: string }> } ) { const auth = await authenticateApiKey(request) - if (!auth) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) - } + if (!auth) return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) const { eventId } = await params const db = createServiceClient() - // Verify event exists and check submission deadline const { data: event } = await db .from('events') - .select('id, submission_deadline') + .select('id, status, submission_deadline') .eq('id', eventId) .is('deleted_at', null) .single() - if (!event) { - return NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }) + if (!event) return NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }) + if (!submissionAllowedStatus(event.status)) { + return NextResponse.json({ success: false, error: 'Submissions are only accepted during hacking/open stages' }, { status: 403 }) } - const submissionDeadline = (event as Record)['submission_deadline'] as string | null + const submissionDeadline = event.submission_deadline as string | null if (submissionDeadline && new Date(submissionDeadline) < new Date()) { return NextResponse.json({ success: false, error: 'Submission deadline has passed' }, { status: 400 }) } - // Must have an approved registration const { data: reg } = await db .from('registrations') .select('id, status, team_name') @@ -41,30 +39,19 @@ export async function POST( .eq('user_id', auth.userId) .single() - if (!reg) { - return NextResponse.json( - { success: false, error: 'You are not registered for this event. Please register first.' }, - { status: 403 } - ) - } - if (reg.status === 'pending') { - return NextResponse.json( - { success: false, error: 'Your registration is still pending approval. Please wait for the organizer to approve your registration before submitting a project.' }, - { status: 403 } - ) - } - if (reg.status === 'rejected') { - return NextResponse.json( - { success: false, error: 'Your registration was not approved. You cannot submit a project.' }, - { status: 403 } - ) - } - if (reg.status !== 'approved') { - return NextResponse.json( - { success: false, error: 'Registration not approved.' }, - { status: 403 } - ) - } + if (!reg) return NextResponse.json({ success: false, error: 'You are not registered for this event. Please register first.' }, { status: 403 }) + if (reg.status === 'pending') return NextResponse.json({ success: false, error: 'Your registration is still pending approval. Please wait for the organizer to approve your registration before submitting a project.' }, { status: 403 }) + if (reg.status === 'rejected') return NextResponse.json({ success: false, error: 'Your registration was not approved. You cannot submit a project.' }, { status: 403 }) + if (reg.status !== 'approved') return NextResponse.json({ success: false, error: 'Registration not approved.' }, { status: 403 }) + + const { data: teamMember } = await db + .from('team_members') + .select('team_id, teams!inner(event_id, status)') + .eq('user_id', auth.userId) + .eq('teams.event_id', eventId) + .neq('teams.status', 'disbanded') + .maybeSingle() + const teamId = teamMember?.team_id ?? null const body = await request.json() as { project_name: string @@ -72,7 +59,6 @@ export async function POST( demo_url?: string description?: string track_ids?: string[] - team_id?: string } const v = validateProjectInput({ @@ -81,76 +67,93 @@ export async function POST( description: body.description, demo_url: body.demo_url, }) - if (!v.ok) { - return NextResponse.json({ success: false, error: 'Validation failed', details: v.errors }, { status: 400 }) - } + if (!v.ok) return NextResponse.json({ success: false, error: 'Validation failed', details: v.errors }, { status: 400 }) - const projectName = v.sanitized.name - - // Idempotency key: scope to this caller's registration, never a caller-supplied - // team_name. Looking up by (event_id, name) let one user overwrite another - // team's project by submitting a matching name. - const { data: existing } = await db - .from('projects') - .select('id, name, github_url, status') - .eq('event_id', eventId) - .eq('registration_id', reg.id) - .maybeSingle() + let existing: { id: string; name: string; github_url: string; status: string; team_id: string | null } | null = null + if (teamId) { + const { data } = await db + .from('projects') + .select('id, name, github_url, status, team_id') + .eq('event_id', eventId) + .eq('team_id', teamId) + .maybeSingle() + existing = data + } else { + const { data } = await db + .from('projects') + .select('id, name, github_url, status, team_id') + .eq('event_id', eventId) + .eq('registration_id', reg.id) + .maybeSingle() + existing = data + } if (existing) { - // Update existing project const updatePayload: Record = { - name: projectName, - github_url: body.github_url.trim(), - description: body.description ?? null, + name: v.sanitized.name, + github_url: v.sanitized.github_url, + description: v.sanitized.description, } - if (body.demo_url !== undefined) updatePayload['demo_url'] = body.demo_url - if (body.track_ids !== undefined) updatePayload['track_ids'] = Array.isArray(body.track_ids) ? body.track_ids.filter(Boolean) : [] - if (body.team_id !== undefined) updatePayload['team_id'] = body.team_id + if (body.demo_url !== undefined) updatePayload.demo_url = v.sanitized.demo_url + if (body.track_ids !== undefined) updatePayload.track_ids = Array.isArray(body.track_ids) ? body.track_ids.filter(Boolean) : [] const { data: updated, error } = await db .from('projects') .update(updatePayload) .eq('id', existing.id) - .select('id, name, github_url, status') + .select('id, name, github_url, status, team_id') .single() - if (error) { - return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - } + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) + const version = await recordSubmissionVersion(db, { + eventId, + projectId: updated.id, + registrationId: reg.id, + teamId: teamId ?? updated.team_id ?? null, + userId: auth.userId, + body, + sanitized: v.sanitized, + }) return NextResponse.json({ success: true, - data: { id: updated.id, project_name: updated.name, github_url: updated.github_url, status: updated.status, updated: true }, + data: { id: updated.id, project_name: updated.name, github_url: updated.github_url, status: updated.status, updated: true, version }, }) } - // Insert new project const insertPayload: Record = { event_id: eventId, registration_id: reg.id, - name: projectName, + team_id: teamId, + name: v.sanitized.name, team_name: reg.team_name, - github_url: body.github_url.trim(), - description: body.description ?? null, + github_url: v.sanitized.github_url, + description: v.sanitized.description, status: 'pending', } - if (body.demo_url !== undefined) insertPayload['demo_url'] = body.demo_url - if (body.track_ids !== undefined) insertPayload['track_ids'] = Array.isArray(body.track_ids) ? body.track_ids.filter(Boolean) : [] - if (body.team_id !== undefined) insertPayload['team_id'] = body.team_id + if (body.demo_url !== undefined) insertPayload.demo_url = v.sanitized.demo_url + if (body.track_ids !== undefined) insertPayload.track_ids = Array.isArray(body.track_ids) ? body.track_ids.filter(Boolean) : [] const { data: inserted, error } = await db .from('projects') .insert(insertPayload) - .select('id, name, github_url, status') + .select('id, name, github_url, status, team_id') .single() - if (error) { - return NextResponse.json({ success: false, error: error.message }, { status: 500 }) - } + if (error) return NextResponse.json({ success: false, error: error.message }, { status: 500 }) + + const version = await recordSubmissionVersion(db, { + eventId, + projectId: inserted.id, + registrationId: reg.id, + teamId, + userId: auth.userId, + body, + sanitized: v.sanitized, + }) - return NextResponse.json( - { success: true, data: { id: inserted.id, project_name: inserted.name, github_url: inserted.github_url, status: inserted.status, updated: false } }, - { status: 201 } - ) + return NextResponse.json({ + success: true, + data: { id: inserted.id, project_name: inserted.name, github_url: inserted.github_url, status: inserted.status, updated: false, version }, + }, { status: 201 }) } diff --git a/app/api/events/[eventId]/reviewers/route.ts b/app/api/events/[eventId]/reviewers/route.ts index d7b71fd..fee321b 100644 --- a/app/api/events/[eventId]/reviewers/route.ts +++ b/app/api/events/[eventId]/reviewers/route.ts @@ -3,99 +3,7 @@ import { createServiceClient } from '@/lib/supabase' import { getSessionUser } from '@/lib/session' import { sendReviewerInviteEmail, sendReviewerNotifyEmail } from '@/lib/mail' import { randomBytes } from 'crypto' - -type AiReview = { - model: string - score: number - dimensions?: Record - summary?: string | { zh?: string; en?: string } - error?: boolean -} - -type AnalysisResult = { - ai_reviews?: AiReview[] -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function initReviewerScores(db: any, eventId: string, userId: string): Promise { - try { - const { data: projects } = await db - .from('projects') - .select('id, analysis_result') - .eq('event_id', eventId) - - if (!projects?.length) return - - const { data: event } = await db - .from('events') - .select('models') - .eq('id', eventId) - .single() - - const models: string[] = (event?.models as string[] | null) ?? ['claude', 'minimax', 'gemini', 'gpt4o', 'deepseek', 'kimi', 'glm'] - - const { data: existing } = await db - .from('reviewer_scores') - .select('project_id, model') - .eq('event_id', eventId) - .eq('reviewer_id', userId) - - const existingSet = new Set() - for (const e of (existing ?? []) as Array<{ project_id: string; model: string }>) { - existingSet.add(`${e.project_id}:${e.model}`) - } - - const inserts: Array> = [] - for (const project of projects as Array<{ id: string; analysis_result: unknown }>) { - const ar = project.analysis_result as AnalysisResult | null - const aiReviews = ar?.ai_reviews ?? [] - - for (const model of models) { - if (existingSet.has(`${project.id}:${model}`)) continue - - const aiReview = aiReviews.find(r => r.model === model && !r.error && (r.score ?? 0) > 0) - - if (aiReview) { - const raw = aiReview.summary - const ai_comment = raw && typeof raw === 'object' - ? (raw.zh ?? raw.en ?? null) - : (raw ?? null) - - inserts.push({ - event_id: eventId, - project_id: project.id, - reviewer_id: userId, - model, - ai_dimension_scores: aiReview.dimensions ?? null, - ai_overall_score: aiReview.score, - ai_comment, - status: 'ai_done', - }) - } else { - inserts.push({ - event_id: eventId, - project_id: project.id, - reviewer_id: userId, - model, - ai_dimension_scores: null, - ai_overall_score: null, - ai_comment: null, - status: 'pending', - }) - } - } - } - - if (!inserts.length) return - - const BATCH = 50 - for (let i = 0; i < inserts.length; i += BATCH) { - await db.from('reviewer_scores').insert(inserts.slice(i, i + BATCH)) - } - } catch (err) { - console.error('[initReviewerScores] error:', err) - } -} +import { initReviewerScores } from '@/lib/reviewer-scores' // GET /api/events/[eventId]/reviewers - list reviewers with status export async function GET( diff --git a/app/api/reviewer-invite/accept/route.ts b/app/api/reviewer-invite/accept/route.ts index b6d2e2b..05f053f 100644 --- a/app/api/reviewer-invite/accept/route.ts +++ b/app/api/reviewer-invite/accept/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { hashPassword, createToken } from '@/lib/auth' import { cookies } from 'next/headers' -import { initReviewerScores } from '@/app/api/events/[eventId]/reviewers/route' +import { initReviewerScores } from '@/lib/reviewer-scores' // POST /api/reviewer-invite/accept — register via invite token // diff --git a/app/api/reviewer-invite/confirm/route.ts b/app/api/reviewer-invite/confirm/route.ts index ed71285..c4384f5 100644 --- a/app/api/reviewer-invite/confirm/route.ts +++ b/app/api/reviewer-invite/confirm/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getSessionUser } from '@/lib/session' -import { initReviewerScores } from '@/app/api/events/[eventId]/reviewers/route' +import { initReviewerScores } from '@/lib/reviewer-scores' // POST /api/reviewer-invite/confirm — finalize invite for an already-registered // user. Requires the caller to be logged in AND their session email to match diff --git a/app/api/v1/events/[id]/publish/route.ts b/app/api/v1/events/[id]/publish/route.ts index 86f43de..bb57510 100644 --- a/app/api/v1/events/[id]/publish/route.ts +++ b/app/api/v1/events/[id]/publish/route.ts @@ -21,7 +21,7 @@ export async function POST( const { data: event, error: fetchError } = await db .from('events') - .select('id, user_id, status, description, tracks, registration_deadline, submission_deadline') + .select('id, user_id, status, description, tracks, start_time, registration_deadline, submission_deadline, judging_end') .eq('id', id) .is('deleted_at', null) .single() @@ -71,9 +71,9 @@ export async function POST( if (event.submission_deadline) { const subDeadline = new Date(event.submission_deadline).getTime() - if (!(subDeadline > regDeadline)) { + if (!(subDeadline >= regDeadline)) { return NextResponse.json( - { error: 'EVENT_PUBLISH_DEADLINE_INVALID_ORDER', message: 'submission_deadline must be after registration_deadline' }, + { error: 'EVENT_PUBLISH_DEADLINE_INVALID_ORDER', message: 'submission_deadline must be at or after registration_deadline' }, { status: 400 } ) } @@ -86,12 +86,13 @@ export async function POST( .eq('id', id) .single() + const targetStatus = event.start_time && new Date(event.start_time) > new Date() ? 'upcoming' : 'recruiting' const prevConfig = (fullEvent?.registration_config ?? {}) as Record - const mergedConfig = { ...prevConfig, open: true } + const mergedConfig = { ...prevConfig, open: targetStatus === 'recruiting' } const { error: updateError } = await db .from('events') - .update({ status: 'recruiting', registration_config: mergedConfig }) + .update({ status: targetStatus, registration_config: mergedConfig }) .eq('id', id) .eq('status', 'draft') @@ -99,5 +100,5 @@ export async function POST( return NextResponse.json({ error: updateError.message }, { status: 500 }) } - return NextResponse.json({ id: event.id, status: 'recruiting' }) + return NextResponse.json({ id: event.id, status: targetStatus }) } diff --git a/app/api/v1/events/[id]/submit/route.ts b/app/api/v1/events/[id]/submit/route.ts index 965e930..07ead33 100644 --- a/app/api/v1/events/[id]/submit/route.ts +++ b/app/api/v1/events/[id]/submit/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getAgentUser } from '@/lib/agentAuth' -import { validateProjectInput, type ValidationResult } from '@/lib/validate-project' +import { validateProjectInput } from '@/lib/validate-project' import { submissionAllowedStatus } from '@/lib/event-status' +import { recordSubmissionVersion } from '@/lib/submissions' export async function POST( request: NextRequest, @@ -72,12 +73,24 @@ export async function POST( const v = validateProjectInput({ name: project_name, github_url, description, demo_url }) if (!v.ok) return NextResponse.json({ error: 'Validation failed', details: v.errors }, { status: 400 }) - const { data: existingProject } = await db - .from('projects') - .select('id, name, github_url, status, team_id') - .eq('event_id', eventId) - .eq('registration_id', reg.id) - .maybeSingle() + let existingProject: { id: string; name: string; github_url: string; status: string; team_id: string | null } | null = null + if (teamId) { + const { data } = await db + .from('projects') + .select('id, name, github_url, status, team_id') + .eq('event_id', eventId) + .eq('team_id', teamId) + .maybeSingle() + existingProject = data + } else { + const { data } = await db + .from('projects') + .select('id, name, github_url, status, team_id') + .eq('event_id', eventId) + .eq('registration_id', reg.id) + .maybeSingle() + existingProject = data + } if (existingProject) { const updatePayload: Record = { @@ -100,7 +113,7 @@ export async function POST( eventId, projectId: updated.id, registrationId: reg.id, - teamId: existingProject.team_id ?? teamId, + teamId: teamId ?? existingProject.team_id ?? null, userId: user.userId, body, sanitized: v.sanitized, @@ -155,42 +168,3 @@ export async function POST( version, }, { status: 200 }) } - -async function recordSubmissionVersion( - db: ReturnType, - input: { - eventId: string - projectId: string - registrationId: string - teamId: string | null - userId: string - body: Record - sanitized: ValidationResult['sanitized'] - } -): Promise { - const { data: latest } = await db - .from('submissions') - .select('version') - .eq('project_id', input.projectId) - .order('version', { ascending: false }) - .limit(1) - .maybeSingle() - - const version = ((latest?.version as number | undefined) ?? 0) + 1 - const { error } = await db.from('submissions').insert({ - event_id: input.eventId, - project_id: input.projectId, - registration_id: input.registrationId, - team_id: input.teamId, - user_id: input.userId, - version, - name: input.sanitized.name, - github_url: input.sanitized.github_url, - demo_url: input.sanitized.demo_url, - description: input.sanitized.description, - payload: input.body, - }) - - if (error) throw new Error(error.message) - return version -} diff --git a/lib/reviewer-scores.ts b/lib/reviewer-scores.ts new file mode 100644 index 0000000..6e5ff9c --- /dev/null +++ b/lib/reviewer-scores.ts @@ -0,0 +1,91 @@ +type AiReview = { + model: string + score: number + dimensions?: Record + summary?: string | { zh?: string; en?: string } + error?: boolean +} + +type AnalysisResult = { + ai_reviews?: AiReview[] +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function initReviewerScores(db: any, eventId: string, userId: string): Promise { + try { + const { data: projects } = await db + .from('projects') + .select('id, analysis_result') + .eq('event_id', eventId) + + if (!projects?.length) return + + const { data: event } = await db + .from('events') + .select('models') + .eq('id', eventId) + .single() + + const models: string[] = (event?.models as string[] | null) ?? ['claude', 'minimax', 'gemini', 'gpt4o', 'deepseek', 'kimi', 'glm'] + + const { data: existing } = await db + .from('reviewer_scores') + .select('project_id, model') + .eq('event_id', eventId) + .eq('reviewer_id', userId) + + const existingSet = new Set() + for (const e of (existing ?? []) as Array<{ project_id: string; model: string }>) { + existingSet.add(`${e.project_id}:${e.model}`) + } + + const inserts: Array> = [] + for (const project of projects as Array<{ id: string; analysis_result: unknown }>) { + const ar = project.analysis_result as AnalysisResult | null + const aiReviews = ar?.ai_reviews ?? [] + + for (const model of models) { + if (existingSet.has(`${project.id}:${model}`)) continue + + const aiReview = aiReviews.find(r => r.model === model && !r.error && (r.score ?? 0) > 0) + if (aiReview) { + const raw = aiReview.summary + const ai_comment = raw && typeof raw === 'object' + ? (raw.zh ?? raw.en ?? null) + : (raw ?? null) + + inserts.push({ + event_id: eventId, + project_id: project.id, + reviewer_id: userId, + model, + ai_dimension_scores: aiReview.dimensions ?? null, + ai_overall_score: aiReview.score, + ai_comment, + status: 'ai_done', + }) + } else { + inserts.push({ + event_id: eventId, + project_id: project.id, + reviewer_id: userId, + model, + ai_dimension_scores: null, + ai_overall_score: null, + ai_comment: null, + status: 'pending', + }) + } + } + } + + if (!inserts.length) return + + const BATCH = 50 + for (let i = 0; i < inserts.length; i += BATCH) { + await db.from('reviewer_scores').insert(inserts.slice(i, i + BATCH)) + } + } catch (err) { + console.error('[initReviewerScores] error:', err) + } +} diff --git a/lib/submissions.ts b/lib/submissions.ts new file mode 100644 index 0000000..e49af5f --- /dev/null +++ b/lib/submissions.ts @@ -0,0 +1,49 @@ +import { createServiceClient } from '@/lib/supabase' +import type { ValidationResult } from '@/lib/validate-project' + +type DbClient = ReturnType + +export type SubmissionInput = { + eventId: string + projectId: string + registrationId: string + teamId: string | null + userId: string + body: Record + sanitized: ValidationResult['sanitized'] +} + +export async function recordSubmissionVersion(db: DbClient, input: SubmissionInput): Promise { + let query = db + .from('submissions') + .select('version') + .eq('event_id', input.eventId) + .order('version', { ascending: false }) + .limit(1) + + if (input.teamId) { + query = query.eq('team_id', input.teamId) + } else { + query = query.eq('project_id', input.projectId) + } + + const { data: latest } = await query.maybeSingle() + const version = ((latest?.version as number | undefined) ?? 0) + 1 + + const { error } = await db.from('submissions').insert({ + event_id: input.eventId, + project_id: input.projectId, + registration_id: input.registrationId, + team_id: input.teamId, + user_id: input.userId, + version, + name: input.sanitized.name, + github_url: input.sanitized.github_url, + demo_url: input.sanitized.demo_url, + description: input.sanitized.description, + payload: input.body, + }) + + if (error) throw new Error(error.message) + return version +} diff --git a/supabase/migrations/018_status_v1_1.sql b/supabase/migrations/018_status_v1_1.sql index f66c5f4..b9f0bf5 100644 --- a/supabase/migrations/018_status_v1_1.sql +++ b/supabase/migrations/018_status_v1_1.sql @@ -45,4 +45,4 @@ COMMENT ON COLUMN public.events.start_time IS 'Registration start; future start_ COMMENT ON COLUMN public.events.judging_end IS 'When judging phase ends; used by cron to auto-transition judging→done'; COMMENT ON COLUMN public.events.cancelled_at IS 'Timestamp when the event was cancelled'; COMMENT ON COLUMN public.events.cancelled_reason IS 'Optional reason provided by the organizer for cancellation'; -COMMENT ON TABLE public.submissions IS 'Versioned project submissions; reviewers should use the highest version per project'; +COMMENT ON TABLE public.submissions IS 'Versioned project submissions; team submissions use the highest version per team, solo submissions use the highest version per project'; diff --git a/supabase/migrations/027_admin_audit_log.sql b/supabase/migrations/027_admin_audit_log.sql index 315ddeb..b4d9525 100644 --- a/supabase/migrations/027_admin_audit_log.sql +++ b/supabase/migrations/027_admin_audit_log.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS public.admin_audit_log ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - admin_user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE SET NULL, + admin_user_id uuid REFERENCES public.users(id) ON DELETE SET NULL, action text NOT NULL, target_type text NOT NULL, target_id text, diff --git a/supabase/migrations/032_ensure_submissions.sql b/supabase/migrations/032_ensure_submissions.sql new file mode 100644 index 0000000..9750974 --- /dev/null +++ b/supabase/migrations/032_ensure_submissions.sql @@ -0,0 +1,53 @@ +-- OPE-100 production guard: ensure versioned project submissions exist. +-- Some production databases missed 018_status_v1_1.sql, so keep this migration +-- idempotent and additive. + +CREATE TABLE IF NOT EXISTS public.submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + registration_id uuid REFERENCES public.registrations(id) ON DELETE SET NULL, + team_id uuid REFERENCES public.teams(id) ON DELETE SET NULL, + user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + version int NOT NULL, + name text NOT NULL, + github_url text NOT NULL, + demo_url text, + description text, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(project_id, version) +); + +ALTER TABLE public.submissions + ADD COLUMN IF NOT EXISTS event_id uuid REFERENCES public.events(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS registration_id uuid REFERENCES public.registrations(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS team_id uuid REFERENCES public.teams(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS user_id uuid REFERENCES public.users(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS version int, + ADD COLUMN IF NOT EXISTS name text, + ADD COLUMN IF NOT EXISTS github_url text, + ADD COLUMN IF NOT EXISTS demo_url text, + ADD COLUMN IF NOT EXISTS description text, + ADD COLUMN IF NOT EXISTS payload jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE public.submissions + ALTER COLUMN payload SET DEFAULT '{}'::jsonb, + ALTER COLUMN created_at SET DEFAULT now(); + +CREATE UNIQUE INDEX IF NOT EXISTS submissions_project_version_uidx + ON public.submissions(project_id, version); +CREATE INDEX IF NOT EXISTS submissions_event_id_idx ON public.submissions(event_id); +CREATE INDEX IF NOT EXISTS submissions_project_latest_idx ON public.submissions(project_id, version DESC); +CREATE INDEX IF NOT EXISTS submissions_team_latest_idx ON public.submissions(team_id, version DESC) + WHERE team_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS submissions_team_version_uidx + ON public.submissions(team_id, version) + WHERE team_id IS NOT NULL; + +GRANT ALL ON public.submissions TO service_role; +GRANT SELECT, INSERT ON public.submissions TO authenticated; + +COMMENT ON TABLE public.submissions IS 'Versioned project submissions; team submissions use the highest version per team, solo submissions use the highest version per project'; From 33fe6227e79ef327760c0193e412a1a73d6854d6 Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sun, 3 May 2026 01:01:13 +0800 Subject: [PATCH 4/8] fix(OPE-210): preserve future publish timing --- app/api/cron/transition-status/route.ts | 3 +- app/api/events/[eventId]/route.ts | 6 +- app/api/events/[eventId]/status/route.ts | 2 +- app/api/events/route.ts | 4 +- app/api/v1/events/[id]/publish/route.ts | 5 +- app/api/v1/events/[id]/route.ts | 6 +- app/api/v1/events/route.ts | 6 ++ lib/event-status.ts | 15 +++- package.json | 1 + scripts/test-ope210-upcoming-publish.mjs | 103 +++++++++++++++++++++++ 10 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 scripts/test-ope210-upcoming-publish.mjs diff --git a/app/api/cron/transition-status/route.ts b/app/api/cron/transition-status/route.ts index 9efbd4e..1db8072 100644 --- a/app/api/cron/transition-status/route.ts +++ b/app/api/cron/transition-status/route.ts @@ -5,6 +5,7 @@ import { canTransitionEventStatus, deriveEventStatus, type EventStatus } from '@ type EventRow = { id: string status: string + registration_open_at: string | null start_time: string | null registration_deadline: string | null submission_deadline: string | null @@ -28,7 +29,7 @@ export async function GET(request: NextRequest) { const { data: events, error } = await db .from('events') - .select('id, status, start_time, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') + .select('id, status, registration_open_at, start_time, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') .is('deleted_at', null) .not('status', 'in', '(done,cancelled)') diff --git a/app/api/events/[eventId]/route.ts b/app/api/events/[eventId]/route.ts index fd040a8..32903f2 100644 --- a/app/api/events/[eventId]/route.ts +++ b/app/api/events/[eventId]/route.ts @@ -99,7 +99,7 @@ export async function PATCH( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } - const { name, track, description, dimensions, models, web3_enabled, sonar_enabled, mode, column_mapping, tracks, banner_url, registration_deadline, submission_deadline, registration_config, status, current_reviewing, is_hidden } = body as { + const { name, track, description, dimensions, models, web3_enabled, sonar_enabled, mode, column_mapping, tracks, banner_url, registration_open_at, start_time, registration_deadline, submission_deadline, registration_config, status, current_reviewing, is_hidden } = body as { name?: string track?: string | null description?: string | null @@ -113,6 +113,8 @@ export async function PATCH( column_mapping?: Record tracks?: Array<{ id: string; name: string; description?: string; prize?: string }> | null banner_url?: string | null + registration_open_at?: string | null + start_time?: string | null registration_deadline?: string | null submission_deadline?: string | null registration_config?: { open: boolean; auto_approve: boolean; fields: unknown[] } | null @@ -135,6 +137,8 @@ export async function PATCH( if (column_mapping !== undefined) updateData.column_mapping = column_mapping if (tracks !== undefined) updateData.tracks = tracks if (banner_url !== undefined) updateData.banner_url = banner_url + if (registration_open_at !== undefined) updateData.registration_open_at = registration_open_at + if (start_time !== undefined) updateData.start_time = start_time if (registration_deadline !== undefined) updateData.registration_deadline = registration_deadline if (submission_deadline !== undefined) updateData.submission_deadline = submission_deadline if (registration_config !== undefined) updateData.registration_config = registration_config diff --git a/app/api/events/[eventId]/status/route.ts b/app/api/events/[eventId]/status/route.ts index 372672a..dadaa09 100644 --- a/app/api/events/[eventId]/status/route.ts +++ b/app/api/events/[eventId]/status/route.ts @@ -26,7 +26,7 @@ export async function POST( const { data: event } = await db .from('events') - .select('id, user_id, name, status, models, mode, start_time, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') + .select('id, user_id, name, status, models, mode, registration_open_at, start_time, registration_deadline, submission_deadline, judging_end, result_announced_at, registration_config') .eq('id', eventId) .single() diff --git a/app/api/events/route.ts b/app/api/events/route.ts index c0bf92b..2003f5b 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -54,7 +54,7 @@ export async function POST(request: NextRequest) { } const body = await request.json() - const { name, track, description, dimensions, models, web3_enabled, mode, tracks, banner_url, registration_deadline, submission_deadline, registration_config } = body + const { name, track, description, dimensions, models, web3_enabled, mode, tracks, banner_url, registration_open_at, start_time, registration_deadline, submission_deadline, registration_config } = body if (!name?.trim()) { return NextResponse.json({ error: '活动名称不能为空' }, { status: 400 }) @@ -86,6 +86,8 @@ export async function POST(request: NextRequest) { tracks: Array.isArray(tracks) ? tracks : [], status: 'draft', banner_url: banner_url || null, + registration_open_at: registration_open_at ?? null, + start_time: start_time ?? null, registration_deadline: registration_deadline ?? null, submission_deadline: submission_deadline ?? null, registration_config: registration_config ?? { open: false, auto_approve: false, fields: [] }, diff --git a/app/api/v1/events/[id]/publish/route.ts b/app/api/v1/events/[id]/publish/route.ts index bb57510..b0bde6d 100644 --- a/app/api/v1/events/[id]/publish/route.ts +++ b/app/api/v1/events/[id]/publish/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createServiceClient } from '@/lib/supabase' import { getAgentUser } from '@/lib/agentAuth' +import { derivePublishStatus } from '@/lib/event-status' // POST /api/v1/events/[id]/publish — organizer/admin agent 把 draft 活动发布为 recruiting(见 OPE-86 状态机) export async function POST( @@ -21,7 +22,7 @@ export async function POST( const { data: event, error: fetchError } = await db .from('events') - .select('id, user_id, status, description, tracks, start_time, registration_deadline, submission_deadline, judging_end') + .select('id, user_id, status, description, tracks, registration_open_at, start_time, registration_deadline, submission_deadline, judging_end') .eq('id', id) .is('deleted_at', null) .single() @@ -86,7 +87,7 @@ export async function POST( .eq('id', id) .single() - const targetStatus = event.start_time && new Date(event.start_time) > new Date() ? 'upcoming' : 'recruiting' + const targetStatus = derivePublishStatus(event) const prevConfig = (fullEvent?.registration_config ?? {}) as Record const mergedConfig = { ...prevConfig, open: targetStatus === 'recruiting' } diff --git a/app/api/v1/events/[id]/route.ts b/app/api/v1/events/[id]/route.ts index 52f00e3..ff38517 100644 --- a/app/api/v1/events/[id]/route.ts +++ b/app/api/v1/events/[id]/route.ts @@ -12,7 +12,7 @@ export async function GET( const { data: event, error } = await db .from('events') - .select('id, name, description, status, registration_config, tracks, registration_deadline, submission_deadline, result_announced_at, banner_url, public_vote') + .select('id, name, description, status, registration_config, tracks, registration_open_at, start_time, registration_deadline, submission_deadline, result_announced_at, banner_url, public_vote') .eq('id', id) .neq('status', 'draft') .is('deleted_at', null) @@ -29,6 +29,8 @@ const PATCH_ALLOWED_FIELDS = [ 'name', 'description', 'tracks', + 'registration_open_at', + 'start_time', 'registration_deadline', 'submission_deadline', 'registration_config', @@ -40,6 +42,8 @@ type PatchBody = Partial<{ name: string description: string | null tracks: Array<{ id?: string; name: string; description?: string; prize?: string }> + registration_open_at: string | null + start_time: string | null registration_deadline: string | null submission_deadline: string | null registration_config: { open?: boolean; auto_approve?: boolean; fields?: unknown[] } diff --git a/app/api/v1/events/route.ts b/app/api/v1/events/route.ts index 689c060..60063d8 100644 --- a/app/api/v1/events/route.ts +++ b/app/api/v1/events/route.ts @@ -41,7 +41,10 @@ export async function POST(request: NextRequest) { name: string description?: string tracks?: Array<{ name: string; description?: string; prize?: string }> + registration_open_at?: string | null + start_time?: string | null registration_deadline?: string + submission_deadline?: string | null } if (!body.name?.trim()) { @@ -61,7 +64,10 @@ export async function POST(request: NextRequest) { name: body.name.trim(), description: body.description?.trim() ?? null, tracks, + registration_open_at: body.registration_open_at ?? null, + start_time: body.start_time ?? null, registration_deadline: body.registration_deadline ?? null, + submission_deadline: body.submission_deadline ?? null, status: 'draft', mode: 'ai_only', web3_enabled: false, diff --git a/lib/event-status.ts b/lib/event-status.ts index 45ff96b..634bd63 100644 --- a/lib/event-status.ts +++ b/lib/event-status.ts @@ -23,6 +23,7 @@ export const STATUS_TRANSITIONS: Record = { } export type EventTiming = { + registration_open_at?: string | null start_time?: string | null registration_deadline?: string | null submission_deadline?: string | null @@ -47,7 +48,9 @@ export function isMergedOpenWindow(input: EventTiming): boolean { export function deriveEventStatus(input: EventTiming & { status?: string | null }, now = new Date()): EventStatus | null { if (input.status === 'cancelled' || input.status === 'done') return input.status + const registrationOpen = input.registration_open_at ? new Date(input.registration_open_at) : null const start = input.start_time ? new Date(input.start_time) : null + const openAt = registrationOpen ?? start const regDeadline = input.registration_deadline ? new Date(input.registration_deadline) : null const submitDeadline = input.submission_deadline ? new Date(input.submission_deadline) : null const judgingEnd = input.judging_end ? new Date(input.judging_end) : null @@ -60,11 +63,19 @@ export function deriveEventStatus(input: EventTiming & { status?: string | null if (!start || now >= start) return 'open' } if (regDeadline && now >= regDeadline) return 'hacking' - if (start && now < start) return 'upcoming' - if (start && now >= start) return 'recruiting' + if (openAt && now < openAt) return 'upcoming' + if (openAt && now >= openAt) return 'recruiting' return null } +export function derivePublishStatus(input: Pick, now = new Date()): Extract { + const registrationOpen = input.registration_open_at ? new Date(input.registration_open_at) : null + const start = input.start_time ? new Date(input.start_time) : null + const openAt = registrationOpen ?? start + + return openAt && openAt > now ? 'upcoming' : 'recruiting' +} + export function teamMutableStatus(status: string | null | undefined): boolean { return status === 'recruiting' || status === 'hacking' } diff --git a/package.json b/package.json index b080c7a..d23bb36 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit", "test:banner-feedback": "node scripts/test-banner-feedback.mjs", "test:event-cover-rules": "node scripts/test-event-cover-rules.mjs", + "test:ope210": "node scripts/test-ope210-upcoming-publish.mjs", "test:skill-md": "node scripts/test-skill-md.mjs", "validate:open-source": "node scripts/validate-open-source.mjs" }, diff --git a/scripts/test-ope210-upcoming-publish.mjs b/scripts/test-ope210-upcoming-publish.mjs new file mode 100644 index 0000000..8692cc6 --- /dev/null +++ b/scripts/test-ope210-upcoming-publish.mjs @@ -0,0 +1,103 @@ +import fs from 'node:fs' +import assert from 'node:assert/strict' +import vm from 'node:vm' +import { createRequire } from 'node:module' +import ts from 'typescript' + +const require = createRequire(import.meta.url) +const source = fs.readFileSync('lib/event-status.ts', 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText +const sandbox = { exports: {}, require } +vm.runInNewContext(compiled, sandbox, { filename: 'lib/event-status.ts' }) +const { deriveEventStatus, derivePublishStatus } = sandbox.exports + +const now = new Date('2026-05-02T00:00:00.000Z') + +assert.equal( + deriveEventStatus({ + status: 'draft', + registration_open_at: '2026-05-09T00:00:00.000Z', + registration_deadline: '2026-06-01T00:00:00.000Z', + submission_deadline: '2026-06-16T00:00:00.000Z', + }, now), + 'upcoming', + 'future registration_open_at should derive upcoming', +) + +assert.equal( + deriveEventStatus({ + status: 'draft', + start_time: '2026-05-09T00:00:00.000Z', + registration_deadline: '2026-06-01T00:00:00.000Z', + submission_deadline: '2026-06-16T00:00:00.000Z', + }, now), + 'upcoming', + 'future start_time should still derive upcoming as fallback', +) + +assert.equal( + deriveEventStatus({ + status: 'draft', + registration_open_at: '2026-05-01T00:00:00.000Z', + registration_deadline: '2026-06-01T00:00:00.000Z', + submission_deadline: '2026-06-16T00:00:00.000Z', + }, now), + 'recruiting', + 'past registration_open_at should derive recruiting', +) + +assert.equal( + derivePublishStatus({ + registration_open_at: '2026-05-09T00:00:00.000Z', + start_time: null, + }, now), + 'upcoming', + 'publish with future registration_open_at should enter upcoming', +) + +assert.equal( + derivePublishStatus({ + registration_open_at: null, + start_time: '2026-05-09T00:00:00.000Z', + }, now), + 'upcoming', + 'publish with future start_time should enter upcoming as fallback', +) + +assert.equal( + derivePublishStatus({ + registration_open_at: '2026-05-01T00:00:00.000Z', + start_time: null, + }, now), + 'recruiting', + 'publish with past registration_open_at should enter recruiting', +) + +const createRoute = fs.readFileSync('app/api/v1/events/route.ts', 'utf8') +assert.match(createRoute, /registration_open_at:\s*body\.registration_open_at \?\? null/) +assert.match(createRoute, /start_time:\s*body\.start_time \?\? null/) +assert.match(createRoute, /submission_deadline:\s*body\.submission_deadline \?\? null/) + +assert.equal( + derivePublishStatus({ registration_open_at: '2026-05-09T00:00:00.000Z' }, now), + 'upcoming', + 'publish should use future registration_open_at for upcoming', +) +assert.equal( + derivePublishStatus({ start_time: '2026-05-09T00:00:00.000Z' }, now), + 'upcoming', + 'publish should fall back to future start_time for upcoming', +) +assert.equal( + derivePublishStatus({ registration_open_at: '2026-05-01T00:00:00.000Z' }, now), + 'recruiting', + 'publish should derive recruiting after registration_open_at', +) + +const publishRoute = fs.readFileSync('app/api/v1/events/[id]/publish/route.ts', 'utf8') +assert.match(publishRoute, /select\('[^']*registration_open_at[^']*start_time[^']*'\)/) +assert.match(publishRoute, /derivePublishStatus\(event\)/) + +console.log('OPE-210 upcoming publish regression checks passed') From a7742858e9d3f3d96414f678fa3a1f4718bdb0fd Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sun, 3 May 2026 01:14:04 +0800 Subject: [PATCH 5/8] chore(OPE-214): redeploy cron secret config From 90970f4ccf5ff5027eed4fbcc9eb1a5a579e4888 Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sun, 3 May 2026 21:15:20 +0800 Subject: [PATCH 6/8] fix: clarify dashboard access for viewer accounts --- app/(dashboard)/dashboard/page.tsx | 51 ++++++++++++++++++++++++++++-- app/LandingClient.tsx | 26 +++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 78364dd..b7b5002 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,6 +1,9 @@ +import Link from 'next/link' +import { ArrowRight, ExternalLink, LockKeyhole } from 'lucide-react' import { redirect } from 'next/navigation' import { getSessionUser } from '@/lib/session' import { createServiceClient } from '@/lib/supabase-server' +import { getServerLocale } from '@/lib/i18n-server' import EventsPageClient, { type EventSummary } from '../events/EventsPageClient' // OPE-126: Force dynamic rendering — Vercel ISR can cache a no-session shell on cold start. @@ -24,11 +27,53 @@ export default async function DashboardPage() { : ['viewer'] const canManage = roles.includes('admin') || roles.includes('organizer') const isAdmin = roles.includes('admin') + const isReviewer = roles.includes('reviewer') + const locale = await getServerLocale() - // Pure viewers (no admin/organizer role) have no events to manage — - // redirect them to the public events page instead of showing an empty dashboard. + // Reviewers have a dashboard area, but their entry point is the review queue. + if (!canManage && isReviewer) { + redirect('/my-reviews') + } + + // Pure viewers have no management workspace. Do not silently redirect them + // to the public site: it looks like the dashboard is broken. Show a clear + // access page with the next available action instead. if (!canManage) { - redirect('/events/public') + const zh = locale === 'zh' + return ( +
+
+
+ +
+

+ {zh ? '需要组织方权限' : 'Organizer access required'} +

+

+ {zh ? '当前账号还不能访问控制台' : 'This account cannot access the dashboard yet'} +

+

+ {zh + ? '控制台用于创建和管理 Hackathon 活动,需要 organizer 或 admin 角色。你仍然可以浏览公开活动;如果需要发布活动,请联系 OpenBuild 开通组织方权限。' + : 'The dashboard is for creating and managing hackathon events, so it requires the organizer or admin role. You can still browse public events. Contact OpenBuild if you need organizer access.'} +

+
+ + {zh ? '浏览公开活动' : 'Browse public events'} + + + {zh ? '申请组织方权限' : 'Request organizer access'} + +
+
+
+ ) } // OPE-25: admin 看全部活动;非 admin 仅看自己名下 diff --git a/app/LandingClient.tsx b/app/LandingClient.tsx index 88b96e7..b564dcf 100644 --- a/app/LandingClient.tsx +++ b/app/LandingClient.tsx @@ -234,12 +234,27 @@ export default function LandingClient({ initialProjectsReviewed }: { initialProj const [locale, setLocale] = useLocale() const t = useT() const [loggedIn, setLoggedIn] = useState(false) + const [dashboardHref, setDashboardHref] = useState('/events/public') + const [dashboardLabel, setDashboardLabel] = useState<'dashboard' | 'reviews' | 'events'>('events') const [projectsReviewed, setProjectsReviewed] = useState(initialProjectsReviewed) useEffect(() => { fetch('/api/auth/me') .then(r => r.ok ? r.json() : null) - .then(data => setLoggedIn(!!data?.loggedIn)) + .then(data => { + setLoggedIn(!!data?.loggedIn) + const role: string[] = Array.isArray(data?.role) ? data.role : [] + if (role.includes('admin') || role.includes('organizer')) { + setDashboardHref('/dashboard') + setDashboardLabel('dashboard') + } else if (role.includes('reviewer')) { + setDashboardHref('/my-reviews') + setDashboardLabel('reviews') + } else { + setDashboardHref('/events/public') + setDashboardLabel('events') + } + }) .catch(() => {}) fetch('/api/public-stats') .then(r => r.ok ? r.json() : null) @@ -289,9 +304,14 @@ export default function LandingClient({ initialProjectsReviewed }: { initialProj {loggedIn ? ( - + ) : ( From 135ca89bded32b36a949678baf52ef1b5dcabf5b Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Sun, 3 May 2026 21:31:39 +0800 Subject: [PATCH 7/8] fix(OPE-225): route public header CTA by role --- components/PublicNavbar.tsx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/components/PublicNavbar.tsx b/components/PublicNavbar.tsx index 960a419..7dd53ce 100644 --- a/components/PublicNavbar.tsx +++ b/components/PublicNavbar.tsx @@ -10,13 +10,28 @@ export default function PublicNavbar() { const t = useT() const [locale, setLocale] = useLocale() const [loggedIn, setLoggedIn] = useState(false) + const [dashboardHref, setDashboardHref] = useState('/events/public') + const [dashboardLabel, setDashboardLabel] = useState<'dashboard' | 'reviews' | 'events'>('events') const [dark, setDark] = useState(false) const pathname = usePathname() useEffect(() => { fetch('/api/auth/me') .then(r => r.ok ? r.json() : null) - .then(data => setLoggedIn(!!data?.loggedIn)) + .then(data => { + setLoggedIn(!!data?.loggedIn) + const role: string[] = Array.isArray(data?.role) ? data.role : [] + if (role.includes('admin') || role.includes('organizer')) { + setDashboardHref('/dashboard') + setDashboardLabel('dashboard') + } else if (role.includes('reviewer')) { + setDashboardHref('/my-reviews') + setDashboardLabel('reviews') + } else { + setDashboardHref('/events/public') + setDashboardLabel('events') + } + }) .catch(() => {}) setDark(document.documentElement.classList.contains('dark')) }, []) @@ -138,9 +153,14 @@ export default function PublicNavbar() { {/* Auth */} {loggedIn ? ( - + ) : ( From 72db910e83702041ff3515e8c44feaf8ceaa7223 Mon Sep 17 00:00:00 2001 From: Ian Xu Date: Mon, 4 May 2026 23:20:30 +0800 Subject: [PATCH 8/8] fix: compact event banner management layout --- .../events/[id]/EventDetailClient.tsx | 164 +++++++++--------- 1 file changed, 83 insertions(+), 81 deletions(-) diff --git a/app/(dashboard)/events/[id]/EventDetailClient.tsx b/app/(dashboard)/events/[id]/EventDetailClient.tsx index be2db80..f822134 100644 --- a/app/(dashboard)/events/[id]/EventDetailClient.tsx +++ b/app/(dashboard)/events/[id]/EventDetailClient.tsx @@ -889,105 +889,107 @@ export default function EventDetailClient() { {/* Banner management */} - - - - - {t('event.banner.title')} - - - - {t('event.banner.empty')}

} - fallbackClassName="border border-dashed border-token" - /> - + +
- -