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.'}
+
+
+
+
+ )
}
// 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 ? (
-
+
- {locale === 'zh' ? '后台' : 'Dashboard'}
+ {dashboardLabel === 'dashboard'
+ ? (locale === 'zh' ? '后台' : 'Dashboard')
+ : dashboardLabel === 'reviews'
+ ? (locale === 'zh' ? '评审' : 'Reviews')
+ : (locale === 'zh' ? '活动' : 'Events')}
+
) : (
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 ? (
-
+
- {locale === 'zh' ? '后台' : 'Dashboard'}
+ {dashboardLabel === 'dashboard'
+ ? (locale === 'zh' ? '后台' : 'Dashboard')
+ : dashboardLabel === 'reviews'
+ ? (locale === 'zh' ? '评审' : 'Reviews')
+ : (locale === 'zh' ? '活动' : 'Events')}
+
) : (
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"
- />
-
+
+
-
- {t('event.banner.aiLabel')}
-
-