Compare commits
47 Commits
89ab5cc8e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2945a92193 | ||
|
|
9b56eb27fb | ||
|
|
160333c2f9 | ||
|
|
f7fdfdec9b | ||
|
|
a2c6baf718 | ||
|
|
c9dc1bfabd | ||
|
|
4e6904fa12 | ||
|
|
45b007334e | ||
|
|
6d2fa3369f | ||
|
|
6eccfc694e | ||
|
|
97ef3e59ac | ||
|
|
a66bd728cd | ||
|
|
64f88890f5 | ||
|
|
dcd85c9b13 | ||
|
|
3be1fcd24a | ||
|
|
d38fe7887a | ||
|
|
28ca7bb0a6 | ||
|
|
d89f67ba57 | ||
|
|
2c311bc65a | ||
|
|
85937ec942 | ||
|
|
81352d7bd2 | ||
|
|
8a4184d20f | ||
|
|
f8f2d77e3b | ||
|
|
696d7e9041 | ||
|
|
f61dcfa89a | ||
|
|
146691be00 | ||
|
|
e4f13aaed4 | ||
|
|
6e1dcc8cbf | ||
|
|
24c7c4bc6c | ||
|
|
8c6a59bad9 | ||
|
|
b66e2071f9 | ||
|
|
df0be6bb48 | ||
|
|
e9e072dda7 | ||
|
|
b0a0a71cfe | ||
|
|
61bf5a4032 | ||
|
|
26709e2c9b | ||
|
|
f3d3a21156 | ||
|
|
9e058e6ad7 | ||
|
|
16e0a08f16 | ||
|
|
c53ec23109 | ||
|
|
b1923cf0e1 | ||
|
|
b757aae551 | ||
|
|
c2afa58606 | ||
|
|
537de07245 | ||
|
|
a6fc697e4d | ||
|
|
8d4f0bac1e | ||
|
|
f2c8cc1e80 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -63,3 +63,8 @@ build-output.txt
|
|||||||
private/
|
private/
|
||||||
public/build-id.json
|
public/build-id.json
|
||||||
.remember/
|
.remember/
|
||||||
|
|
||||||
|
# Local tooling + session screenshots
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
|
/*.png
|
||||||
|
|||||||
@@ -0,0 +1,745 @@
|
|||||||
|
# Grand Final Judge-Doc Curation + Optional Uploads Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let admins curate which previously-submitted documents finale judges see, and make the "optional revised uploads" mode render correctly for finalists.
|
||||||
|
|
||||||
|
**Architecture:** Everything builds on the existing `final-documents.ts` service + `finalist` tRPC router + the LIVE_FINAL round's `configJson` (same pattern as the shipped `allowFinalistRevisedUploads` toggle). A new configJson key `reviewVisibleRequirementIds` filters the judge review payload; new status fields `hasRequired`/`allUploaded` drive optional-mode rendering in the finalist banner/panel. No schema migration.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 15 App Router, tRPC 11 + Zod, Prisma 6, Vitest 4 (sequential, real test DB), shadcn/ui (Card/Switch/Checkbox).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-09-finale-doc-curation-optional-uploads-design.md`
|
||||||
|
|
||||||
|
**Conventions for every task:** TypeScript strict, `type` over `interface`. Tests use the factories in `tests/helpers.ts` (`createTestProgram`, `createTestCompetition`, `createTestRound`, `createTestProject`, `createTestProjectRoundState`, `createTestUser`, `uid`) and clean up with `cleanupTestData(programId, userIds?)` in `afterAll`. Run a single file with `npx vitest run tests/unit/<file>.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `hasRequired` + `allUploaded` on `FinalDocumentStatus`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/services/final-documents.ts` (type at ~line 14, computation at ~line 95)
|
||||||
|
- Test: `tests/unit/final-documents.test.ts` (extend existing file)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
In `tests/unit/final-documents.test.ts`, first extend the `makeFinaleProgram` factory (top of file) with an `optionalRequirements` option — change the two `fileRequirement.create` calls to use it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function makeFinaleProgram(
|
||||||
|
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
|
||||||
|
) {
|
||||||
|
// ... existing body unchanged, except both requirement creates:
|
||||||
|
// isRequired: !opts.optionalRequirements
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add inside the existing `describe('getFinalDocumentStatusForProject', ...)` block:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
|
||||||
|
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
|
||||||
|
const before = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(before!.hasRequired).toBe(false)
|
||||||
|
expect(before!.allUploaded).toBe(false)
|
||||||
|
expect(before!.allRequiredUploaded).toBe(false)
|
||||||
|
|
||||||
|
for (const req of [reqPlan!, reqVideo!]) {
|
||||||
|
await prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
|
||||||
|
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
|
||||||
|
bucket: 'b', objectKey: uid('key'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const after = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(after!.hasRequired).toBe(false)
|
||||||
|
expect(after!.allUploaded).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
|
||||||
|
const { program, round, reqPlan } = await makeFinaleProgram()
|
||||||
|
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status!.hasRequired).toBe(true) // reqVideo still required
|
||||||
|
expect(status!.allUploaded).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status!.hasRequired).toBe(false)
|
||||||
|
expect(status!.allUploaded).toBe(false)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents.test.ts`
|
||||||
|
Expected: the 3 new tests FAIL with TypeScript/undefined errors on `hasRequired` / `allUploaded` (fields don't exist yet); all pre-existing tests still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
In `src/server/services/final-documents.ts`, extend the type (~line 14):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type FinalDocumentStatus = {
|
||||||
|
roundId: string
|
||||||
|
roundName: string
|
||||||
|
deadline: Date | null
|
||||||
|
deadlinePassed: boolean
|
||||||
|
requirements: FinalDocRequirement[]
|
||||||
|
allRequiredUploaded: boolean
|
||||||
|
hasRequired: boolean // any slot is marked required
|
||||||
|
allUploaded: boolean // every listed slot has a file (false when no slots exist)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `getFinalDocumentStatusForProject` (~line 95), replace the return-value computation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const required = reqStatuses.filter((r) => r.isRequired)
|
||||||
|
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
|
||||||
|
const hasRequired = required.length > 0
|
||||||
|
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
|
||||||
|
const deadline = round.windowCloseAt ?? null
|
||||||
|
return {
|
||||||
|
roundId: round.id,
|
||||||
|
roundName: round.name,
|
||||||
|
deadline,
|
||||||
|
deadlinePassed: deadline ? new Date() > deadline : false,
|
||||||
|
requirements: reqStatuses,
|
||||||
|
allRequiredUploaded,
|
||||||
|
hasRequired,
|
||||||
|
allUploaded,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents.test.ts`
|
||||||
|
Expected: ALL tests pass (new + pre-existing).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts
|
||||||
|
git commit -m "feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Curation filter in `listFinalistDocumentsForReview`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/services/final-documents.ts` (`listFinalistDocumentsForReview`, ~line 257; new helper next to `finalistUploadsEnabled` ~line 45)
|
||||||
|
- Test: Create `tests/unit/final-documents-curation.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/unit/final-documents-curation.test.ts`. The review service presigns every file via MinIO, so mock `getPresignedUrl` (partial module mock — keeps `BUCKET_NAME` etc. real):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect, afterAll, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/minio', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/minio')>()
|
||||||
|
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { prisma } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
createTestProjectRoundState,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { listFinalistDocumentsForReview } from '@/server/services/final-documents'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of programIds) await cleanupTestData(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One finalist team with 4 files:
|
||||||
|
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
|
||||||
|
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
|
||||||
|
* - loose.pdf (prior SUBMISSION round, NO requirement)
|
||||||
|
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
|
||||||
|
*/
|
||||||
|
async function setupCuration() {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
programIds.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
|
||||||
|
const reqBP = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
|
||||||
|
})
|
||||||
|
const reqDeck = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
|
||||||
|
})
|
||||||
|
const finale = await createTestRound(comp.id, {
|
||||||
|
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
|
||||||
|
windowCloseAt: new Date(Date.now() + 86_400_000),
|
||||||
|
configJson: { allowFinalistRevisedUploads: true },
|
||||||
|
})
|
||||||
|
const reqFinal = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
|
||||||
|
})
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, finale.id)
|
||||||
|
|
||||||
|
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
|
||||||
|
prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
id: uid('file'), projectId: project.id, roundId, requirementId,
|
||||||
|
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
|
||||||
|
bucket: 'b', objectKey: uid('key'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
|
||||||
|
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
|
||||||
|
await mkFile(priorRound.id, null, 'loose.pdf')
|
||||||
|
await mkFile(finale.id, reqFinal.id, 'final.mp4')
|
||||||
|
|
||||||
|
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSelection(roundId: string, ids: string[] | null) {
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
|
||||||
|
const cfg = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
if (ids === null) delete cfg.reviewVisibleRequirementIds
|
||||||
|
else cfg.reviewVisibleRequirementIds = ids
|
||||||
|
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('listFinalistDocumentsForReview curation', () => {
|
||||||
|
it('no selection key → all files visible (current behavior)', async () => {
|
||||||
|
const { program } = await setupCuration()
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
expect(result.teams).toHaveLength(1)
|
||||||
|
expect(result.teams[0].files).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selection → only matching prior files, finale uploads always visible', async () => {
|
||||||
|
const { program, finale, reqBP } = await setupCuration()
|
||||||
|
await setSelection(finale.id, [reqBP.id])
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
const names = result.teams[0].files.map((f) => f.fileName).sort()
|
||||||
|
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty selection → only finale uploads visible', async () => {
|
||||||
|
const { program, finale } = await setupCuration()
|
||||||
|
await setSelection(finale.id, [])
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prior file without a requirement is excluded under any selection', async () => {
|
||||||
|
const { program, finale, reqBP, reqDeck } = await setupCuration()
|
||||||
|
await setSelection(finale.id, [reqBP.id, reqDeck.id])
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||||
|
Expected: the first test PASSES (current behavior), the other three FAIL (filter not implemented — they see all 4 files).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
In `src/server/services/final-documents.ts`, add a config reader next to `finalistUploadsEnabled` (~line 45):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Which prior-round FileRequirement ids are visible to finale judges.
|
||||||
|
* null = no curation (show all prior files). Empty array = hide all prior
|
||||||
|
* files (Grand Final round uploads are always shown regardless).
|
||||||
|
*/
|
||||||
|
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
|
||||||
|
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `listFinalistDocumentsForReview`:
|
||||||
|
1. After the `if (!round) return ...` guard, read the selection: `const visibleIds = reviewVisibleRequirementIds(round.configJson)`
|
||||||
|
2. Add `requirementId: true` to the `allFiles` select.
|
||||||
|
3. At the top of the `for (const f of allFiles)` loop, before building `rf`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const isFinaleUpload = f.roundId === round.id
|
||||||
|
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
|
||||||
|
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Use the `isFinaleUpload` const in the `rf` object (replacing the inline `f.roundId === round.id`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||||
|
Expected: all 4 PASS.
|
||||||
|
|
||||||
|
Also run the neighbors to catch regressions: `npx vitest run tests/unit/final-documents.test.ts`
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
|
||||||
|
git commit -m "feat(final-docs): filter judge review by reviewVisibleRequirementIds (finale uploads always shown)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Picker options helper `listReviewVisibilityOptions`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/services/final-documents.ts` (new exported function + type, after `listFinalistDocumentsForReview`)
|
||||||
|
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to `tests/unit/final-documents-curation.test.ts` (import `listReviewVisibilityOptions` from the same service module):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('listReviewVisibilityOptions', () => {
|
||||||
|
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
|
||||||
|
const { program, reqBP, reqDeck } = await setupCuration()
|
||||||
|
const options = await listReviewVisibilityOptions(prisma, program.id)
|
||||||
|
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
|
||||||
|
const bp = options.find((o) => o.requirementId === reqBP.id)!
|
||||||
|
expect(bp.name).toBe('Business Plan')
|
||||||
|
expect(bp.fileCount).toBe(1)
|
||||||
|
expect(bp.roundName).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns [] when there is no open finale round', async () => {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
programIds.push(program.id)
|
||||||
|
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||||
|
Expected: FAIL — `listReviewVisibilityOptions` is not exported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
In `src/server/services/final-documents.ts`, after `listFinalistDocumentsForReview`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ReviewDocSlot = {
|
||||||
|
requirementId: string
|
||||||
|
name: string
|
||||||
|
roundName: string
|
||||||
|
roundSort: number
|
||||||
|
fileCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct prior-round document slots (FileRequirements) that the finalist
|
||||||
|
* teams have files for — the options offered in the admin "documents shown to
|
||||||
|
* judges" picker. Excludes the finale round's own slots (those uploads are
|
||||||
|
* always visible to judges) and files without a requirement.
|
||||||
|
*/
|
||||||
|
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
|
||||||
|
const round = await getOpenFinaleRound(prisma, programId)
|
||||||
|
if (!round) return []
|
||||||
|
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
|
||||||
|
const files = await prisma.projectFile.findMany({
|
||||||
|
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
|
||||||
|
select: {
|
||||||
|
requirementId: true,
|
||||||
|
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const slots = new Map<string, ReviewDocSlot>()
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.requirementId || !f.requirement) continue
|
||||||
|
const existing = slots.get(f.requirementId)
|
||||||
|
if (existing) existing.fileCount++
|
||||||
|
else slots.set(f.requirementId, {
|
||||||
|
requirementId: f.requirementId,
|
||||||
|
name: f.requirement.name.trim(),
|
||||||
|
roundName: f.requirement.round.name,
|
||||||
|
roundSort: f.requirement.round.sortOrder,
|
||||||
|
fileCount: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
|
||||||
|
git commit -m "feat(final-docs): listReviewVisibilityOptions — distinct prior-round doc slots for the curation picker"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: tRPC procedures `getReviewDocSettings` / `setReviewVisibleRequirements`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/finalist.ts` (add two procedures next to `getRevisedUploadSetting`/`setRevisedUploadSetting`, ~line 1695; extend the existing `@/server/services/final-documents` import with `listReviewVisibilityOptions` and `reviewVisibleRequirementIds`)
|
||||||
|
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to `tests/unit/final-documents-curation.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as finalistRouter from '@/server/routers/finalist'
|
||||||
|
import { createCaller } from '../setup'
|
||||||
|
import { createTestUser } from '../helpers' // merge into the existing helpers import
|
||||||
|
|
||||||
|
describe('finalist review-doc settings procedures', () => {
|
||||||
|
const userIds: string[] = []
|
||||||
|
afterAll(async () => {
|
||||||
|
// cleanupTestData of programIds already runs in the file-level afterAll;
|
||||||
|
// pass userIds through an extra cleanup for the admin users:
|
||||||
|
for (const id of programIds) await cleanupTestData(id, userIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips a selection and preserves sibling configJson keys', async () => {
|
||||||
|
const { program, finale, reqBP } = await setupCuration()
|
||||||
|
const admin = await createTestUser('PROGRAM_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const caller = createCaller(finalistRouter.finalistRouter, admin)
|
||||||
|
|
||||||
|
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||||
|
expect(initial.selectedIds).toBeNull()
|
||||||
|
expect(initial.options.length).toBe(2)
|
||||||
|
|
||||||
|
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
|
||||||
|
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||||
|
expect(curated.selectedIds).toEqual([reqBP.id])
|
||||||
|
|
||||||
|
// sibling key from setupCuration must survive
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
|
||||||
|
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
|
||||||
|
|
||||||
|
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
|
||||||
|
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||||
|
expect(cleared.selectedIds).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-LIVE_FINAL round', async () => {
|
||||||
|
const { program, priorRound } = await setupCuration()
|
||||||
|
const admin = await createTestUser('PROGRAM_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const caller = createCaller(finalistRouter.finalistRouter, admin)
|
||||||
|
await expect(
|
||||||
|
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
|
||||||
|
).rejects.toThrow()
|
||||||
|
void program
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adjust the top-of-file `../helpers` import to include `createTestUser` rather than re-importing.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||||
|
Expected: FAIL — `getReviewDocSettings` does not exist on the router.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement**
|
||||||
|
|
||||||
|
In `src/server/routers/finalist.ts`, extend the existing service import with `listReviewVisibilityOptions, reviewVisibleRequirementIds`, then add after `setRevisedUploadSetting`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Options + current selection for the "documents shown to judges" picker. */
|
||||||
|
getReviewDocSettings: adminProcedure
|
||||||
|
.input(z.object({ programId: z.string(), roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
|
||||||
|
return {
|
||||||
|
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
|
||||||
|
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
|
||||||
|
setReviewVisibleRequirements: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
|
||||||
|
if (!round || round.roundType !== 'LIVE_FINAL') {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
|
||||||
|
}
|
||||||
|
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
|
||||||
|
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next } })
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FINALIST_REVIEW_DOCS_CURATED',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: { requirementIds: input.requirementIds },
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `data: { configJson: next }` may need `next as Prisma.InputJsonValue` depending on inference — match how the file already imports/uses Prisma types if the typechecker complains.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
|
||||||
|
Expected: all PASS. Then `npm run typecheck` — clean.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/server/routers/finalist.ts tests/unit/final-documents-curation.test.ts
|
||||||
|
git commit -m "feat(final-docs): admin procedures to read/set judge-visible document curation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Optional-mode rendering — banner + panel
|
||||||
|
|
||||||
|
No component-test infrastructure exists in this repo (vitest covers server code only) — verify via typecheck + the manual smoke in Task 7.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/applicant/final-documents-banner.tsx`
|
||||||
|
- Modify: `src/components/applicant/final-documents-panel.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the banner**
|
||||||
|
|
||||||
|
In `final-documents-banner.tsx`:
|
||||||
|
|
||||||
|
1. Guard (line 11): `if (!status || status.requirements.length === 0) return null`
|
||||||
|
2. Replace the `done` computation (line 18) and add the mode flag:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const optionalMode = !status.hasRequired
|
||||||
|
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Replace the title span (lines 26-28):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="font-semibold">
|
||||||
|
{done
|
||||||
|
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
|
||||||
|
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else (styling, checklist, deadline, button gated on `!done`) stays as is.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the panel**
|
||||||
|
|
||||||
|
In `final-documents-panel.tsx`:
|
||||||
|
|
||||||
|
1. Guard (line 21): `if (!status || status.requirements.length === 0) return null`
|
||||||
|
2. Add after the guard: `const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded`
|
||||||
|
3. Replace both `status.allRequiredUploaded` usages (badge at line 29, team upload-button gate at line 51) with `done`.
|
||||||
|
4. Badge label: `{status.hasRequired ? 'Submitted' : 'Uploaded'}`
|
||||||
|
5. Description (line 38) — append the optional hint:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CardDescription>
|
||||||
|
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
|
||||||
|
{!status.hasRequired && ' These uploads are optional.'}
|
||||||
|
</CardDescription>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: clean. (The tRPC client types pick up `hasRequired`/`allUploaded` from Task 1 automatically.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/applicant/final-documents-banner.tsx src/components/applicant/final-documents-panel.tsx
|
||||||
|
git commit -m "feat(final-docs): optional-mode rendering for finalist banner + panel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Admin "Documents shown to judges" card
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/admin/grand-finale/review-docs-picker.tsx`
|
||||||
|
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (import near line 100; render near line 1535)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the picker component**
|
||||||
|
|
||||||
|
`src/components/admin/grand-finale/review-docs-picker.tsx` (full file):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin picker: which previously-submitted documents finale judges see on the
|
||||||
|
* review page. Default (switch off) shows everything; switching to curated
|
||||||
|
* mode starts with all slots ticked, and the admin unticks what to hide.
|
||||||
|
* Grand Final round uploads are always visible regardless.
|
||||||
|
*/
|
||||||
|
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
|
||||||
|
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
|
||||||
|
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
if (!data || data.options.length === 0) return null
|
||||||
|
|
||||||
|
const curated = data.selectedIds !== null
|
||||||
|
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
|
||||||
|
const toggleSlot = (id: string, on: boolean) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
if (on) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
set.mutate({ roundId, requirementIds: [...next] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Eye className="h-5 w-5" /> Documents shown to judges
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose which previously submitted documents judges see on the finalist review page.
|
||||||
|
Documents uploaded directly to this Grand Final round are always visible.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="curate-review-docs"
|
||||||
|
checked={curated}
|
||||||
|
disabled={set.isPending}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
|
||||||
|
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{curated && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.options.map((o) => (
|
||||||
|
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(o.requirementId)}
|
||||||
|
disabled={set.isPending}
|
||||||
|
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
|
||||||
|
/>
|
||||||
|
<span>{o.name} — {o.roundName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire into the round admin page**
|
||||||
|
|
||||||
|
In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`:
|
||||||
|
|
||||||
|
Import (next to the other grand-finale imports, ~line 100):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
|
||||||
|
```
|
||||||
|
|
||||||
|
Render inside the existing `isGrandFinale && programId` block, directly after the flex row containing `<FinalDocsUploadsToggle …>` (the `</div>` around line 1545):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ReviewDocsPicker programId={programId} roundId={roundId} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/admin/grand-finale/review-docs-picker.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
|
||||||
|
git commit -m "feat(final-docs): admin card to curate documents shown to finale judges"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Full verification
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: all tests pass (was 321 before this work; now more).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint + typecheck + build**
|
||||||
|
|
||||||
|
Run: `npm run lint && npm run typecheck && npm run build`
|
||||||
|
Expected: all clean. (CLAUDE.md: always build before push.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (dev server + Playwright or browser)**
|
||||||
|
|
||||||
|
1. As admin, open the Grand Final round page → the "Documents shown to judges" card lists the prior-round slots with counts; flip to curated, untick one slot.
|
||||||
|
2. Open `/admin/finals-documents` → the unticked document type disappears from every team; any Grand Final uploads remain.
|
||||||
|
3. Flip the curation switch off → all documents reappear.
|
||||||
|
4. With `allowFinalistRevisedUploads` ON and all finale slots optional (set in dev data), check the applicant dashboard banner shows "Upload updated Grand Final documents (optional)" and turns green only when every slot is filled.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit anything outstanding**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short # should be clean except untracked screenshots/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed at planning time)
|
||||||
|
|
||||||
|
- **Spec coverage:** configJson key + semantics → Tasks 2/4; always-visible finale uploads → Task 2; requirement-less files excluded under selection → Task 2; picker options with counts → Task 3; admin UI next to toggle → Task 6; `hasRequired`/`allUploaded` + zero-slot edge → Tasks 1/5; sibling-key preservation + audit → Task 4; reminders unchanged → verified in spec, no task needed.
|
||||||
|
- **Placeholder scan:** none.
|
||||||
|
- **Type consistency:** `ReviewDocSlot`, `reviewVisibleRequirementIds(configJson)`, `getReviewDocSettings`/`setReviewVisibleRequirements`, `hasRequired`/`allUploaded` used consistently across tasks.
|
||||||
1646
docs/superpowers/plans/2026-06-09-grand-final-documents.md
Normal file
1646
docs/superpowers/plans/2026-06-09-grand-final-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
592
docs/superpowers/plans/2026-06-10-grand-finale-ceremony.md
Normal file
592
docs/superpowers/plans/2026-06-10-grand-finale-ceremony.md
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
# Grand Finale Ceremony System Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ship the full Option-C grand-finale ceremony system (admin-driven presentation phases with real timers + overtime log, audience QR favorite-voting with per-category windows, persisted juror notes/comments, deliberation completion, big-screen ceremony view with cinematic results reveal) before the 2026-06-11 event.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing `LiveProgressCursor` with a per-project phase state machine and server-stamped timers; extend `LiveVotingSession` with audience-window state and a new `AudienceFavoriteVote` pick-one model; big screen is a pure derivation of existing state plus a small `RevealState` controller. No new session-level phase machine.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 15 App Router, tRPC 11, Prisma 6/PostgreSQL, Tailwind 4 + shadcn/ui, `motion` v11 (already installed) for reveal animations, `qrcode.react` (new tiny dep), Vitest 4.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md`
|
||||||
|
|
||||||
|
**Critical context for the implementer:**
|
||||||
|
- Two parallel live systems exist: `live.ts` (LiveProgressCursor, cohort-based votes) and `live-voting.ts` (LiveVotingSession, jury criteria votes + audience tokens). The finale uses **cursor for presentation flow** and **LiveVotingSession for all voting**. The cohort-based `castVote`/`castStageVote` in `live.ts` are NOT used for the finale — leave them alone.
|
||||||
|
- Source of truth for presentation order: `round.configJson.projectOrder` (managed by `live.start`/`live.reorder`).
|
||||||
|
- Known bug: jury live page passes `params.roundId` as `sessionId` to `getSessionForVoting` → NOT_FOUND. Fixed in Task 7.
|
||||||
|
- Known bug: deliberation jury page has `juryMemberId: ''` and `hasVoted = false` hardcoded. Fixed in Task 10.
|
||||||
|
- `LiveVotingSession.roundId` is `@unique`, so by-round lookup is safe.
|
||||||
|
- Project category field is `competitionCategory` (`STARTUP | BUSINESS_CONCEPT`), nullable.
|
||||||
|
- **NEVER** run `prisma migrate dev` if `migrate status` shows drift (memory rule) — use the create-only + `db execute` + `migrate resolve` path in Task 2.
|
||||||
|
- Run tests with `npx vitest run tests/unit/<file>` (sequential forks pool). Build check: `npm run build`. Always build before push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Public paths for audience + ceremony routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/auth.config.ts:52-65`
|
||||||
|
- Test: `tests/unit/auth-public-paths.test.ts` (extend existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the existing public-paths test** — read `tests/unit/auth-public-paths.test.ts` first and follow its existing assertion style; add cases asserting `/vote/competition/abc`, `/vote/xyz`, `/live-scores/xyz`, `/live/ceremony/abc` are public and that `/live` alone (jury route prefix is `/jury/...` so no conflict) does not accidentally open admin routes (assert `/admin` still private).
|
||||||
|
- [ ] **Step 2: Run** `npx vitest run tests/unit/auth-public-paths.test.ts` — expect new cases FAIL.
|
||||||
|
- [ ] **Step 3: Implement** — in `src/lib/auth.config.ts` add to `publicPaths`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'/vote', // audience QR voting (token-based, no account)
|
||||||
|
'/live-scores', // public live scoreboard
|
||||||
|
'/live/ceremony', // big-screen ceremony view (projector)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test again** — expect PASS. Also `curl -sI http://localhost:3000/vote/competition/x | head -3` (dev server) → must NOT be a redirect to /login.
|
||||||
|
- [ ] **Step 5: Commit** `fix(auth): make audience vote, live-scores and ceremony routes public`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Schema migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `prisma/schema.prisma` (LiveProgressCursor ~2152, LiveVotingSession ~1165, LiveVote ~1202, AudienceVoter ~1230, Round, Project, User back-relations)
|
||||||
|
- Create: `prisma/migrations/<ts>_grand_finale_ceremony/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add enums** (near other enums):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum LivePhase {
|
||||||
|
ON_DECK
|
||||||
|
PRESENTING
|
||||||
|
QA
|
||||||
|
SCORING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AudiencePhase {
|
||||||
|
CLOSED
|
||||||
|
OPEN
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `LiveProgressCursor`:**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
projectPhase LivePhase @default(ON_DECK)
|
||||||
|
phaseStartedAt DateTime?
|
||||||
|
phaseDurationSeconds Int?
|
||||||
|
phasePausedAt DateTime?
|
||||||
|
phasePausedAccumMs Int @default(0)
|
||||||
|
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
|
||||||
|
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `LiveVotingSession`:**
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// Audience favorite-vote window (grand finale)
|
||||||
|
audiencePhase AudiencePhase @default(CLOSED)
|
||||||
|
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||||
|
audienceWindowOpenedAt DateTime?
|
||||||
|
audienceWindowClosesAt DateTime?
|
||||||
|
allowOverallFavorite Boolean @default(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
and relations `favoriteVotes AudienceFavoriteVote[]`, `revealState RevealState?`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Extend `LiveVote`** with `comment String? @db.Text`, and `AudienceVoter` with `favoriteVotes AudienceFavoriteVote[]`.
|
||||||
|
- [ ] **Step 5: New models** (after AudienceVoter):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model AudienceFavoriteVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
windowKey String // matches LiveVotingSession.audienceWindowKey at cast time
|
||||||
|
projectId String
|
||||||
|
audienceVoterId String
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([sessionId, windowKey, audienceVoterId])
|
||||||
|
@@index([sessionId, windowKey, ipAddress])
|
||||||
|
@@index([sessionId, windowKey, projectId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LiveNote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roundId String
|
||||||
|
projectId String
|
||||||
|
userId String
|
||||||
|
content String @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roundId, projectId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RevealState {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String @unique
|
||||||
|
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
|
||||||
|
stepsJson Json @db.JsonB // RevealStep[] — see Task 8
|
||||||
|
currentStepIndex Int @default(-1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add back-relations: `Project.audienceFavoriteVotes AudienceFavoriteVote[]`, `Project.liveNotes LiveNote[]`, `Round.liveNotes LiveNote[]`, `User.liveNotes LiveNote[]`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Migrate safely.** `npx prisma migrate status` first. If clean: `npx prisma migrate dev --name grand_finale_ceremony`. If drifted: `npx prisma migrate dev --create-only --name grand_finale_ceremony`, review SQL, then `npx prisma db execute --file prisma/migrations/<ts>_grand_finale_ceremony/migration.sql` and `npx prisma migrate resolve --applied <ts>_grand_finale_ceremony`. Then `npx prisma generate`.
|
||||||
|
- [ ] **Step 7: Verify** `npm run typecheck` passes (pre-existing errors aside). Commit `feat(finale): schema for phases, audience windows, favorite votes, notes, reveal`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Timer helper `src/lib/live-timer.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/live-timer.ts`
|
||||||
|
- Test: `tests/unit/live-timer.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (pure functions, no DB):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { elapsedMs, remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||||
|
|
||||||
|
const t0 = new Date('2026-06-11T10:00:00Z')
|
||||||
|
const at = (s: number) => new Date(t0.getTime() + s * 1000)
|
||||||
|
|
||||||
|
describe('live-timer', () => {
|
||||||
|
it('elapsedMs counts from phaseStartedAt', () => {
|
||||||
|
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 0 }, at(90))).toBe(90_000)
|
||||||
|
})
|
||||||
|
it('elapsedMs freezes while paused and subtracts accumulated pause', () => {
|
||||||
|
// paused at +60s, asked at +90s → frozen at 60s
|
||||||
|
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: at(60), phasePausedAccumMs: 0 }, at(90))).toBe(60_000)
|
||||||
|
// resumed with 30s pause accumulated, asked at +120s → 90s elapsed
|
||||||
|
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 30_000 }, at(120))).toBe(90_000)
|
||||||
|
})
|
||||||
|
it('remainingSeconds goes negative on overtime', () => {
|
||||||
|
expect(remainingSeconds({ phaseStartedAt: t0, phaseDurationSeconds: 60, phasePausedAt: null, phasePausedAccumMs: 0 }, at(75))).toBe(-15)
|
||||||
|
})
|
||||||
|
it('remainingSeconds is null without timer', () => {
|
||||||
|
expect(remainingSeconds({ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 }, t0)).toBeNull()
|
||||||
|
})
|
||||||
|
it('formatClock renders mm:ss and overtime', () => {
|
||||||
|
expect(formatClock(305)).toBe('5:05')
|
||||||
|
expect(formatClock(0)).toBe('0:00')
|
||||||
|
expect(formatClock(-83)).toBe('+1:23')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run** — FAIL (module not found).
|
||||||
|
- [ ] **Step 3: Implement:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type PhaseTimerState = {
|
||||||
|
phaseStartedAt: Date | string | null
|
||||||
|
phaseDurationSeconds: number | null
|
||||||
|
phasePausedAt: Date | string | null
|
||||||
|
phasePausedAccumMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
|
||||||
|
if (!t.phaseStartedAt) return 0
|
||||||
|
const start = new Date(t.phaseStartedAt).getTime()
|
||||||
|
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
|
||||||
|
return Math.max(0, end - start - t.phasePausedAccumMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
|
||||||
|
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
|
||||||
|
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClock(seconds: number): string {
|
||||||
|
const over = seconds < 0
|
||||||
|
const abs = Math.abs(seconds)
|
||||||
|
const m = Math.floor(abs / 60)
|
||||||
|
const s = abs % 60
|
||||||
|
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): server-stamped phase timer helper`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Phase machine + notes in `live.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/live.ts`
|
||||||
|
- Test: `tests/unit/live-phase.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests.** Use `createCaller(liveRouter, adminUser)` + factories (`createTestProgram/Competition/Round/Project`, round status `ROUND_ACTIVE`, `live.start` with 2 projects). Cases:
|
||||||
|
- `sendToScreens` sets `projectPhase='ON_DECK'`, target project active, timer fields null, `overrideSlide` cleared.
|
||||||
|
- `startPresentation` → `PRESENTING`, `phaseStartedAt` set, `phaseDurationSeconds` from input (e.g. 120) else from `round.configJson.presentationDurationMinutes*60` else 300.
|
||||||
|
- `startQA` after PRESENTING appends a timing-log entry `{projectId, phase:'PRESENTING', configuredSeconds, overranSeconds}` and starts QA timer.
|
||||||
|
- `openScoring` appends QA entry, phase `SCORING`, timer cleared.
|
||||||
|
- `pausePhase`/`resumePhase`: after pause, `phasePausedAt` set; resume folds into `phasePausedAccumMs` and clears `phasePausedAt`; pausing twice errors; resuming unpaused errors.
|
||||||
|
- overtime: startPresentation with `durationSeconds: 1`, manipulate by directly `prisma.liveProgressCursor.update({phaseStartedAt: new Date(Date.now()-10_000)})`, then `startQA` → log entry `overranSeconds >= 9`.
|
||||||
|
- `setOverrideSlide` sets/clears.
|
||||||
|
- `saveNote` upserts by (roundId, projectId, userId); second save with same juror overwrites content; `getMyNotes` returns only caller's notes.
|
||||||
|
- [ ] **Step 2: Run** — FAIL (procedures missing).
|
||||||
|
- [ ] **Step 3: Implement in `live.ts`.** Shared helper at top of file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type TimingEntry = {
|
||||||
|
projectId: string
|
||||||
|
phase: 'PRESENTING' | 'QA'
|
||||||
|
startedAt: string
|
||||||
|
endedAt: string
|
||||||
|
configuredSeconds: number | null
|
||||||
|
overranSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedOutTiming(cursor: {
|
||||||
|
activeProjectId: string | null
|
||||||
|
projectPhase: string
|
||||||
|
phaseStartedAt: Date | null
|
||||||
|
phaseDurationSeconds: number | null
|
||||||
|
phasePausedAt: Date | null
|
||||||
|
phasePausedAccumMs: number
|
||||||
|
timingLogJson: unknown
|
||||||
|
}, now: Date): Prisma.InputJsonValue | undefined {
|
||||||
|
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
|
||||||
|
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
|
||||||
|
const end = cursor.phasePausedAt ?? now
|
||||||
|
const elapsedSec = Math.max(0, Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000))
|
||||||
|
const entry: TimingEntry = {
|
||||||
|
projectId: cursor.activeProjectId,
|
||||||
|
phase: cursor.projectPhase,
|
||||||
|
startedAt: cursor.phaseStartedAt.toISOString(),
|
||||||
|
endedAt: now.toISOString(),
|
||||||
|
configuredSeconds: cursor.phaseDurationSeconds,
|
||||||
|
overranSeconds: cursor.phaseDurationSeconds == null ? 0 : Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
|
||||||
|
}
|
||||||
|
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as TimingEntry[]) : []
|
||||||
|
return [...log, entry] as unknown as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoundDurations(prisma: PrismaClient, roundId: string) {
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||||
|
const cfg = (round.configJson as Record<string, unknown>) ?? {}
|
||||||
|
return {
|
||||||
|
presentation: typeof cfg.presentationDurationMinutes === 'number' ? cfg.presentationDurationMinutes * 60 : 300,
|
||||||
|
qa: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
|
||||||
|
projectOrder: (cfg.projectOrder as string[]) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutations (all `adminProcedure`, all audit-logged following the file's existing `logAudit` pattern with actions `LIVE_SEND_TO_SCREENS`, `LIVE_PHASE_STARTED`, `LIVE_PHASE_PAUSED`, `LIVE_PHASE_RESUMED`, `LIVE_OVERRIDE_SLIDE`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sendToScreens: input {roundId, projectId} → cursor findUniqueOrThrow by roundId; durations+order;
|
||||||
|
index = order.indexOf(projectId) (BAD_REQUEST if -1);
|
||||||
|
update: { activeProjectId, activeOrderIndex: index, projectPhase: 'ON_DECK',
|
||||||
|
phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0,
|
||||||
|
overrideSlide: null, timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
|
||||||
|
startPresentation: input {roundId, durationSeconds?: z.number().int().min(10).max(7200).optional()}
|
||||||
|
→ update { projectPhase: 'PRESENTING', phaseStartedAt: now,
|
||||||
|
phaseDurationSeconds: input.durationSeconds ?? durations.presentation,
|
||||||
|
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
|
||||||
|
startQA: same shape, phase 'QA', default durations.qa
|
||||||
|
openScoring: { projectPhase: 'SCORING', phaseStartedAt: null, phaseDurationSeconds: null,
|
||||||
|
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
|
||||||
|
pausePhase: PRECONDITION_FAILED if !cursor.phaseStartedAt || cursor.phasePausedAt; set phasePausedAt: now
|
||||||
|
resumePhase: PRECONDITION_FAILED if !cursor.phasePausedAt;
|
||||||
|
set phasePausedAccumMs: cursor.phasePausedAccumMs + (now - cursor.phasePausedAt), phasePausedAt: null
|
||||||
|
|
||||||
|
setOverrideSlide: input {roundId, slide: z.enum(['welcome','break','deliberation','thanks']).nullable()}
|
||||||
|
→ update { overrideSlide: input.slide }
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes procedures (`protectedProcedure`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
saveNote: input {roundId, projectId, content: z.string().max(20_000)} →
|
||||||
|
prisma.liveNote.upsert({ where: { roundId_projectId_userId: { roundId, projectId, userId: ctx.user.id } },
|
||||||
|
create: {...}, update: { content } })
|
||||||
|
getMyNotes: input {roundId} → prisma.liveNote.findMany({ where: { roundId, userId: ctx.user.id } })
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend `getCursor` return: spread now includes the new cursor fields automatically (`...cursor`); additionally fetch `orderedProjects` (id, title, teamName, competitionCategory) for the whole `projectOrder` (one `findMany` + reorder in JS) and include `activeProject.competitionCategory` in its select.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** `npx vitest run tests/unit/live-phase.test.ts` — PASS. Run `npx vitest run tests/unit/auth-public-paths.test.ts` too (regression).
|
||||||
|
- [ ] **Step 5: Commit** `feat(finale): per-project phase machine, server timers, overtime log, juror notes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Audience windows + favorite votes in `live-voting.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/live-voting.ts`
|
||||||
|
- Test: `tests/unit/audience-window.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests.** Setup: program/competition/round (LIVE_FINAL, ROUND_ACTIVE), 3 projects (2 STARTUP, 1 BUSINESS_CONCEPT via `prisma.project.update` setting `competitionCategory`), `round.configJson.projectOrder` set, LiveVotingSession created with `allowAudienceVotes: true`, two AudienceVoter rows (tokens A, B). Cases:
|
||||||
|
1. `openAudienceWindow({windowKey:'CATEGORY:STARTUP', durationMinutes:5})` → phase OPEN, closesAt ≈ now+5m. Opening again → CONFLICT.
|
||||||
|
2. `castFavoriteVote` token A for STARTUP project → row created. Re-cast token A other STARTUP project → same row updated (count still 1).
|
||||||
|
3. Cast for the BUSINESS_CONCEPT project while STARTUP window open → BAD_REQUEST.
|
||||||
|
4. Set `audienceWindowClosesAt` to past via prisma, cast → PRECONDITION_FAILED (server-side time check, no cron).
|
||||||
|
5. `closeAudienceWindow` then cast → PRECONDITION_FAILED. Re-open works (new window, key CATEGORY:BUSINESS_CONCEPT) → casting BUSINESS_CONCEPT project OK.
|
||||||
|
6. `openAudienceWindow({windowKey:'OVERALL'})` with `allowOverallFavorite:false` → FORBIDDEN; after `updateSessionConfig({allowOverallFavorite:true})` → OK; any ordered project castable.
|
||||||
|
7. IP cap: create 3 voters with ctx ip '1.2.3.4' casting in same window (use `createTestContext` with custom ip — check `tests/setup.ts` signature; if ip not injectable, set `ipAddress` on rows directly and cast the 4th via caller whose ctx.ip is '1.2.3.4') → 4th distinct voter from same IP → TOO_MANY_REQUESTS. A voter updating their own vote from that IP still succeeds.
|
||||||
|
8. `getFavoriteTallies` returns per-windowKey per-project counts.
|
||||||
|
9. `getAudienceWindow` (public) reports phase CLOSED once `closesAt` past even without an explicit close, includes eligible projects in order, and `myVote` for a token.
|
||||||
|
- [ ] **Step 2: Run** — FAIL.
|
||||||
|
- [ ] **Step 3: Implement.** Zod: `const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])`. Helper in file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function windowIsOpen(s: { audiencePhase: string; audienceWindowClosesAt: Date | null }, now = new Date()) {
|
||||||
|
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
|
||||||
|
}
|
||||||
|
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
|
||||||
|
return key === 'CATEGORY:STARTUP' ? 'STARTUP' : key === 'CATEGORY:BUSINESS_CONCEPT' ? 'BUSINESS_CONCEPT' : null
|
||||||
|
}
|
||||||
|
async function getOrderedFinaleProjects(prisma: PrismaClient, session: { roundId: string | null; projectOrderJson: unknown }) {
|
||||||
|
let order: string[] = []
|
||||||
|
if (session.roundId) {
|
||||||
|
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
|
||||||
|
order = ((round?.configJson as Record<string, unknown>)?.projectOrder as string[]) ?? []
|
||||||
|
}
|
||||||
|
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { id: { in: order } },
|
||||||
|
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||||
|
})
|
||||||
|
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
return order.map((id) => byId.get(id)).filter(Boolean) as typeof projects
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Procedures:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
openAudienceWindow (adminProcedure): input {sessionId, windowKey: windowKeySchema, durationMinutes: z.number().int().min(1).max(120).default(5)}
|
||||||
|
→ session findUniqueOrThrow; if windowIsOpen(session) → CONFLICT 'An audience window is already open';
|
||||||
|
if windowKey==='OVERALL' && !session.allowOverallFavorite → FORBIDDEN 'Overall favorite vote is not enabled';
|
||||||
|
update { audiencePhase:'OPEN', audienceWindowKey: windowKey, audienceWindowOpenedAt: now,
|
||||||
|
audienceWindowClosesAt: new Date(now + durationMinutes*60_000) }; audit 'AUDIENCE_WINDOW_OPENED'.
|
||||||
|
|
||||||
|
closeAudienceWindow (adminProcedure): update { audiencePhase:'CLOSED', audienceWindowKey:null,
|
||||||
|
audienceWindowOpenedAt:null, audienceWindowClosesAt:null }; audit 'AUDIENCE_WINDOW_CLOSED'.
|
||||||
|
|
||||||
|
getAudienceWindow (publicProcedure): input {sessionId, token: z.string().optional()} →
|
||||||
|
session select audiencePhase/windowKey/closesAt/allowAudienceVotes/roundId/projectOrderJson;
|
||||||
|
const open = windowIsOpen(session); const key = open ? session.audienceWindowKey : null;
|
||||||
|
projects = open ? getOrderedFinaleProjects(...).filter(p => { const cat = categoryForKey(key!); return cat ? p.competitionCategory === cat : true }) : [];
|
||||||
|
myVote: if token && key → voter by token → favoriteVote findUnique by (sessionId, windowKey:key, audienceVoterId) → projectId;
|
||||||
|
return { open, windowKey: key, closesAt: open ? session.audienceWindowClosesAt : null, projects, myVoteProjectId }
|
||||||
|
|
||||||
|
castFavoriteVote (publicProcedure): input {sessionId, token, projectId} →
|
||||||
|
voter by token (UNAUTHORIZED if missing/mismatched session);
|
||||||
|
session findUniqueOrThrow; if !windowIsOpen → PRECONDITION_FAILED 'Voting is not open right now';
|
||||||
|
const key = session.audienceWindowKey!; const cat = categoryForKey(key);
|
||||||
|
project findUniqueOrThrow select competitionCategory; ordered = getOrderedFinaleProjects(...);
|
||||||
|
if (!ordered.some(p => p.id === input.projectId)) → BAD_REQUEST 'Project is not part of this vote';
|
||||||
|
if (cat && project.competitionCategory !== cat) → BAD_REQUEST 'Project is not in the open category';
|
||||||
|
existing = favoriteVote findUnique (sessionId, windowKey:key, audienceVoterId: voter.id);
|
||||||
|
if (!existing && ctx.ip) { const ipCount = await prisma.audienceFavoriteVote.count({ where: { sessionId, windowKey: key, ipAddress: ctx.ip } });
|
||||||
|
if (ipCount >= 3) → TOO_MANY_REQUESTS 'Vote limit reached for this network' }
|
||||||
|
upsert (update: { projectId, ipAddress: ctx.ip ?? existing?.ipAddress }); return { projectId }
|
||||||
|
|
||||||
|
getFavoriteTallies (adminProcedure): input {sessionId} →
|
||||||
|
groupBy ['windowKey','projectId'] _count; plus projects (title/teamName) join; plus per-window total counts.
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `allowOverallFavorite: z.boolean().optional()` to `updateSessionConfig` input (passes straight through to `data`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): audience favorite-vote windows with category gating + IP cap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Jury vote comment + by-round session resolution + my-votes query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/live-voting.ts`
|
||||||
|
- Test: `tests/unit/live-vote-comment.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** (a) `vote` with `comment: 'strong pitch'` persists it; re-vote updates it; (b) new `getSessionForVotingByRound({roundId})` returns the same payload shape as `getSessionForVoting` and creates nothing (null when no session); (c) new `getMyFinaleInputs({roundId})` returns caller's LiveVotes (score, criterionScoresJson, comment, projectId) and LiveNotes for the round.
|
||||||
|
- [ ] **Step 2: Run** — FAIL.
|
||||||
|
- [ ] **Step 3: Implement:**
|
||||||
|
- `vote` input gains `comment: z.string().max(5000).optional()`; include in upsert create/update (`comment: input.comment ?? undefined` on update so an omitted comment doesn't erase).
|
||||||
|
- `getSessionForVotingByRound` (protectedProcedure): `findUnique({ where: { roundId } })`; if null return null; else reuse the body of `getSessionForVoting` (extract a shared `buildVotingPayload(session, ctx)` helper used by both procedures — DRY).
|
||||||
|
- `getMyFinaleInputs` (protectedProcedure): input `{roundId}` → session by roundId (null-safe) → `liveVote.findMany({ where: { sessionId, userId: ctx.user.id } , select: { projectId, score, criterionScoresJson, comment, votedAt } })` + `liveNote.findMany({ where: { roundId, userId: ctx.user.id } })`. Return `{ votes, notes, session: { id, votingMode, criteriaJson } | null }`.
|
||||||
|
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): vote comments, by-round session lookup, my-finale-inputs query`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Jury live page rework
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(jury)/jury/competitions/[roundId]/live/page.tsx`
|
||||||
|
- Modify: `src/components/jury/live-voting-form.tsx` (add comment field — read it first; pass `comment` through `onVoteSubmit`)
|
||||||
|
|
||||||
|
No DB logic here; verified by build + Playwright in Task 13. Behaviors:
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix session wiring** — replace `getSessionForVoting({sessionId: params.roundId})` with `getSessionForVotingByRound({roundId: params.roundId})` (poll 2000ms). Keep `live.getCursor` poll at 2000ms (was 5000 — tighten for ceremony).
|
||||||
|
- [ ] **Step 2: Phase rendering** from `cursor.projectPhase`:
|
||||||
|
- `ON_DECK`: full-width banner card — "Up next" eyebrow, project title XL, team name; muted note "Presentation starting shortly". No scoring form.
|
||||||
|
- `PRESENTING` / `QA`: project card with phase badge (`Presentation` / `Q&A`) and live countdown chip using `remainingSeconds`/`formatClock` from `@/lib/live-timer` (tick via 1s `setInterval`, computed from server stamps — never local countdown state); red text when negative. Notes + scoring form below.
|
||||||
|
- `SCORING`: same but scoring card gets a highlighted ring (`ring-2 ring-[#de0f1e]`) and a "Scoring is open" badge.
|
||||||
|
- [ ] **Step 3: Persisted notes** — replace local `notes` state: load via `trpc.live.getMyNotes({roundId})`, keep a `Record<projectId, string>` local draft, debounce 800ms → `trpc.live.saveNote.mutate({roundId, projectId, content})`; show "Saved" / "Saving…" microcopy. Notes keyed per active project (switching project switches the note).
|
||||||
|
- [ ] **Step 4: Comment field** — add optional `Textarea` "Comment (visible to admins with your scores)" inside the voting form submission; include `comment` in `vote` mutation.
|
||||||
|
- [ ] **Step 5:** `npm run build` green. Commit `feat(finale): phase-aware jury live page with persisted notes + comments`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Reveal controller (backend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/live-voting.ts`
|
||||||
|
- Test: `tests/unit/reveal.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing tests:** (a) `saveReveal` upserts steps in DRAFT; (b) `armReveal` requires ≥1 step, DRAFT→ARMED; (c) `revealNext` ARMED→REVEALING idx 0, increments, last step → DONE (idx stays last); (d) `resetReveal` → DRAFT idx -1; (e) **no-leak:** `getCeremonyState` (public, Task 9 — write the test now against the procedure added there if sequencing demands; otherwise assert via a `getPublicReveal` helper) returns only steps `0..currentStepIndex`, empty when ARMED, none when DRAFT.
|
||||||
|
- [ ] **Step 2: Run** — FAIL.
|
||||||
|
- [ ] **Step 3: Implement.** Step schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const revealStepSchema = z.object({
|
||||||
|
kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']),
|
||||||
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
|
place: z.number().int().min(1).max(10).optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
title: z.string().max(200).optional(), // resolved display strings, stored denormalized
|
||||||
|
subtitle: z.string().max(300).optional(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
saveReveal (adminProcedure): {sessionId, steps: z.array(revealStepSchema).max(50)} →
|
||||||
|
revealState upsert by sessionId { stepsJson: steps, status: 'DRAFT', currentStepIndex: -1 }
|
||||||
|
armReveal (adminProcedure): requires existing DRAFT with steps.length>0 → status 'ARMED'
|
||||||
|
revealNext (adminProcedure): ARMED → { status:'REVEALING', currentStepIndex: 0 };
|
||||||
|
REVEALING → idx+1; if idx+1 === steps.length-1 → also status 'DONE'…
|
||||||
|
// careful: advance then check — newIndex = currentStepIndex + 1; clamp to steps.length-1;
|
||||||
|
// status = newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING'
|
||||||
|
resetReveal (adminProcedure): → { status:'DRAFT', currentStepIndex: -1 }
|
||||||
|
getRevealAdmin (adminProcedure): full state incl. all steps (for preview)
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit-log arm/next/reset with action names `REVEAL_ARMED`, `REVEAL_ADVANCED`, `REVEAL_RESET`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): results reveal controller with step-through state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Public ceremony state endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/server/routers/live-voting.ts`
|
||||||
|
- Test: extend `tests/unit/reveal.test.ts` + `tests/unit/audience-window.test.ts` no-leak/shape cases
|
||||||
|
|
||||||
|
- [ ] **Step 1: Failing test:** `getCeremonyState({roundId})` (publicProcedure) returns `{ overrideSlide, phase: {projectPhase, phaseStartedAt, phaseDurationSeconds, phasePausedAt, phasePausedAccumMs}, activeProject: {title, teamName, competitionCategory} | null, audience: { open, windowKey, closesAt, voteCount }, reveal: { status, steps: <revealed only>, currentStepIndex } | null, programName }`. Assert: never includes scores, never includes un-revealed steps, includes audience voteCount for the open window only.
|
||||||
|
- [ ] **Step 2: Run** — FAIL.
|
||||||
|
- [ ] **Step 3: Implement** — compose from `liveProgressCursor.findUnique({roundId})`, session by roundId, `audienceFavoriteVote.count({sessionId, windowKey})` when open, `revealState` (slice steps `0..currentStepIndex` only when REVEALING/DONE; `[]` when ARMED; null when DRAFT/absent). One procedure, ~60 lines.
|
||||||
|
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): public ceremony-state endpoint for big screen`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Deliberation jury completion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx`
|
||||||
|
- Modify: `src/server/services/deliberation.ts` (only if `getSessionWithVotes` lacks project list — verify first)
|
||||||
|
- Modify: `src/server/routers/deliberation.ts` (add projects to getSession payload if needed)
|
||||||
|
- Read first: `src/components/jury/deliberation-ranking-form.tsx`
|
||||||
|
- Test: `tests/unit/deliberation-jury-wiring.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Investigate** `getSessionWithVotes` — confirm what `session.participants[].user` contains (expect JuryGroupMember incl. `user`), and where the rank-able project list comes from (`session.results` is empty before finalize — the form currently gets `[]`!). Decide: extend `getSessionWithVotes` to include `projects` = projects of the session's round + category (via `ProjectRoundState` where `roundId`, project `competitionCategory === session.category`), selecting id/title/teamName.
|
||||||
|
- [ ] **Step 2: Failing test:** caller = juror user who is a JuryGroupMember + DeliberationParticipant; `deliberation.getSession` exposes `projects` (non-empty pre-finalize) and participant rows that let the client resolve `juryMemberId`; `submitVote` with that `juryMemberId` succeeds and `getSession` then shows the vote (`hasVoted` derivable). Also assert a juror cannot submit with another member's `juryMemberId` (existing enforcement — pin it).
|
||||||
|
- [ ] **Step 3: Implement service/router change**, run test — PASS.
|
||||||
|
- [ ] **Step 4: Fix the page:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { data: me } = trpc.user.me.useQuery() // or useSession() — match codebase pattern (check src for existing usage)
|
||||||
|
const myParticipant = session?.participants?.find((p: any) => p.user?.user?.id === me?.id)
|
||||||
|
const juryMemberId = myParticipant?.user?.id ?? null // JuryGroupMember.id
|
||||||
|
const hasVoted = !!session?.votes?.some((v: any) => v.juryMember?.user?.id === me?.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `projects={session.projects}` to `DeliberationRankingForm`. Submit all votes in ONE call sequence with `juryMemberId`; disable submit when `!juryMemberId` with explanatory text ("You are not a participant of this deliberation").
|
||||||
|
|
||||||
|
- [ ] **Step 5: Context panels** (below the ranking form, one collapsible card per project): my finale criteria scores + comment from `trpc.liveVoting.getMyFinaleInputs({roundId: session.roundId})` (criteria labels from `session` payload's criteriaJson), editable via the same `LiveVotingForm` in a dialog (submits `liveVoting.vote` — works because session status check is `IN_PROGRESS`; **verify**: if finale session will be COMPLETED by deliberation time, relax `vote`'s status guard to allow `IN_PROGRESS | PAUSED` and gate `currentProjectId` check to only apply when phase-voting — simplest: allow voting for any ordered project when `round.roundType === 'DELIBERATION'`-linked… **Decision:** add `allowRevote: true` behavior — `vote` accepts any `projectId` in the finale order when the session status is `IN_PROGRESS` or `PAUSED`; keep the `currentProjectId` equality check ONLY when `projectPhase` voting is live i.e. when the cursor's active project equals the voted project OR session.status === 'PAUSED'. Implement as: skip the `currentProjectId !== input.projectId` check when `input.projectId` is in the session's project order and the cursor for the round is in `SCORING` or session is `PAUSED`. Write a unit test for this relaxation.) Also show my `LiveNote` per project, and a link row to the finals documents page (route: check `src/app/(jury)` for the finals docs page added 2026-06-09 — link to it with the project preselected if supported, else plain link).
|
||||||
|
- [ ] **Step 6:** Tests + `npm run build` green. Commit `feat(finale): working jury deliberation flow with finale-score review and notes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Admin control panel revamp
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/admin/live/live-control-panel.tsx` (becomes orchestrator)
|
||||||
|
- Create: `src/components/admin/live/run-order-list.tsx`, `phase-controls.tsx`, `audience-window-panel.tsx`, `timing-log-card.tsx`, `reveal-panel.tsx`
|
||||||
|
- Modify: `package.json` (add `qrcode.react`)
|
||||||
|
- Find the admin page hosting `LiveControlPanel` (grep usage) — ensure it passes `roundId` + `competitionId` and has room for the new layout (2-col grid on lg).
|
||||||
|
|
||||||
|
Pure UI — verified via Playwright in Task 13. Behaviors per component:
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `npm i qrcode.react`.
|
||||||
|
- [ ] **Step 2: `run-order-list.tsx`** — props `{roundId}`; uses `live.getCursor` data (`orderedProjects`, `activeProjectId`, `activeOrderIndex`). Groups rows under `BUSINESS_CONCEPT` / `STARTUP` headings (preserving global order); each row: index, title, teamName, category dot, ▲▼ buttons (swap in `projectOrder`, call `live.reorder`), and a "Send to screens" button (`live.sendToScreens`). Active row highlighted; ON_DECK row shows "on deck" badge.
|
||||||
|
- [ ] **Step 3: `phase-controls.tsx`** — props `{roundId}`. Shows active project + phase badge; one primary button for the next transition (ON_DECK→"Start presentation", PRESENTING→"Start Q&A", QA→"Open scoring", SCORING→"Send next project" which calls `sendToScreens` with the next project in order); secondary buttons for pause/resume; the big server-derived countdown (`remainingSeconds`/`formatClock`, 1s tick, `text-red-600 animate-pulse` when negative with "OVER" label); duration override `Input` (minutes, prefilled from round config) applied to the next start call. Keep legacy session pause/resume (cursor.isPaused) as a small row.
|
||||||
|
- [ ] **Step 4: `audience-window-panel.tsx`** — props `{roundId}`. Resolves session via `liveVoting.getSession({roundId})`. Buttons "Open vote — Business Concepts" / "Open vote — Startups" / "Open vote — Overall favorite" (last disabled unless `allowOverallFavorite`; a `Switch` toggles it via `updateSessionConfig`), shared duration `Input` (default 5). When open: countdown, live vote count (`getFavoriteTallies` poll 3s — render per-window totals; per-project tallies in a collapsible "Tallies (admin only)"), "Close now" `destructive` button. "Show QR" button → `Dialog` with `<QRCodeSVG value={origin + '/vote/competition/' + roundId} size={420}/>` + the URL printed beneath.
|
||||||
|
- [ ] **Step 5: `timing-log-card.tsx`** — renders `cursor.timingLogJson` rows: project title (lookup from orderedProjects), phase, configured vs actual, overran chip (red `+m:ss`) when `overranSeconds > 0`.
|
||||||
|
- [ ] **Step 6: `reveal-panel.tsx`** — props `{roundId}`. "Compose from results" button: pulls `liveVoting.getResults({sessionId})`, `getFavoriteTallies`, and `deliberation.listSessions({competitionId})` → for each category with a finalized deliberation use its results order, else fall back to jury `getResults` order filtered by category; builds default steps (category-intro → places 3,2,1 → audience-award per category → overall-favorite if tallies exist → thanks) with resolved `title` (team/project name) and `subtitle` ("3rd place — Business Concepts" etc.); shows editable preview list (delete/reorder steps); "Save draft" → `saveReveal`. Then "Arm" (confirm dialog: "Big screen will switch to Results mode"), "Reveal next" (primary, shows `currentStepIndex+1 / steps.length`), "Reset". Show current step preview text so the admin always knows what fires next.
|
||||||
|
- [ ] **Step 7:** Compose all into `live-control-panel.tsx` (left col: phase-controls + run-order-list; right col: audience-window-panel + timing-log-card + reveal-panel). `npm run build` green. Commit `feat(finale): admin ceremony control panel — phases, run order, audience windows, QR, reveal`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Audience voting page + big-screen ceremony page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(public)/vote/competition/[roundId]/page.tsx` (read existing first; rework content)
|
||||||
|
- Create: `src/app/(public)/live/ceremony/[roundId]/page.tsx`
|
||||||
|
- Create: `src/components/public/ceremony/` (slides: `ceremony-shell.tsx`, `presentation-slide.tsx`, `audience-vote-slide.tsx`, `reveal-slide.tsx`, `static-slide.tsx`)
|
||||||
|
|
||||||
|
**Invoke the `frontend-design` skill before building these two surfaces** — the reveal especially must be projector-gorgeous (spec §9): Montserrat 700, dark-blue `#053d57` field, red `#de0f1e` accent, `motion` (v11, import from `'motion/react'`) AnimatePresence transitions, confetti-grade flourish on 1st place + audience award, 16:9-safe, high contrast, no text below ~32px effective.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Audience page** — resolve session: add tiny public procedure `liveVoting.getAudienceContextByRound({roundId})` returning `{sessionId, allowAudienceVotes, programName, roundName}` (5 lines, include in Task 5's test file as a shape assertion). Page flow: on mount ensure token in `localStorage['mopc-audience-' + sessionId]` else `registerAudienceVoter` → store. Poll `getAudienceWindow({sessionId, token})` every 3s. States: **waiting** (brand header, "Voting opens after the presentations — keep this page open", subtle wave animation), **open** (windowKey title — "Pick your favorite Business Concept", big tappable project cards (title + team), selected ring, confirm button → `castFavoriteVote`, then **voted** state: green check, "Vote recorded — you can change it until voting closes", countdown chip, tap-again-to-change), **closed** ("Voting is closed — thanks!"). Friendly error toast for IP-cap rejection. Mobile-first, thumb-sized targets, zero instructions needed.
|
||||||
|
- [ ] **Step 2: Ceremony page** — `'use client'`; poll `liveVoting.getCeremonyState({roundId})` every 2s; full-screen `ceremony-shell` (fixed inset-0, `bg-[#053d57]`, MOPC wordmark small top-left, no nav chrome). Render precedence exactly: overrideSlide → reveal (ARMED: "Results" splash; REVEALING/DONE: `reveal-slide` for `steps[currentStepIndex]` with AnimatePresence between steps) → audience window open (`audience-vote-slide`: giant centered QR (white tile, rounded), "Vote for your favorite …", mm:ss countdown, "N votes cast" ticker) → cursor phase (ON_DECK: "Up next" + team; PRESENTING/QA: team name hero + phase label + huge countdown `formatClock`, red glow when negative; SCORING: "The jury is scoring" interstitial) → welcome slide. 1s local tick for countdowns computed from server stamps.
|
||||||
|
- [ ] **Step 3: Reveal slide details** — `place` step: eyebrow ("3rd place — Startups"), team name scales in (motion spring, `initial={{opacity:0, y:40, scale:0.9}}`), 1st place gets gold treatment + confetti burst (CSS/motion particles — ~40 absolutely-positioned animated divs, no new dep); `audience-award`/`overall-favorite`: red accent treatment "Audience Choice"; `category-intro` and `thanks`: typographic full-bleed statements.
|
||||||
|
- [ ] **Step 4:** `npm run build` green. Commit `feat(finale): audience voting page + big-screen ceremony view with animated reveal`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: End-to-end verification + tally audit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/unit/live-results-tally.test.ts`
|
||||||
|
- All previous files (fixes as found)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tally audit tests** — `getResults`: 2 jury voters scoring 2 projects (criteria mode: assert weighted normalization matches hand-computed values), audienceWeight 0 default keeps jury-only ordering; tie detection fires on equal totals; `getFavoriteTallies` counts match casts. Run — PASS (fix `getResults` if hand-computed values disagree; document any fix in the commit).
|
||||||
|
- [ ] **Step 2: Full suite** `npx vitest run` — all green. `npm run typecheck` and `npm run build` — green.
|
||||||
|
- [ ] **Step 3: Manual drive (Playwright MCP against dev server), screenshots at each stop:** seed/identify a LIVE_FINAL round with projects in both categories → admin: start live session, send project to screens, start presentation (1 min override), watch countdown go red, start Q&A, open scoring → jury (second context): see ON_DECK→phases follow along, write a note, refresh (note persists), submit criteria scores + comment → admin: open Business-Concepts audience window → **clean browser context** (NOT the logged-in profile): load `/vote/competition/[roundId]`, cast favorite, change vote, see voted state → ceremony page shows QR + count → close window → compose reveal from results, arm, step through all steps on ceremony page → deliberation: create session, open voting, juror ranks (verify the Task 10 fix), close, aggregate, adminDecide override, finalize.
|
||||||
|
- [ ] **Step 4: Public-route curl checks** for `/vote/competition/<id>`, `/live/ceremony/<id>`, `/live-scores/<id>` — 200, no login redirect.
|
||||||
|
- [ ] **Step 5: Fix everything found; re-run suite; commit** `test(finale): tally audit + e2e ceremony verification fixes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 14 (STRETCH — only if all above is done and verified): Live ranking mode toggle
|
||||||
|
|
||||||
|
Skip unless time clearly permits. Admin toggle on the session (`votingMode: 'ranking'`), juror drag-rank of seen-so-far projects persisted to a new `LiveRank` model, results by Borda. **Do not start this before Task 13 is fully green.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution ground rules
|
||||||
|
|
||||||
|
- Commit after every task (or sub-step where marked); never push without `npm run build` green.
|
||||||
|
- Local dev DB only tonight; prod deploy is a separate explicit step with the user (memory: backup first, never `docker compose down -v`).
|
||||||
|
- If a step's investigation contradicts this plan (shapes, routes, component props), trust the code, adjust minimally, note the deviation in the commit message.
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# External Attendee Dish Self-Selection — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved (design), pending implementation plan
|
||||||
|
**Author:** Matt + Claude
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
External lunch attendees (e.g. partners, VIPs added by an admin in the logistics
|
||||||
|
screen) currently have **no way to choose their own dish**. The admin is expected
|
||||||
|
to set each external's `dishId` inline. There is no email and no self-service page.
|
||||||
|
|
||||||
|
This surfaced when an admin (Marine Jacq-Pietri, `marine@monaco-impact.org`) added
|
||||||
|
herself as an external attendee expecting to receive an email to pick a dish, and
|
||||||
|
never got one — because the flow does not exist. Verified in prod 2026-06-05:
|
||||||
|
the `ExternalAttendee` row exists with `dishId = null`, and no email path targets
|
||||||
|
externals.
|
||||||
|
|
||||||
|
### Current behaviour (verified in code + prod)
|
||||||
|
|
||||||
|
- `lunch.createExternal` / `lunch.updateExternal` write the row and send **no email**.
|
||||||
|
- The only "Pick your lunch dish" email (`sendLunchReminderEmail`) is driven by
|
||||||
|
`selectUnpickedAttendees`, which queries **`AttendingMember` rows tied to a
|
||||||
|
CONFIRMED `FinalistConfirmation`** — finalist team members only. Externals are
|
||||||
|
never in that set.
|
||||||
|
- `sendLunchRecapEmail` goes to admins + `extraRecipients` only (a manifest, not a picker).
|
||||||
|
- Externals' dishes are meant to be set by the admin inline via `dishId`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
External attendees with an email on file receive a dish-selection email containing
|
||||||
|
a tokenized link to a dedicated, no-login page where they choose a dish, declare
|
||||||
|
allergens, and add allergen notes — mirroring the finalist team-member picker.
|
||||||
|
|
||||||
|
## Design decisions (locked)
|
||||||
|
|
||||||
|
1. **Email trigger:** auto-send on add (when the external has an email) **plus** a
|
||||||
|
per-row "Resend invite" button in the logistics screen.
|
||||||
|
2. **Reminders:** unpicked externals are included in both the reminder cron and the
|
||||||
|
manual "Send reminders" action.
|
||||||
|
3. **Page fields:** dish + allergens + allergen notes (mirror the member picker).
|
||||||
|
4. **Dish write precedence:** last-write-wins. Both the inline admin `dishId` field
|
||||||
|
and the self-service page can write the dish; the admin can always override.
|
||||||
|
|
||||||
|
## Reference pattern
|
||||||
|
|
||||||
|
This feature mirrors the existing **finalist confirmation flow**:
|
||||||
|
|
||||||
|
- `src/lib/finalist-token.ts` — HMAC-signed token (`{ confirmationId, exp }`) via
|
||||||
|
`NEXTAUTH_SECRET`.
|
||||||
|
- `src/app/(public)/finalist/confirm/[token]/page.tsx` — public, tokenized, no-login page.
|
||||||
|
- `finalist.getByToken` / `finalist.confirm` / `finalist.decline` — `publicProcedure`s.
|
||||||
|
|
||||||
|
We replicate this shape for externals.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Data model
|
||||||
|
|
||||||
|
`prisma/schema.prisma` — `ExternalAttendee` gains one nullable field:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
inviteSentAt DateTime? // when the dish-selection email was last sent
|
||||||
|
```
|
||||||
|
|
||||||
|
- Drives an "invited ✓" indicator in the admin UI.
|
||||||
|
- Does **not** gate resends or reminders (those are intentionally repeatable).
|
||||||
|
- Nullable, so the migration is additive with no backfill.
|
||||||
|
|
||||||
|
**No `token` column.** The link is a stateless HMAC-signed token; the external is
|
||||||
|
loaded by the `externalId` embedded in the verified payload. Trade-off accepted:
|
||||||
|
individual links can't be revoked without rotating `NEXTAUTH_SECRET` — acceptable
|
||||||
|
for low-stakes dish picking. (This is the one intentional divergence from the
|
||||||
|
finalist flow, which stores a DB token for supersede/rotation scenarios that
|
||||||
|
externals don't have.)
|
||||||
|
|
||||||
|
Migration: single additive column. Apply in prod via `prisma migrate deploy`
|
||||||
|
(runs automatically on container start per the entrypoint). **Do not** run
|
||||||
|
`migrate dev` against the drifted dev DB — create the migration SQL and use
|
||||||
|
`db execute` + `migrate resolve` if needed locally.
|
||||||
|
|
||||||
|
### 2. Token helper — `src/lib/external-lunch-token.ts`
|
||||||
|
|
||||||
|
Mirror `finalist-token.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ExternalLunchTokenPayload = { externalId: string; exp: number }
|
||||||
|
export function signExternalLunchToken(payload): string
|
||||||
|
export function verifyExternalLunchToken(token): ExternalLunchTokenPayload // throws on bad sig / expired
|
||||||
|
```
|
||||||
|
|
||||||
|
- HMAC-SHA256 over base64url payload, `timingSafeEqual` comparison.
|
||||||
|
- `exp` = `eventAt + 24h` when `eventAt` is set, else `now + 30d`. Generous so the
|
||||||
|
link outlives the change deadline (the deadline is enforced separately at write time).
|
||||||
|
|
||||||
|
### 3. tRPC — `src/server/routers/lunch.ts`
|
||||||
|
|
||||||
|
- **`getExternalByToken`** (`publicProcedure`, input `{ token }`):
|
||||||
|
verify token → load external (+ its `LunchEvent`, ordered `dishes`, current
|
||||||
|
`dish`/`allergens`/`allergenOther`) → return payload incl. computed
|
||||||
|
`changeDeadline = eventAt − changeCutoffHours`. Throws map to the page's friendly
|
||||||
|
error states (`expired` / `signature` / not found).
|
||||||
|
|
||||||
|
- **`setExternalPick`** (`publicProcedure`, input
|
||||||
|
`{ token, dishId: string | null, allergens, allergenOther }`):
|
||||||
|
verify token → if `eventAt` set and `now > changeDeadline` → `PRECONDITION_FAILED`
|
||||||
|
→ update the external's `dishId` / `allergens` / `allergenOther`. No audit row
|
||||||
|
(no authenticated user on a public pick).
|
||||||
|
|
||||||
|
- **`sendExternalInvite`** (`adminProcedure`, input `{ externalId }`):
|
||||||
|
load external (must have an email, else `PRECONDITION_FAILED`) → sign token →
|
||||||
|
`sendExternalDishInviteEmail(...)` → stamp `inviteSentAt = now` → audit
|
||||||
|
`LUNCH_EXTERNAL_INVITE_SENT`. Returns the updated row.
|
||||||
|
|
||||||
|
- **`createExternal`** (existing, modified): after insert, if `input.email` present,
|
||||||
|
fire-and-forget send the invite (sign token, send email, stamp `inviteSentAt`)
|
||||||
|
wrapped in `try/catch` — **never throws** (per the "round notifications never
|
||||||
|
throw" project constraint). A failed send leaves `inviteSentAt = null` so the
|
||||||
|
admin can resend.
|
||||||
|
|
||||||
|
### 4. Email — `src/lib/email.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function sendExternalDishInviteEmail(opts: {
|
||||||
|
to: string
|
||||||
|
name: string
|
||||||
|
eventAt: Date | null
|
||||||
|
venue: string | null
|
||||||
|
notes: string | null
|
||||||
|
changeDeadline: Date | null
|
||||||
|
pickUrl: string
|
||||||
|
}): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Uses the existing branded wrapper.
|
||||||
|
- Subject: `Choose your lunch dish — MOPC grand finale`.
|
||||||
|
- Body: greeting, event date (Europe/Monaco), venue, optional notes, deadline, CTA
|
||||||
|
button → `pickUrl`.
|
||||||
|
- One template serves both the initial invite and reminders.
|
||||||
|
|
||||||
|
### 5. Reminders — extend existing flow
|
||||||
|
|
||||||
|
- **`src/server/services/lunch-reminders.ts`**: add
|
||||||
|
`selectUnpickedExternals(prisma, event)` → externals where `email` is set and
|
||||||
|
`dishId IS NULL` for the event.
|
||||||
|
- **`src/app/api/cron/lunch-reminders/route.ts`** and **`lunch.sendReminders`**:
|
||||||
|
after the existing `AttendingMember` loop, also loop unpicked externals and send
|
||||||
|
`sendExternalDishInviteEmail` with a freshly signed token URL. External links go
|
||||||
|
to `/lunch/pick/<token>` (not `/applicant`). Per-send errors are caught and
|
||||||
|
logged, consistent with the member loop.
|
||||||
|
|
||||||
|
### 6. Public page — `src/app/(public)/lunch/pick/[token]/page.tsx`
|
||||||
|
|
||||||
|
Mirror `(public)/finalist/confirm/[token]/page.tsx`:
|
||||||
|
|
||||||
|
- `'use client'`, reads `token` from params, queries `lunch.getExternalByToken`.
|
||||||
|
- States: loading skeleton; invalid/expired/not-found friendly cards (reuse the
|
||||||
|
`FriendlyError` pattern with `info@monaco-opc.com` fallback).
|
||||||
|
- Header card: event date, venue, notes, deadline countdown (reuse `CountdownLabel`).
|
||||||
|
- Form: dish radio list with dietary-tag badges, allergen checkboxes, allergen-notes
|
||||||
|
textarea. Submit → `lunch.setExternalPick`.
|
||||||
|
- Success state: "Your dish is saved", editable until the deadline.
|
||||||
|
- Past deadline: read-only with "contact an admin" message.
|
||||||
|
|
||||||
|
### 7. Admin UI — logistics externals table
|
||||||
|
|
||||||
|
- Per-row status chip: `no email` / `Invited` / `Picked`.
|
||||||
|
- Per-row **Resend invite** button → `lunch.sendExternalInvite` (disabled when no email).
|
||||||
|
- The inline `dishId` editor stays (admin override path).
|
||||||
|
|
||||||
|
### Manifest / recap
|
||||||
|
|
||||||
|
No change. `lunch-recap.ts` already includes externals, so self-service picks flow
|
||||||
|
into the manifest, CSV export, and recap email automatically.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **No email on external:** auto-send skipped; resend button disabled; reminders skip.
|
||||||
|
- **Tampered / expired link:** friendly error card; no data leak.
|
||||||
|
- **Pick after deadline:** `PRECONDITION_FAILED`; page shows read-only state.
|
||||||
|
- **Admin and external both set a dish:** last-write-wins (intended).
|
||||||
|
- **Email added later via `updateExternal`:** no auto-send on update; admin uses the
|
||||||
|
resend button (keeps `updateExternal` side-effect-free).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: token sign/verify roundtrip + tamper + expiry rejection (`external-lunch-token`).
|
||||||
|
- Unit: `selectUnpickedExternals` returns only emailed + unpicked externals.
|
||||||
|
- Integration: `getExternalByToken` happy path; bad/expired token errors.
|
||||||
|
- Integration: `setExternalPick` happy path; deadline rejection.
|
||||||
|
- Integration: `createExternal` with email stamps `inviteSentAt` (mocked email send);
|
||||||
|
without email leaves it null.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- External attendee decline / RSVP (this is dish-only).
|
||||||
|
- Reworking the member picker.
|
||||||
|
- Audience-window / live-voting rework (tracked separately).
|
||||||
|
```
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Grand Final: judge-visible document curation + optional revised uploads
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Approved (Matt, this session)
|
||||||
|
**Builds on:** `2026-06-09-grand-final-documents-design.md` and the same-day pivot (commits `f8f2d77`, `8a4184d`)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Feedback from the other program admin:
|
||||||
|
|
||||||
|
> Jury actually need to see BP + Exec summary + 1min video — the ones they uploaded already. And candidates should be able to upload their PDF pres + video — optional, as some sent it another way.
|
||||||
|
|
||||||
|
Two gaps against what is deployed:
|
||||||
|
|
||||||
|
1. **Judges see too much.** `listFinalistDocumentsForReview` returns *every* file each finalist team ever submitted (5–7 per team on prod: Pitch Deck, Intro Video, Executive Summary, Business Plan, Promotional Video, plus any Grand Final uploads). The admin wants judges to see a curated subset (BP + exec summary + 1-min video). There is no way to choose which prior documents are surfaced.
|
||||||
|
2. **"Optional uploads" mode renders wrong.** The three modes the admin wants are: no new uploads (toggle OFF — works), mandatory uploads (toggle ON + slots required — works), and optional uploads (toggle ON + slots marked not-required). In the all-optional case, `FinalDocumentStatus.allRequiredUploaded` is hardcoded `false` when zero slots are required, so the finalist banner/panel never reach a settled state and the copy implies the docs are mandatory.
|
||||||
|
|
||||||
|
Prod facts (verified 2026-06-09 via read-only query): 9 finalist teams, 48 prior files + 3 Grand Final uploads. Every team has all 5 prior doc types. The Business Plan and the 1-minute promo video live under *two different* `FileRequirement` rows depending on the team's path (Semi-Finals Document Submission for 8 teams, Spotlight on Africa Submission Round for 1 team — Blue Fields Company).
|
||||||
|
|
||||||
|
## Part 1 — Admin curation of judge-visible documents
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
New optional key on the LIVE_FINAL round's `configJson`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
reviewVisibleRequirementIds?: string[] // FileRequirement ids from prior rounds
|
||||||
|
```
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- **absent / null** → show all prior files (current behavior; safe default, no migration needed)
|
||||||
|
- **non-empty array** → show only prior files whose `requirementId` is in the list
|
||||||
|
- **empty array** → hide all prior files (only Grand Final uploads remain visible)
|
||||||
|
- **Grand Final round uploads are always shown**, regardless of the selection — they are what the team explicitly submitted for the finale
|
||||||
|
- Prior files with no `requirementId` (fileType-only) are excluded whenever a selection is active. (All 48 prod files have a requirement, so nothing is lost in practice.)
|
||||||
|
|
||||||
|
### Service (`src/server/services/final-documents.ts`)
|
||||||
|
|
||||||
|
`listFinalistDocumentsForReview` adds `requirementId` to its file select and applies the filter above using the finale round's `configJson`. No signature change; `ReviewPayload` unchanged.
|
||||||
|
|
||||||
|
New helper to power the admin picker: list the distinct prior-round requirement slots referenced by the finalist teams' files — `{ requirementId, name, roundName, fileCount }`, ordered by round sort then name. Derived from the same file query, so the picker only offers slots that actually have files.
|
||||||
|
|
||||||
|
### tRPC (`src/server/routers/finalist.ts`, adminProcedure)
|
||||||
|
|
||||||
|
- `getReviewDocSettings` → `{ options: Slot[], selectedIds: string[] | null }` (null = "all" mode)
|
||||||
|
- `setReviewVisibleRequirements({ requirementIds: string[] | null })` → writes/clears the configJson key (null clears back to "show all"). Audited like `setRevisedUploadSetting`.
|
||||||
|
|
||||||
|
### Admin UI
|
||||||
|
|
||||||
|
New card "Documents shown to judges" placed next to the existing revised-uploads toggle (`src/components/admin/grand-finale/final-docs-uploads-toggle.tsx`, rendered on the LIVE_FINAL round admin page `src/app/(admin)/admin/rounds/[roundId]/page.tsx`):
|
||||||
|
|
||||||
|
- A "Show all submitted documents" master state (the default), and beneath it a checkbox per slot labeled `"<requirement name> — <round name>"` with the file count (e.g. "Business Plan — Semi-Finals Document Submission (8 files)").
|
||||||
|
- Unchecking the master switches to curated mode with all boxes ticked; the admin then unticks what judges shouldn't see. Re-checking the master clears the selection (back to null/"all").
|
||||||
|
- Copy notes that Grand Final uploads are always visible to judges.
|
||||||
|
|
||||||
|
For the admin's stated goal, they'd switch to curated mode and leave 5 boxes ticked: Executive Summary (Intake), Business Plan (Semi-Finals + Spotlight), Promotional Video (Semi-Finals) and 1 Minute Promotional Video (Spotlight) — judges then see exactly BP + exec summary + 1-min video per team, plus any finale uploads.
|
||||||
|
|
||||||
|
## Part 2 — All-optional upload mode fix
|
||||||
|
|
||||||
|
`FinalDocumentStatus` (in `final-documents.ts`) gains:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
hasRequired: boolean // any slot with isRequired
|
||||||
|
allUploaded: boolean // requirements.length > 0 && every slot has a file, required or not
|
||||||
|
```
|
||||||
|
|
||||||
|
`allRequiredUploaded` keeps its current semantics (meaningful only when `hasRequired`). Edge case: if the toggle is ON but no slots are defined at all (`requirements.length === 0`), the banner and panel render nothing — no vacuous "(0 of 0)" complete state.
|
||||||
|
|
||||||
|
UI changes:
|
||||||
|
- **Banner** (`src/components/applicant/final-documents-banner.tsx`): when `hasRequired` is false — title "Upload updated Grand Final documents (optional)", same neutral blue styling, keep per-doc checklist/count/deadline/upload button; green settled state ("Grand Final documents uploaded") only when `allUploaded`.
|
||||||
|
- **Panel** (`src/components/applicant/final-documents-panel.tsx`, team + mentor variants): "Submitted" badge driven by `hasRequired ? allRequiredUploaded : allUploaded`; description gains "(optional)" when nothing is required.
|
||||||
|
|
||||||
|
Reminders need **no change** — verified: the cron and the untargeted manual blast already skip teams with no missing *required* docs, so all-optional mode never nags; an explicitly targeted manual reminder still sends (intentional admin override).
|
||||||
|
|
||||||
|
## Out of scope (admin actions in existing UI, not code)
|
||||||
|
|
||||||
|
- Flipping `allowFinalistRevisedUploads` ON
|
||||||
|
- Creating/adjusting the finale upload slots (PDF presentation + 1-min video, "Required" off) in the round's file-requirements editor
|
||||||
|
- Ticking the curation checkboxes
|
||||||
|
- Populating the Finals Jury group (still open from the previous ship)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vitest service tests (extend `tests/` final-documents coverage):
|
||||||
|
- Curation: null selection → all files; selection → only matching prior files + finale uploads always; empty array → finale uploads only; file without requirementId excluded under a selection.
|
||||||
|
- Picker helper returns distinct slots with correct counts.
|
||||||
|
- `setReviewVisibleRequirements` round-trips null/array through configJson without clobbering other keys (`allowFinalistRevisedUploads`).
|
||||||
|
- Status: `hasRequired`/`allUploaded` across mixed, all-optional (0 required), and fully-uploaded fixtures.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **configJson clobbering:** both toggles write the same JSON column — read-modify-write must preserve sibling keys (existing `setRevisedUploadSetting` pattern already does this; reuse it).
|
||||||
|
- **Stale selection:** if a selected requirement is later deleted, its files simply stop matching; "all" fallback never breaks. No cleanup needed.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Grand-Final Documents — upload visibility, mentor surfacing, judge review, notifications
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Design — pending review
|
||||||
|
**Edition:** MOPC 2026 (program "Monaco Ocean Protection Challenge", competition `MOPC 2026`)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Nine finalist teams must submit two final deliverables ahead of the Grand Final — a **final PDF presentation** and a **~1-minute video** — and the upcoming Grand-Final judges need to review those documents. Today there is no discoverable upload prompt, no consolidated judge review surface, and no notifications driving teams to upload.
|
||||||
|
|
||||||
|
## Current state (verified against prod, 2026-06-09)
|
||||||
|
|
||||||
|
The data layer already exists and is correctly set up:
|
||||||
|
|
||||||
|
- **"Grand Final" (LIVE_FINAL) round is `ROUND_ACTIVE`** with `windowCloseAt = 2026-06-11 21:00 UTC` and the empty **"Finals Jury"** group attached (`juryGroupId` set, **0 members**).
|
||||||
|
- **Two `FileRequirement` rows already exist on the Grand Final round** (legacy per-round system): **"PDF presentation support"** (`application/pdf`) and **"1 minute video"** (`video/*`). Both currently `required = false`, `maxSizeMB = null`, **0 files uploaded**. This set is being expanded to the confirmed 4-document set below.
|
||||||
|
- **All 9 finalist teams are correctly enrolled**: each has a `ProjectRoundState` in both the (closed) Mentoring round and the (active) Grand Final round, and all 9 have `FinalistConfirmation.status = CONFIRMED`. No mismatches (confirmed↔enrolled↔in-mentor all aligned). All 9 share one mentor, **Camille Lopez**. Attendee counts 1–4 (program `defaultAttendeeCap = 4`).
|
||||||
|
- Auto-enroll (confirmed + in mentor round → Grand Final round) is working via `finalist.enrollFinalists`; the **admin override already exists** (`finalist.adminConfirm` to mark attending without a token; `finalist.unenroll` to remove) in the `/admin/logistics` **Confirmations tab** + attendance dialog.
|
||||||
|
|
||||||
|
### What this means
|
||||||
|
|
||||||
|
The **upload already works today**: `/applicant/documents` renders an upload section for every round in `openRounds`, where `openRounds` = program rounds that are `ROUND_ACTIVE` **and** the project is a member of (`applicant.getMyDashboard`). The Grand Final round qualifies, so all 9 teams can upload the PDF + video right now. The upload procedure (`applicant.getUploadUrl`) permits any team member to upload against a requirement as long as the round is `ROUND_ACTIVE` (it is). The `windowCloseAt` deadline is **advisory only** (display, not blocking).
|
||||||
|
|
||||||
|
The gaps are therefore: **discoverability** (no banner/notification), **judge review** (no consolidated surface; jury can only see files for projects they are individually assigned to, and the Finals Jury group is empty), and **mentor-section surfacing** (the final documents never appear in the mentor area).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Make the existing finalist upload **discoverable** via a dashboard banner and notifications.
|
||||||
|
2. Give the upcoming Grand-Final judges a **read-only review page** of all finalists' documents.
|
||||||
|
3. Surface the final documents (and a pre-deadline cue) inside **the mentor section**, on both the team's and the mentor's views.
|
||||||
|
4. Add **email + in-app notifications**, triggered **automatically** (pre-deadline reminder cron) and **manually** (admin blast).
|
||||||
|
|
||||||
|
## Document set (confirmed)
|
||||||
|
|
||||||
|
The Grand Final round's `FileRequirement` rows are reconfigured to **four required documents**, identical for both categories (STARTUP and BUSINESS_CONCEPT) — the per-round `FileRequirement` model already applies one set to all teams in the round:
|
||||||
|
|
||||||
|
1. **Final Presentation** — `application/pdf` (rename of the existing "PDF presentation support" row)
|
||||||
|
2. **Final Business Plan** — `application/pdf` (new)
|
||||||
|
3. **1-minute Video** — `video/*` (existing "1 minute video" row)
|
||||||
|
4. **Executive Summary** — `application/pdf` (new)
|
||||||
|
|
||||||
|
All four `required = true`. PDF-only for the three document slots (no Word). This is an additive/safe prod data change (0 files currently uploaded). If per-category document sets are ever needed, that is out of scope here (the per-round model does not support it without extra work).
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- Admin approve / needs-changes review workflow on documents.
|
||||||
|
- Migrating to the heavier `SubmissionWindow` system (the legacy `FileRequirement` anchor is already set up and proven).
|
||||||
|
- A mentor-milestone tracker UI (`MentorMilestone`/`MentorMilestoneCompletion` models exist but have no UI; "last steps of the mentor round" is treated as descriptive framing, surfaced as a read-only Final Documents panel, not a milestone system).
|
||||||
|
- Comments/threads on the judge review page.
|
||||||
|
- New admin enrollment/override controls (`adminConfirm` + `unenroll` already exist; we only ensure they are reachable from the finale round overview).
|
||||||
|
|
||||||
|
## Architecture decision
|
||||||
|
|
||||||
|
Build thin additions on the **existing legacy `FileRequirement` → `ProjectFile` anchor** that is already configured on the Grand Final round. Reuse:
|
||||||
|
|
||||||
|
- Upload mechanics (`applicant.getUploadUrl` / `saveFileMetadata`, presigned MinIO PUT, `RequirementUploadList`).
|
||||||
|
- File preview/download (`FilePreview` / `file-viewer`, `file.getDownloadUrl`).
|
||||||
|
- The notification pipeline (`createNotification`, `notifyProjectTeam`/`notifyProjectMentors`, `NotificationEmailSetting`, `NOTIFICATION_EMAIL_TEMPLATES`, `sendStyledNotificationEmail`) and the reminder-cron pattern (`sendDueConfirmationReminders`).
|
||||||
|
|
||||||
|
**The deadline everywhere** (banner, mentor cue, reminder cron) is the Grand Final round's single `windowCloseAt` field, edited by admins in round settings. All deadline displays use **browser-local time + zone label** (`Intl.DateTimeFormat`), never UTC/fixed Monaco time, per the locked grand-finale timezone rule. Deadline behavior is **soft/advisory** — uploads stay open while the round is active; past the date, files are flagged "late" in the UI but still accepted.
|
||||||
|
|
||||||
|
## Shared service: `src/server/services/final-documents.ts`
|
||||||
|
|
||||||
|
A new service centralizes the logic, wrapped by thin tRPC procedures:
|
||||||
|
|
||||||
|
- `getFinalDocumentStatusForProject(prisma, projectId)` → `{ roundId, roundName, deadline, deadlinePassed, requirements: [{ id, name, acceptedMimeTypes, uploaded, file? }], allRequiredUploaded }` or `null` when the project is not a CONFIRMED finalist in an active LIVE_FINAL round. The single source of truth for the banner, the mentor panels, and reminder targeting.
|
||||||
|
- `listFinalistDocumentsForReview(prisma, programId)` → `{ round: { name, deadline }, totalCount, submittedCount, teams: [{ projectId, teamName, category, confirmStatus, documents: [{ requirementId, requirementName, file? }] }] }`. File metadata only; presigned URLs are fetched per-file on demand by the client via existing `file.getDownloadUrl`.
|
||||||
|
- `sendDueFinalDocReminders(prisma)` → cron entry. Targets CONFIRMED finalists in the active LIVE_FINAL round with at least one required document missing, whose `finalDocsReminderSentAt` is null and whose deadline is within the reminder window; creates `GRAND_FINAL_DOCS_REMINDER` notifications and stamps `finalDocsReminderSentAt`. Best-effort per row.
|
||||||
|
- `sendManualFinalDocReminders(prisma, { programId, projectIds?, actorId })` → admin blast. For the given projects (default: all CONFIRMED finalists with missing required docs), create `GRAND_FINAL_DOCS_REMINDER` notifications regardless of `finalDocsReminderSentAt`. Returns `{ sent }`.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Finalist upload banner (applicant dashboard)
|
||||||
|
|
||||||
|
- New auto-hiding banner component (pattern of `LunchBanner`/`MyLogisticsCard`: returns `null` when not applicable) on `src/app/(applicant)/applicant/page.tsx`.
|
||||||
|
- Backed by a new query **`applicant.getFinalDocumentStatus`** (wraps `getFinalDocumentStatusForProject` for the caller's project).
|
||||||
|
- Shows: heading ("Upload your Grand Final documents"), each required document with a ✓ / empty state (e.g. "2 of 4 uploaded"), deadline in browser-local time + zone, and a CTA button → `/applicant/documents`. Collapses to a "✓ Submitted" confirmation once all required documents are uploaded. Non-dismissible while incomplete.
|
||||||
|
|
||||||
|
### 2. Mentor-section "Final Documents" panel (team + mentor)
|
||||||
|
|
||||||
|
A new read-only `FinalDocumentsPanel` component rendered on **both** mentor surfaces:
|
||||||
|
|
||||||
|
- **Team view** — `src/app/(applicant)/applicant/mentor/page.tsx` (adds a panel below the existing mentor cards / chat / workspace-files). Uses `applicant.getFinalDocumentStatus`.
|
||||||
|
- **Mentor view** — `src/app/(mentor)/mentor/workspace/[projectId]/page.tsx` (adds a "Final Documents" section or tab for the viewed project). Uses a new **`mentor.getProjectFinalDocuments`** procedure (mentor/team access check) wrapping `getFinalDocumentStatusForProject`.
|
||||||
|
|
||||||
|
Behavior: before upload + as the deadline nears, a **visual cue** ("Final grand-final documents due [date] — upload now", with an upload CTA on the team view); after upload, the PDF + video appear as the team's read-only "final documents" (inline preview / video player / download), visible to both the team and their mentor. Same underlying `ProjectFile`s — no duplicate storage.
|
||||||
|
|
||||||
|
### 3. Judge review page (thin dedicated page, reusing existing components)
|
||||||
|
|
||||||
|
**Why dedicated rather than baking into the existing per-project jury page** (verified in prod): the finale has **0 `Assignment` rows**, the "Finals Jury" group has **0 members**, and no `LiveVotingSession` exists. The existing jury flow is assignment-gated — the round page lists `roundAssignment.getMyAssignments` (empty for the finale) and `file.listByProject` 403s any juror without an `Assignment` to the project. The finale runs on a **group + live-session** model, not per-project assignments. Baking in would require either fabricating an assignment per judge × finalist or rewiring the assignment-based access path — more work and risk, and a worse UX (one project at a time vs. all finalists at once). So a dedicated page is the better fit *because* the existing page's access model does not apply to the finale.
|
||||||
|
|
||||||
|
It stays thin by **reusing existing components** — the same `MultiWindowDocViewer` / `FilePreview` / `<video>` / `file.getDownloadUrl` used on the per-project page — laid out as a consolidated finale list. Not a reinvented viewer.
|
||||||
|
|
||||||
|
- New read-only page (e.g. `src/app/(jury)/jury/finals-documents/page.tsx`) listing all finalist teams grouped by category, each with its four documents (3 PDFs + video) via the reused viewer/preview components plus download. Missing documents show "Not yet uploaded". Header shows the deadline and "X of N submitted".
|
||||||
|
- Backed by a new **`finalist.listReviewDocuments`** procedure (wraps `listFinalistDocumentsForReview`). Authorization: **SUPER_ADMIN / PROGRAM_ADMIN, OR a `JuryGroupMember` of the active LIVE_FINAL round's jury group**, via a new `assertFinalsReviewAccess(ctx)` helper. Unauthorized → access-denied state.
|
||||||
|
- Entry points: a jury sidebar link ("Finalist Documents") shown when an active LIVE_FINAL round exists, and a "Review finalist documents" link on the admin Grand Final round overview.
|
||||||
|
- **Implementation note:** verify the `(jury)` route-group layout does not hard-redirect admins; if it does, either relax it for this page or mount the page on a neutral path reachable by both. Authorization is enforced by the procedure regardless.
|
||||||
|
|
||||||
|
### 4. Notifications (email + in-app; auto + manual)
|
||||||
|
|
||||||
|
- **New notification type `GRAND_FINAL_DOCS_REMINDER`** (team-facing): added to `NotificationTypes`, with a `NOTIFICATION_EMAIL_TEMPLATES` entry (branded) and a `seed-notification-settings.ts` row (`category: "logistics"`, per-type `sendEmail` toggle, subject/body overridable). In-app + email.
|
||||||
|
- **New notification type `GRAND_FINAL_DOCS_SUBMITTED`** (mentor-facing, light): when a team uploads a final document, notify the team's mentor(s) in-app so it surfaces in their mentor section. Seed row with `sendEmail` default **off** (in-app on). Triggered from `applicant.saveFileMetadata` when the file is for the LIVE_FINAL round (best-effort, never throws).
|
||||||
|
- **Automatic (cron):** new route `src/app/api/cron/final-document-reminders/route.ts` (protected by `CRON_SECRET`) calling `sendDueFinalDocReminders`. Reminder window read from the round `configJson.finalDocsReminderHoursBeforeDeadline` (default 48h). Fires once per team (stamped via `finalDocsReminderSentAt`). This doubles as the initial "documents are open" nudge.
|
||||||
|
- **Manual (admin):** a "Remind teams to upload final documents" action with a live `EmailPreviewDialog` (mirrors the existing finalist reminder-blast), backed by `finalist.sendDocumentReminders` → `sendManualFinalDocReminders`. Placed on the admin Grand Final round overview and/or the `/admin/logistics` Confirmations tab. Usable immediately to kick off the round.
|
||||||
|
|
||||||
|
### 5. Minor polish
|
||||||
|
|
||||||
|
- Reconfigure the round's `FileRequirement` rows to the 4-document set (rename "PDF presentation support" → "Final Presentation"; add "Final Business Plan" + "Executive Summary" as `application/pdf`; keep "1-minute Video"), all `required = true` (guarded prod data update at ship time, or via the admin round file-requirement editor if present). Additive/safe — 0 files uploaded.
|
||||||
|
- Confirm admins can edit the round's `windowCloseAt` in round settings (the admin-set deadline). If no input exists, add a small one; likely already present.
|
||||||
|
|
||||||
|
## Data model changes
|
||||||
|
|
||||||
|
- `FinalistConfirmation.finalDocsReminderSentAt DateTime?` (new nullable column) — lets the auto reminder fire once per team. Migration required (additive, safe).
|
||||||
|
- `NotificationTypes`: add `GRAND_FINAL_DOCS_REMINDER`, `GRAND_FINAL_DOCS_SUBMITTED`.
|
||||||
|
- `seed-notification-settings.ts`: add rows for both new types (auto-provisions on deploy).
|
||||||
|
- Optional round config field `finalDocsReminderHoursBeforeDeadline` (default 48) validated in the LIVE_FINAL round Zod config.
|
||||||
|
|
||||||
|
## tRPC procedures (new)
|
||||||
|
|
||||||
|
| Procedure | Router | Auth | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `applicant.getFinalDocumentStatus` | applicant | protected (team member) | Banner + team mentor panel |
|
||||||
|
| `mentor.getProjectFinalDocuments` | mentor | mentor/team access to project | Mentor workspace panel |
|
||||||
|
| `finalist.listReviewDocuments` | finalist | admin OR finale jury-group member | Judge review page |
|
||||||
|
| `finalist.sendDocumentReminders` | finalist | admin | Manual reminder blast |
|
||||||
|
|
||||||
|
(Reminder email preview reuses the existing `notification.previewEmailTemplate`.)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vitest (sequential, factories per `tests/helpers.ts`):
|
||||||
|
|
||||||
|
- `getFinalDocumentStatusForProject`: all 4 required uploaded / partial / none (`allRequiredUploaded` correct); returns `null` for a non-confirmed team and when no active LIVE_FINAL round; `deadlinePassed` reflects `windowCloseAt`.
|
||||||
|
- `listFinalistDocumentsForReview`: returns all finalist teams with correct per-requirement file mapping and `submittedCount`.
|
||||||
|
- Authorization matrix for `finalist.listReviewDocuments`: admin ✓, finale jury-group member ✓, non-finale jury member ✗, applicant ✗.
|
||||||
|
- `sendDueFinalDocReminders`: targets only CONFIRMED finalists with missing required docs inside the window; stamps `finalDocsReminderSentAt`; idempotent (no double-send).
|
||||||
|
- `finalist.sendDocumentReminders`: admin only; counts correctly.
|
||||||
|
|
||||||
|
Live-UI smoke on dev (lesson learned — catches what tests/build miss): banner renders for a finalist; team mentor panel + mentor-workspace panel render; judge page renders for an admin and for a finale jury-group member and denies a non-finale juror; upload still works; a manual reminder produces an in-app + email notification.
|
||||||
|
|
||||||
|
## Prerequisites / admin actions (outside code)
|
||||||
|
|
||||||
|
1. **Populate the "Finals Jury" group** with the actual judges (existing jury-group admin UI) — required before the review page is useful to them.
|
||||||
|
2. **Extend the Grand Final `windowCloseAt`** (currently 2026-06-11, ~2 days out) to the intended deadline.
|
||||||
|
3. Reconfigure the round's file requirements to the 4-document set (Final Presentation, Final Business Plan, 1-minute Video, Executive Summary), all `required = true` — I can do this as a guarded prod update.
|
||||||
|
|
||||||
|
## Build sequence (shippable in phases; deadline is imminent)
|
||||||
|
|
||||||
|
1. **Banner + manual admin reminder + minor polish** — makes the existing upload discoverable now (most urgent).
|
||||||
|
2. **Judge review page** + access helper + entry points.
|
||||||
|
3. **Mentor-section Final Documents panel** (team + mentor) + `mentor.getProjectFinalDocuments`.
|
||||||
|
4. **Auto reminder cron** + `GRAND_FINAL_DOCS_SUBMITTED` on-upload mentor notification + new notification types/templates/seed + migration.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Per the prod-deploy runbook: after everything is reviewed and tested (build clean, `npx vitest run` green), commit to `main`, push to `code.monaco-opc.com/MOPC/MOPC-Portal`, **track the Gitea CI build** until it publishes `mopc/mopc-portal:latest`, then redeploy on prod (`ssh stefan@89.58.5.223:22022`, `/opt/letsbe/stacks/mopc-portal`): `docker compose pull && docker compose up -d` (NEVER `-v`). Confirm the additive migration applied and the new notification-settings rows seeded, then live-smoke the banner, judge page, and a manual reminder.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Grand Finale Ceremony System — Design (Option C)
|
||||||
|
|
||||||
|
> **Status:** Approved 2026-06-10. Event is 2026-06-11 — build tonight in strict dependency order (see Build Order). Each layer must leave the system complete and operable if work stops there.
|
||||||
|
>
|
||||||
|
> Supersedes the operational scope of `2026-04-28-grand-finale-live-voting-rework.md` (the audience-window section of that spec is implemented here as designed).
|
||||||
|
|
||||||
|
## Decisions (locked with user)
|
||||||
|
|
||||||
|
1. **Jury scoring mode:** criteria scores + optional comment (like prior evaluation rounds). Live ranking mode is a stretch goal only.
|
||||||
|
2. **Audience votes:** per-category favorite windows always; an **overall-favorite** window exists behind an admin toggle (decided day-of).
|
||||||
|
3. **Vote gating:** one vote per browser token per window AND max **3 votes per IP per window** (venue NAT tolerance).
|
||||||
|
4. **Deliberation:** per category (two sessions). Existing backend (FULL_RANKING/Borda, adminDecide override) is used as-is.
|
||||||
|
5. **Architecture:** Option C = full B scope + big-screen ceremony view + results reveal controller. Big screen is **derived from existing state** — no new session-level phase machine. Only new state: reveal controller + display override slide.
|
||||||
|
6. **During audience windows the big screen shows vote count only** ("147 votes cast"), never a live per-project tally.
|
||||||
|
7. **Big-screen results reveal must be visually outstanding** — projector-grade, brand identity (dark blue `#053d57` field, red `#de0f1e` accent, Montserrat), animated transitions.
|
||||||
|
|
||||||
|
## Current foundation (verified 2026-06-10)
|
||||||
|
|
||||||
|
- `LiveProgressCursor` (cursor: activeProjectId, activeOrderIndex, isPaused) + `live.ts` router (start/jump/reorder/pause/resume/getCursor).
|
||||||
|
- `LiveVotingSession` / `LiveVote` / `AudienceVoter` + `live-voting.ts` router (criteria voting, importCriteriaFromForm, getResults, registerAudienceVoter/castAudienceVote, public results).
|
||||||
|
- Jury live page `/jury/competitions/[roundId]/live` follows cursor (poll 5s); notes textarea is **not persisted**; prior-data panel stubbed.
|
||||||
|
- Admin `live-control-panel.tsx`: prev/next/pause/resume + **client-local fake timer**.
|
||||||
|
- Public `/live-scores/[sessionId]` scoreboard with SSE.
|
||||||
|
- Deliberation backend + router 100% complete; jury deliberation page has `juryMemberId=''` and `hasVoted=false` hardcoded (jurors cannot vote).
|
||||||
|
- `publicPaths` in `auth.config.ts` does **not** include `/vote` or `/live-scores` → audience pages bounce to login. Launch blocker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Public access (do first)
|
||||||
|
|
||||||
|
Add `/vote`, `/live-scores`, `/live/ceremony` to `publicPaths` in `src/lib/auth.config.ts` (wherever publicPaths lives). Verify with `curl -I` (not the logged-in Playwright profile).
|
||||||
|
|
||||||
|
## 2. Schema changes (one migration)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// LiveProgressCursor — per-project phase + server-stamped timer
|
||||||
|
projectPhase LivePhase @default(ON_DECK) // ON_DECK | PRESENTING | QA | SCORING
|
||||||
|
phaseStartedAt DateTime?
|
||||||
|
phaseDurationSeconds Int?
|
||||||
|
phasePausedAt DateTime?
|
||||||
|
phasePausedAccumMs Int @default(0)
|
||||||
|
timingLogJson Json? // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
|
||||||
|
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks' | null
|
||||||
|
|
||||||
|
enum LivePhase { ON_DECK PRESENTING QA SCORING }
|
||||||
|
|
||||||
|
// LiveVotingSession — audience window (locked spec from 2026-04-28 + overall kind)
|
||||||
|
audiencePhase AudiencePhase @default(CLOSED) // CLOSED | OPEN
|
||||||
|
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||||
|
audienceWindowOpenedAt DateTime?
|
||||||
|
audienceWindowClosesAt DateTime?
|
||||||
|
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
|
||||||
|
|
||||||
|
enum AudiencePhase { CLOSED OPEN }
|
||||||
|
|
||||||
|
// LiveVote — optional overall comment
|
||||||
|
comment String?
|
||||||
|
|
||||||
|
model AudienceFavoriteVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
windowKey String // matches audienceWindowKey at cast time
|
||||||
|
projectId String
|
||||||
|
audienceVoterId String
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
@@unique([sessionId, windowKey, audienceVoterId])
|
||||||
|
@@index([sessionId, windowKey, ipAddress])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LiveNote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roundId String
|
||||||
|
projectId String
|
||||||
|
userId String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
@@unique([roundId, projectId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RevealState {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String @unique
|
||||||
|
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
|
||||||
|
stepsJson Json // ordered reveal steps, see §8
|
||||||
|
currentStepIndex Int @default(-1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Timer math (shared client+server helper `src/lib/live-timer.ts`): `remaining = phaseDurationSeconds − (now − phaseStartedAt − phasePausedAccumMs)`; negative = overtime, displayed `+m:ss` in red. On every phase transition the server appends a timing-log entry with `overranSeconds = max(0, elapsed − configured)`.
|
||||||
|
|
||||||
|
## 3. Ceremony control — `live.ts` router + admin panel revamp
|
||||||
|
|
||||||
|
New adminProcedure mutations on `live`:
|
||||||
|
|
||||||
|
- `sendToScreens({roundId, projectId?})` — advance cursor to project (or next), `projectPhase=ON_DECK`, no timer. This is the "next up" grace.
|
||||||
|
- `startPresentation({roundId, durationSeconds?})` — `PRESENTING`, stamp `phaseStartedAt`, duration from round config `presentationDurationMinutes` unless overridden.
|
||||||
|
- `startQA({roundId, durationSeconds?})` — close out PRESENTING into timing log, start QA timer (`qaDurationMinutes` default).
|
||||||
|
- `openScoring({roundId})` — close QA into log, `phase=SCORING`, no timer.
|
||||||
|
- `pausePhase` / `resumePhase` — stamp `phasePausedAt` / fold into `phasePausedAccumMs`.
|
||||||
|
- `setOverrideSlide({roundId, slide})` — force big-screen slide or clear.
|
||||||
|
- `getCursor` extended to return phase, timer stamps, timing log, override slide.
|
||||||
|
|
||||||
|
Admin panel (`live-control-panel.tsx` revamp):
|
||||||
|
- Project order list **grouped by category**, current/next highlighted, reorder preserved (existing `reorder` mutation), tap-to-`sendToScreens` any project (handles schedule shuffles).
|
||||||
|
- Big phase buttons in flow order; live server-derived countdown, red + counting up when over; pause/resume.
|
||||||
|
- Per-project duration override inputs (pre-filled from config).
|
||||||
|
- Timing log table (per project: presentation over by X, Q&A over by Y).
|
||||||
|
- Audience section: window open/close buttons per category + overall (gated by `allowOverallFavorite` toggle), duration picker (default 5 min), live countdown, **live vote count**, "Close now". QR button → full-screen dialog with giant QR (`qrcode.react` or equivalent tiny dep) linking to `/vote/competition/[roundId]`.
|
||||||
|
- Override-slide buttons: Welcome / Break / Deliberation / Thank you / Clear.
|
||||||
|
- Reveal section: see §9.
|
||||||
|
|
||||||
|
## 4. Jury live page
|
||||||
|
|
||||||
|
- Phase-aware: ON_DECK → "Up next: Team X" banner; PRESENTING/QA → project details, same countdown the admin sees, persistent notes; SCORING → scoring form spotlighted (form already available from PRESENTING on — early scorers not blocked).
|
||||||
|
- **Notes**: `LiveNote` autosave (debounced ~800ms) via new `live.saveNote` / `live.getMyNotes` (juryProcedure). Notes resurface in deliberation.
|
||||||
|
- Scoring: existing criteria form + new optional **comment** textarea → stored on `LiveVote.comment`. Votes upsert-editable until session COMPLETED.
|
||||||
|
|
||||||
|
## 5. Audience voting
|
||||||
|
|
||||||
|
`live-voting.ts` additions:
|
||||||
|
|
||||||
|
- `openAudienceWindow({sessionId, windowKey, durationMinutes})` (admin) — errors if already OPEN; `OVERALL` requires `allowOverallFavorite`.
|
||||||
|
- `closeAudienceWindow({sessionId})` (admin) — early close allowed anytime.
|
||||||
|
- `getAudienceWindow({sessionId})` (public) — phase, windowKey, closesAt, eligible projects (category members or all), my-vote-for-this-window (by token).
|
||||||
|
- `castFavoriteVote({sessionId, token, projectId})` (public). Server-side gates, in order:
|
||||||
|
1. `audiencePhase === OPEN`
|
||||||
|
2. `now <= audienceWindowClosesAt` (source of truth even if no cron closes it)
|
||||||
|
3. project's category matches windowKey (or OVERALL → any finalist project)
|
||||||
|
4. token valid for session
|
||||||
|
5. unique (session, windowKey, voter) — re-vote within open window **updates** the row (change-your-mind allowed while open)
|
||||||
|
6. IP cap: ≥3 distinct-voter rows for (session, windowKey, ipAddress) → reject with friendly message
|
||||||
|
- `getFavoriteTallies({sessionId})` (admin) — counts per project per windowKey.
|
||||||
|
- `setAllowOverallFavorite({sessionId, allow})` (admin).
|
||||||
|
|
||||||
|
Audience page `/vote/competition/[roundId]` (rework): scan → auto-`registerAudienceVoter`, token in localStorage → states: **waiting** ("Voting opens after the presentations" + current presenting team name), **open** (category title, project cards, tap → confirm → done, countdown chip), **voted** ("Vote recorded — you can change it until the window closes"), **closed**. Mobile-first, zero-instruction usable.
|
||||||
|
|
||||||
|
No cron needed: vote-time + read-time checks enforce the close; window auto-renders as closed everywhere once `closesAt` passes.
|
||||||
|
|
||||||
|
## 6. Deliberation completion
|
||||||
|
|
||||||
|
- Fix jury page wiring: resolve `juryMemberId` from `session.participants` matching current user; derive `hasVoted` from `session.votes`.
|
||||||
|
- Add per-project context panels to the jury deliberation page: **my finale scores** (from `LiveVote`, editable inline — edits upsert the same vote, audit-logged; "keep" = do nothing), **my live notes** (`LiveNote`), **document links** (reuse the judge-docs components/links from the finals docs feature).
|
||||||
|
- Admin: existing create-session (per category), aggregate, runoff, `adminDecide` (manual rankings), finalize, result lock — verify end-to-end, no new build expected.
|
||||||
|
|
||||||
|
## 7. Big-screen ceremony view — `/live/ceremony/[roundId]` (public)
|
||||||
|
|
||||||
|
Full-screen, no chrome, dark-blue field, Montserrat, brand accents. **Pure derivation** of state (poll ~2s + reuse SSE hook where available):
|
||||||
|
|
||||||
|
| State | Display |
|
||||||
|
|---|---|
|
||||||
|
| `overrideSlide` set | That slide (Welcome / Break / Deliberation in progress / Thank you) |
|
||||||
|
| Reveal REVEALING/ARMED | Reveal mode (§9) |
|
||||||
|
| Audience window OPEN | Giant QR + "Vote for your favorite {category}" + countdown + **vote count only** |
|
||||||
|
| Cursor ON_DECK | "Up next: Team X" |
|
||||||
|
| Cursor PRESENTING/QA | Team name, category chip, phase label, large countdown (red overtime) |
|
||||||
|
| Cursor SCORING | "Jury is scoring" interstitial |
|
||||||
|
| Nothing active | Welcome slide |
|
||||||
|
|
||||||
|
## 8. Reveal controller (admin) — §3 panel section
|
||||||
|
|
||||||
|
- "Build reveal" generates `stepsJson` from deliberation results (per category 3rd→2nd→1st) + audience favorite tallies (per-category winners, overall if enabled): `[{kind:'category-intro',category}, {kind:'place',category,place,projectId}, …, {kind:'audience-award',windowKey,projectId}, {kind:'thanks'}]`. Editable/rebuildable while DRAFT (e.g., after adminDecide changes order).
|
||||||
|
- Admin previews all steps privately. "Arm" → big screen shows a Results splash. "Next" advances `currentStepIndex` one step at a time. "Reset" → back to DRAFT, screen leaves reveal mode.
|
||||||
|
- Router: `liveVoting.buildReveal`, `armReveal`, `revealNext`, `resetReveal` (admin) + reveal state included in the public ceremony-state query (steps beyond `currentStepIndex` are **never** sent to the public endpoint).
|
||||||
|
|
||||||
|
## 9. Reveal visuals (the gorgeous part)
|
||||||
|
|
||||||
|
Use the `frontend-design` skill when building. Requirements: cinematic step transitions (place card slides/fades up, 1st-place moment visibly bigger than 3rd/2nd), confetti or equivalent flourish on 1st place and audience award, team name in very large Montserrat 700, category chip, no scores shown unless step includes them, safe on 16:9 projector at distance (high contrast, no small text). Prefer CSS/tailwind animation; adding `framer-motion` is acceptable if it materially raises quality. Must degrade gracefully (refresh mid-reveal lands on current step).
|
||||||
|
|
||||||
|
## 10. Results tally audit
|
||||||
|
|
||||||
|
- Jury results: existing `getResults` weighted aggregation + tie detection — dedicated tests.
|
||||||
|
- Audience awards are **counts from `AudienceFavoriteVote`**, kept separate from jury scores (separate awards). `audienceVoteWeight` blending stays available but is NOT used in the reveal unless explicitly configured; default 0.
|
||||||
|
|
||||||
|
## Out of scope (explicit)
|
||||||
|
|
||||||
|
Live ranking mode for jurors (stretch only, after everything else), automated tie-breaker revotes (admin_decides stands), audience phones showing live scores (vote-only page), session-level ceremony phase machine.
|
||||||
|
|
||||||
|
## Build order (cut line moves down, never breaks)
|
||||||
|
|
||||||
|
1. publicPaths fix + schema migration
|
||||||
|
2. Audience windows + favorite votes + IP cap + audience page + QR (audience system complete)
|
||||||
|
3. Phase model + server timers + admin panel revamp (ceremony operable)
|
||||||
|
4. Jury page phases + persisted notes + vote comments (jury complete)
|
||||||
|
5. Deliberation wiring fix + context panels (deliberation complete)
|
||||||
|
6. Big-screen ceremony view (derived states + override slides)
|
||||||
|
7. Reveal controller + reveal visuals
|
||||||
|
8. Tally audit tests → stretch: live ranking toggle
|
||||||
|
|
||||||
|
Each layer: vitest tests + `npm run build` green before the next.
|
||||||
|
|
||||||
|
## Test matrix (vitest, follows tests/helpers.ts factory pattern)
|
||||||
|
|
||||||
|
- Window: open/cast OK; wrong-category reject; cast after closesAt reject (no cron); early close reject; re-open works; OVERALL requires toggle.
|
||||||
|
- Favorite votes: one per token per window; re-vote updates while open; IP cap at 3 distinct voters; tallies correct.
|
||||||
|
- Phases: transition sequence; timing log entries with correct overranSeconds; pause/resume accumulator math.
|
||||||
|
- Notes: upsert per (round, project, user).
|
||||||
|
- Deliberation: juryMemberId resolution, hasVoted, vote→aggregate→finalize with real juror identity.
|
||||||
|
- Reveal: build from results, step advance, public endpoint never leaks un-revealed steps.
|
||||||
|
- Results: weighted jury aggregation + tie detection regression tests.
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -65,6 +65,7 @@
|
|||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -13417,6 +13418,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add inviteSentAt to ExternalAttendee for dish-selection email tracking
|
||||||
|
ALTER TABLE "ExternalAttendee" ADD COLUMN "inviteSentAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FinalistConfirmation" ADD COLUMN "finalDocsReminderSentAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LivePhase" AS ENUM ('ON_DECK', 'PRESENTING', 'QA', 'SCORING');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AudiencePhase" AS ENUM ('CLOSED', 'OPEN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LiveProgressCursor" ADD COLUMN "overrideSlide" TEXT,
|
||||||
|
ADD COLUMN "phaseDurationSeconds" INTEGER,
|
||||||
|
ADD COLUMN "phasePausedAccumMs" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "phasePausedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "phaseStartedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "projectPhase" "LivePhase" NOT NULL DEFAULT 'ON_DECK',
|
||||||
|
ADD COLUMN "timingLogJson" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LiveVote" ADD COLUMN "comment" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowOverallFavorite" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "audiencePhase" "AudiencePhase" NOT NULL DEFAULT 'CLOSED',
|
||||||
|
ADD COLUMN "audienceWindowClosesAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "audienceWindowKey" TEXT,
|
||||||
|
ADD COLUMN "audienceWindowOpenedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AudienceFavoriteVote" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"windowKey" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"audienceVoterId" TEXT NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AudienceFavoriteVote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LiveNote" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"roundId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LiveNote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RevealState" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"stepsJson" JSONB NOT NULL,
|
||||||
|
"currentStepIndex" INTEGER NOT NULL DEFAULT -1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RevealState_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_ipAddress_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "ipAddress");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_projectId_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AudienceFavoriteVote_sessionId_windowKey_audienceVoterId_key" ON "AudienceFavoriteVote"("sessionId", "windowKey", "audienceVoterId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LiveNote_userId_idx" ON "LiveNote"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LiveNote_roundId_projectId_userId_key" ON "LiveNote"("roundId", "projectId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RevealState_sessionId_key" ON "RevealState"("sessionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_audienceVoterId_fkey" FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RevealState" ADD CONSTRAINT "RevealState_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -347,6 +347,7 @@ model User {
|
|||||||
resourceAccess ResourceAccess[]
|
resourceAccess ResourceAccess[]
|
||||||
submittedProjects Project[] @relation("ProjectSubmittedBy")
|
submittedProjects Project[] @relation("ProjectSubmittedBy")
|
||||||
liveVotes LiveVote[]
|
liveVotes LiveVote[]
|
||||||
|
liveNotes LiveNote[]
|
||||||
|
|
||||||
// Team membership & mentorship
|
// Team membership & mentorship
|
||||||
teamMemberships TeamMember[]
|
teamMemberships TeamMember[]
|
||||||
@@ -657,6 +658,10 @@ model Project {
|
|||||||
finalistConfirmation FinalistConfirmation?
|
finalistConfirmation FinalistConfirmation?
|
||||||
externalLunchAttendees ExternalAttendee[]
|
externalLunchAttendees ExternalAttendee[]
|
||||||
|
|
||||||
|
// Grand-finale ceremony
|
||||||
|
audienceFavoriteVotes AudienceFavoriteVote[]
|
||||||
|
liveNotes LiveNote[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([tags])
|
@@index([tags])
|
||||||
@@ -1188,13 +1193,22 @@ model LiveVotingSession {
|
|||||||
audienceRequireId Boolean @default(false) // Require email/phone for audience
|
audienceRequireId Boolean @default(false) // Require email/phone for audience
|
||||||
audienceVotingDuration Int? // Minutes (null = same as jury)
|
audienceVotingDuration Int? // Minutes (null = same as jury)
|
||||||
|
|
||||||
|
// Audience favorite-vote window (grand finale)
|
||||||
|
audiencePhase AudiencePhase @default(CLOSED)
|
||||||
|
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||||
|
audienceWindowOpenedAt DateTime?
|
||||||
|
audienceWindowClosesAt DateTime?
|
||||||
|
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round Round? @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
votes LiveVote[]
|
votes LiveVote[]
|
||||||
audienceVoters AudienceVoter[]
|
audienceVoters AudienceVoter[]
|
||||||
|
favoriteVotes AudienceFavoriteVote[]
|
||||||
|
revealState RevealState?
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -1211,6 +1225,9 @@ model LiveVote {
|
|||||||
// Criteria scores (used when votingMode="criteria")
|
// Criteria scores (used when votingMode="criteria")
|
||||||
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
|
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
|
||||||
|
|
||||||
|
// Optional overall comment from the juror (grand finale)
|
||||||
|
comment String? @db.Text
|
||||||
|
|
||||||
// Audience voter link
|
// Audience voter link
|
||||||
audienceVoterId String?
|
audienceVoterId String?
|
||||||
|
|
||||||
@@ -1238,13 +1255,81 @@ model AudienceVoter {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
votes LiveVote[]
|
votes LiveVote[]
|
||||||
|
favoriteVotes AudienceFavoriteVote[]
|
||||||
|
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One pick-one-favorite vote per audience member per voting window.
|
||||||
|
// windowKey snapshots LiveVotingSession.audienceWindowKey at cast time so
|
||||||
|
// per-category and overall votes coexist in one table.
|
||||||
|
model AudienceFavoriteVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
windowKey String // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
|
||||||
|
projectId String
|
||||||
|
audienceVoterId String
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([sessionId, windowKey, audienceVoterId])
|
||||||
|
@@index([sessionId, windowKey, ipAddress])
|
||||||
|
@@index([sessionId, windowKey, projectId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-juror per-project free-text notes taken during the live ceremony.
|
||||||
|
// Resurfaces during deliberation.
|
||||||
|
model LiveNote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
roundId String
|
||||||
|
projectId String
|
||||||
|
userId String
|
||||||
|
content String @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([roundId, projectId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-driven results reveal for the big-screen ceremony view.
|
||||||
|
// Steps beyond currentStepIndex are never exposed publicly.
|
||||||
|
model RevealState {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String @unique
|
||||||
|
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
|
||||||
|
stepsJson Json @db.JsonB // RevealStep[]
|
||||||
|
currentStepIndex Int @default(-1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LivePhase {
|
||||||
|
ON_DECK
|
||||||
|
PRESENTING
|
||||||
|
QA
|
||||||
|
SCORING
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AudiencePhase {
|
||||||
|
CLOSED
|
||||||
|
OPEN
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TEAM MEMBERSHIP
|
// TEAM MEMBERSHIP
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2157,6 +2242,15 @@ model LiveProgressCursor {
|
|||||||
activeOrderIndex Int @default(0)
|
activeOrderIndex Int @default(0)
|
||||||
isPaused Boolean @default(false)
|
isPaused Boolean @default(false)
|
||||||
|
|
||||||
|
// Per-project ceremony phase + server-stamped timer (grand finale)
|
||||||
|
projectPhase LivePhase @default(ON_DECK)
|
||||||
|
phaseStartedAt DateTime?
|
||||||
|
phaseDurationSeconds Int?
|
||||||
|
phasePausedAt DateTime?
|
||||||
|
phasePausedAccumMs Int @default(0)
|
||||||
|
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
|
||||||
|
overrideSlide String? // big-screen override: 'welcome' | 'break' | 'deliberation' | 'thanks'
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -2283,6 +2377,7 @@ model Round {
|
|||||||
notificationLogs NotificationLog[]
|
notificationLogs NotificationLog[]
|
||||||
cohorts Cohort[]
|
cohorts Cohort[]
|
||||||
liveCursor LiveProgressCursor?
|
liveCursor LiveProgressCursor?
|
||||||
|
liveNotes LiveNote[]
|
||||||
|
|
||||||
@@unique([competitionId, slug])
|
@@unique([competitionId, slug])
|
||||||
@@unique([competitionId, sortOrder])
|
@@unique([competitionId, sortOrder])
|
||||||
@@ -2762,6 +2857,7 @@ model FinalistConfirmation {
|
|||||||
declineReason String? // optional free-text on decline
|
declineReason String? // optional free-text on decline
|
||||||
expiredAt DateTime?
|
expiredAt DateTime?
|
||||||
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
|
reminderSentAt DateTime? // set when the pre-deadline reminder is sent (cron)
|
||||||
|
finalDocsReminderSentAt DateTime? // set when the grand-final document-upload reminder is sent (cron)
|
||||||
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
promotedFromWaitlistEntryId String? @unique // null for original finalists
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -2983,6 +3079,7 @@ model ExternalAttendee {
|
|||||||
dishId String?
|
dishId String?
|
||||||
allergens Allergen[] @default([])
|
allergens Allergen[] @default([])
|
||||||
allergenOther String?
|
allergenOther String?
|
||||||
|
inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,20 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
description: 'Email to the attendee when their visa application status changes',
|
description: 'Email to the attendee when their visa application status changes',
|
||||||
sendEmail: true,
|
sendEmail: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'GRAND_FINAL_DOCS_REMINDER',
|
||||||
|
category: 'logistics',
|
||||||
|
label: 'Final Documents Reminder',
|
||||||
|
description: 'Reminder to finalist teams to upload their Grand Final documents before the deadline',
|
||||||
|
sendEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notificationType: 'GRAND_FINAL_DOCS_SUBMITTED',
|
||||||
|
category: 'logistics',
|
||||||
|
label: 'Final Documents Submitted',
|
||||||
|
description: 'Notifies the team mentor when a finalist uploads a Grand Final document',
|
||||||
|
sendEmail: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Admin notifications (in-app only by default)
|
// Admin notifications (in-app only by default)
|
||||||
{
|
{
|
||||||
|
|||||||
33
scripts/configure-grand-final-requirements.mjs
Normal file
33
scripts/configure-grand-final-requirements.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// scripts/configure-grand-final-requirements.mjs
|
||||||
|
// Usage: node scripts/configure-grand-final-requirements.mjs (dry-run, prints plan)
|
||||||
|
// node scripts/configure-grand-final-requirements.mjs --apply (writes)
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
const p = new PrismaClient()
|
||||||
|
const APPLY = process.argv.includes('--apply')
|
||||||
|
|
||||||
|
const TARGET = [
|
||||||
|
{ name: 'Final Presentation', acceptedMimeTypes: ['application/pdf'], sortOrder: 1, renameFrom: 'PDF presentation support' },
|
||||||
|
{ name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], sortOrder: 2 },
|
||||||
|
{ name: '1-minute Video', acceptedMimeTypes: ['video/*'], sortOrder: 3, renameFrom: '1 minute video' },
|
||||||
|
{ name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], sortOrder: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const round = await p.round.findFirst({ where: { roundType: 'LIVE_FINAL' }, orderBy: { sortOrder: 'desc' } })
|
||||||
|
if (!round) throw new Error('No LIVE_FINAL round')
|
||||||
|
const existing = await p.fileRequirement.findMany({ where: { roundId: round.id } })
|
||||||
|
console.log(`Round "${round.name}" (${round.id}); existing reqs: ${existing.map((r) => r.name).join(', ') || 'none'}`)
|
||||||
|
|
||||||
|
for (const t of TARGET) {
|
||||||
|
const match = existing.find((r) => r.name === t.name || (t.renameFrom && r.name === t.renameFrom))
|
||||||
|
if (match) {
|
||||||
|
console.log(`UPDATE "${match.name}" -> name="${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`)
|
||||||
|
if (APPLY) await p.fileRequirement.update({ where: { id: match.id }, data: { name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } })
|
||||||
|
} else {
|
||||||
|
console.log(`CREATE "${t.name}" required=true sort=${t.sortOrder} mimes=${t.acceptedMimeTypes}`)
|
||||||
|
if (APPLY) await p.fileRequirement.create({ data: { roundId: round.id, name: t.name, acceptedMimeTypes: t.acceptedMimeTypes, isRequired: true, sortOrder: t.sortOrder } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(APPLY ? 'APPLIED.' : 'DRY-RUN (pass --apply to write).')
|
||||||
|
}
|
||||||
|
run().catch((e) => { console.error(e); process.exit(1) }).finally(() => p.$disconnect())
|
||||||
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function AdminFinalsDocumentsPage() {
|
||||||
|
return <FinalsDocumentsReview />
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
FileText,
|
FileText,
|
||||||
Languages,
|
Languages,
|
||||||
|
MonitorPlay,
|
||||||
|
Scale,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -93,9 +95,14 @@ import { FileRequirementsEditor } from '@/components/admin/round/file-requiremen
|
|||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
|
||||||
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
|
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
|
||||||
|
import { LiveControlPanel } from '@/components/admin/live/live-control-panel'
|
||||||
|
import { DeliberationControlPanel } from '@/components/admin/deliberation/deliberation-control-panel'
|
||||||
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
|
||||||
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
|
||||||
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
|
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
|
||||||
|
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
|
||||||
|
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
|
||||||
|
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
|
||||||
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -970,6 +977,10 @@ export default function RoundDetailPage() {
|
|||||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
|
||||||
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
|
||||||
|
...(isGrandFinale ? [{ value: 'ceremony', label: 'Ceremony', icon: MonitorPlay }] : []),
|
||||||
|
...(round?.roundType === 'DELIBERATION'
|
||||||
|
? [{ value: 'deliberation', label: 'Deliberation', icon: Scale }]
|
||||||
|
: []),
|
||||||
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
||||||
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
|
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
|
||||||
{ value: 'config', label: 'Config', icon: Settings },
|
{ value: 'config', label: 'Config', icon: Settings },
|
||||||
@@ -1529,6 +1540,19 @@ export default function RoundDetailPage() {
|
|||||||
{isGrandFinale && programId && (
|
{isGrandFinale && programId && (
|
||||||
<>
|
<>
|
||||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<FinalDocsUploadsToggle roundId={roundId} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/admin/finals-documents">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Review finalist documents
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<FinalDocsReminderButton programId={programId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReviewDocsPicker programId={programId} roundId={roundId} />
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FinalistSlotsCard programId={programId} />
|
<FinalistSlotsCard programId={programId} />
|
||||||
<WaitlistCard programId={programId} />
|
<WaitlistCard programId={programId} />
|
||||||
@@ -1646,6 +1670,20 @@ export default function RoundDetailPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════ CEREMONY TAB (LIVE_FINAL) ═══════════ */}
|
||||||
|
{isGrandFinale && (
|
||||||
|
<TabsContent value="ceremony" className="space-y-4">
|
||||||
|
<LiveControlPanel roundId={roundId} competitionId={competitionId} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════ DELIBERATION TAB (DELIBERATION rounds) ═══════════ */}
|
||||||
|
{round?.roundType === 'DELIBERATION' && (
|
||||||
|
<TabsContent value="deliberation" className="space-y-4">
|
||||||
|
<DeliberationControlPanel roundId={roundId} competitionId={competitionId} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
|
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
|
||||||
{hasJury && !isEvaluation && (
|
{hasJury && !isEvaluation && (
|
||||||
<TabsContent value="jury" className="space-y-6">
|
<TabsContent value="jury" className="space-y-6">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||||
import { RequestChangeDialog } from './request-change-dialog'
|
import { RequestChangeDialog } from './request-change-dialog'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -216,6 +217,9 @@ export default function ApplicantMentorPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Final Documents (self-hides when not a finalist) */}
|
||||||
|
<FinalDocumentsPanel variant="team" />
|
||||||
|
|
||||||
{/* Request change dialog */}
|
{/* Request change dialog */}
|
||||||
{projectId && (
|
{projectId && (
|
||||||
<RequestChangeDialog
|
<RequestChangeDialog
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MentoringRequestCard } from '@/components/applicant/mentoring-request-c
|
|||||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card'
|
||||||
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
import { AttendingMembersCard } from '@/components/applicant/attending-members-card'
|
||||||
import { MyLogisticsCard } from '@/components/applicant/my-logistics-card'
|
import { MyLogisticsCard } from '@/components/applicant/my-logistics-card'
|
||||||
|
import { FinalDocumentsBanner } from '@/components/applicant/final-documents-banner'
|
||||||
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
import { LunchBanner } from '@/components/applicant/lunch-banner'
|
||||||
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
import { ExternalAttendeesStrip } from '@/components/applicant/external-attendees-strip'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -206,6 +207,9 @@ export default function ApplicantDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grand Final document upload banner (auto-hides for non-finalists) */}
|
||||||
|
<FinalDocumentsBanner />
|
||||||
|
|
||||||
{/* Active round deadline banner */}
|
{/* Active round deadline banner */}
|
||||||
{!isRejected && openRounds.length > 0 && (() => {
|
{!isRejected && openRounds.length > 0 && (() => {
|
||||||
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING'])
|
||||||
|
|||||||
@@ -1,76 +1,142 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { use, useState } from 'react'
|
import { use, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
|
||||||
import { LiveVotingForm } from '@/components/jury/live-voting-form'
|
import { LiveVotingForm } from '@/components/jury/live-voting-form'
|
||||||
|
import { remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||||
|
import { Clock, Mic2, MessageCircleQuestion, PenLine, Sparkles } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const PHASE_META: Record<string, { label: string; icon: typeof Mic2 }> = {
|
||||||
|
PRESENTING: { label: 'Presentation', icon: Mic2 },
|
||||||
|
QA: { label: 'Q&A', icon: MessageCircleQuestion },
|
||||||
|
SCORING: { label: 'Scoring open', icon: PenLine },
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseCountdown({ phase }: { phase: {
|
||||||
|
phaseStartedAt: Date | string | null
|
||||||
|
phaseDurationSeconds: number | null
|
||||||
|
phasePausedAt: Date | string | null
|
||||||
|
phasePausedAccumMs: number
|
||||||
|
} }) {
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
const remaining = remainingSeconds(phase)
|
||||||
|
if (remaining === null) return null
|
||||||
|
const over = remaining < 0
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={over ? 'destructive' : 'secondary'}
|
||||||
|
className={`gap-1 tabular-nums text-sm ${over ? 'animate-pulse' : ''}`}
|
||||||
|
>
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{formatClock(remaining)}
|
||||||
|
{over && <span className="font-semibold">OVER</span>}
|
||||||
|
{phase.phasePausedAt && <span>· paused</span>}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
||||||
const params = use(paramsPromise)
|
const params = use(paramsPromise)
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [notes, setNotes] = useState('')
|
|
||||||
const [priorDataOpen, setPriorDataOpen] = useState(false)
|
|
||||||
|
|
||||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId })
|
const { data: cursor } = trpc.live.getCursor.useQuery(
|
||||||
|
{ roundId: params.roundId },
|
||||||
// Fetch live voting session data
|
{ refetchInterval: 2000 }
|
||||||
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
|
|
||||||
{ sessionId: params.roundId },
|
|
||||||
{ enabled: !!params.roundId, refetchInterval: 2000 }
|
|
||||||
)
|
)
|
||||||
|
const { data: sessionData } = trpc.liveVoting.getSessionForVotingByRound.useQuery(
|
||||||
|
{ roundId: params.roundId },
|
||||||
|
{ refetchInterval: 2000 }
|
||||||
|
)
|
||||||
|
const { data: myNotes } = trpc.live.getMyNotes.useQuery({ roundId: params.roundId })
|
||||||
|
|
||||||
// Placeholder for prior data - this would need to be implemented in evaluation router
|
// ── Persisted notes (autosave, keyed per project) ────────────────────────
|
||||||
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null
|
const [noteDrafts, setNoteDrafts] = useState<Record<string, string>>({})
|
||||||
|
const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
|
||||||
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
onSuccess: () => {
|
const saveNote = trpc.live.saveNote.useMutation({
|
||||||
utils.liveVoting.getSessionForVoting.invalidate()
|
onSuccess: () => setNoteStatus('saved'),
|
||||||
toast.success('Vote submitted successfully')
|
onError: () => setNoteStatus('idle'),
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast.error(err.message)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => {
|
const activeProject = cursor?.activeProject ?? null
|
||||||
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id
|
const activeProjectId = activeProject?.id ?? null
|
||||||
if (!projectId) return
|
|
||||||
|
|
||||||
const sessionId = sessionData?.session?.id || params.roundId
|
const savedNoteFor = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const n of myNotes ?? []) map[n.projectId] = n.content
|
||||||
|
return map
|
||||||
|
}, [myNotes])
|
||||||
|
|
||||||
|
const currentDraft =
|
||||||
|
activeProjectId != null
|
||||||
|
? noteDrafts[activeProjectId] ?? savedNoteFor[activeProjectId] ?? ''
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const handleNoteChange = (value: string) => {
|
||||||
|
if (!activeProjectId) return
|
||||||
|
setNoteDrafts((d) => ({ ...d, [activeProjectId]: value }))
|
||||||
|
setNoteStatus('saving')
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||||
|
const projectId = activeProjectId
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
saveNote.mutate({ roundId: params.roundId, projectId, content: value })
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voting ───────────────────────────────────────────────────────────────
|
||||||
|
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.liveVoting.getSessionForVotingByRound.invalidate()
|
||||||
|
toast.success('Vote submitted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleVoteSubmit = (vote: {
|
||||||
|
score: number
|
||||||
|
criterionScores?: Record<string, number>
|
||||||
|
comment?: string
|
||||||
|
}) => {
|
||||||
|
if (!activeProjectId || !sessionData?.session?.id) return
|
||||||
submitVoteMutation.mutate({
|
submitVoteMutation.mutate({
|
||||||
sessionId,
|
sessionId: sessionData.session.id,
|
||||||
projectId,
|
projectId: activeProjectId,
|
||||||
score: vote.score,
|
score: vote.score,
|
||||||
criterionScores: vote.criterionScores,
|
criterionScores: vote.criterionScores,
|
||||||
|
comment: vote.comment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract voting mode and criteria from session
|
|
||||||
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
||||||
const criteria = (sessionData?.session?.criteriaJson as Array<{
|
const criteria = sessionData?.session?.criteriaJson as
|
||||||
id: string
|
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||||
label: string
|
| undefined
|
||||||
description?: string
|
|
||||||
scale: number
|
|
||||||
weight: number
|
|
||||||
}> | undefined)
|
|
||||||
|
|
||||||
const activeProject = cursor?.activeProject || sessionData?.currentProject
|
const phase = cursor?.projectPhase ?? 'ON_DECK'
|
||||||
|
const categoryLabel =
|
||||||
|
activeProject?.competitionCategory === 'STARTUP'
|
||||||
|
? 'Startup'
|
||||||
|
: activeProject?.competitionCategory === 'BUSINESS_CONCEPT'
|
||||||
|
? 'Business Concept'
|
||||||
|
: null
|
||||||
|
|
||||||
if (!activeProject) {
|
if (!activeProject) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground">Waiting for ceremony to begin...</p>
|
<Sparkles className="mb-3 h-8 w-8 text-brand-teal/60" />
|
||||||
|
<p className="font-medium">Waiting for the ceremony to begin…</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
The admin will control which project is displayed
|
Projects will appear here automatically as they take the stage
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -78,105 +144,116 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ON_DECK: "Up next" banner, no scoring yet ───────────────────────────
|
||||||
|
if (phase === 'ON_DECK') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="overflow-hidden border-0 bg-gradient-to-r from-[#053d57] to-[#0a5a7c] text-white">
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
|
||||||
|
Up next
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">{activeProject.title}</h1>
|
||||||
|
{activeProject.teamName && (
|
||||||
|
<p className="mt-2 text-lg text-white/80">{activeProject.teamName}</p>
|
||||||
|
)}
|
||||||
|
{categoryLabel && (
|
||||||
|
<Badge className="mt-4 bg-white/15 text-white hover:bg-white/15">{categoryLabel}</Badge>
|
||||||
|
)}
|
||||||
|
<p className="mt-6 text-sm text-white/60">Presentation starting shortly</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{activeProject.description && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">About this project</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING
|
||||||
|
const PhaseIcon = phaseMeta.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Current Project Display */}
|
{/* Current Project + phase */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
|
||||||
<CardDescription className="mt-2">
|
<CardDescription className="mt-1">
|
||||||
Live project presentation
|
{activeProject.teamName}
|
||||||
|
{categoryLabel ? ` · ${categoryLabel}` : ''}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{votingMode === 'criteria' && (
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">Criteria Voting</Badge>
|
<Badge variant={phase === 'SCORING' ? 'default' : 'outline'} className="gap-1.5">
|
||||||
)}
|
<PhaseIcon className="h-3.5 w-3.5" />
|
||||||
|
{phaseMeta.label}
|
||||||
|
</Badge>
|
||||||
|
{cursor && <PhaseCountdown phase={cursor} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{activeProject.description && (
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes — persisted, autosaved */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Your Notes</CardTitle>
|
||||||
|
<CardDescription>Private — resurfaced during deliberation</CardDescription>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{noteStatus === 'saving' ? 'Saving…' : noteStatus === 'saved' ? 'Saved' : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
{activeProject.description && (
|
|
||||||
<p className="text-muted-foreground">{activeProject.description}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Prior Jury Data (Collapsible) */}
|
|
||||||
{priorData && (
|
|
||||||
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
|
|
||||||
<Card>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<CardHeader className="cursor-pointer hover:bg-muted/50">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
|
|
||||||
{priorDataOpen ? (
|
|
||||||
<ChevronUp className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold">
|
|
||||||
{priorData.averageScore?.toFixed(1) || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{priorData.strengths && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
|
|
||||||
<p className="mt-1 text-sm">{priorData.strengths}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{priorData.weaknesses && (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
|
|
||||||
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Card>
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Your Notes</CardTitle>
|
|
||||||
<CardDescription>Optional notes for this project</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={notes}
|
value={currentDraft}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => handleNoteChange(e.target.value)}
|
||||||
placeholder="Add your observations and comments..."
|
placeholder="Observations during the presentation and Q&A…"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Voting Form */}
|
{/* Scoring — available from presentation start, spotlighted at SCORING.
|
||||||
|
Keyed on vote presence: the form initializes its editing state from
|
||||||
|
existingVote, which arrives async after mount. */}
|
||||||
<LiveVotingForm
|
<LiveVotingForm
|
||||||
|
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
|
||||||
projectId={activeProject.id}
|
projectId={activeProject.id}
|
||||||
votingMode={votingMode}
|
votingMode={votingMode}
|
||||||
criteria={criteria}
|
criteria={criteria}
|
||||||
existingVote={sessionData?.userVote ? {
|
existingVote={
|
||||||
score: sessionData.userVote.score,
|
sessionData?.userVote
|
||||||
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined
|
? {
|
||||||
} : null}
|
score: sessionData.userVote.score,
|
||||||
|
criterionScoresJson: sessionData.userVote.criterionScoresJson as
|
||||||
|
| Record<string, number>
|
||||||
|
| undefined,
|
||||||
|
comment: sessionData.userVote.comment,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
onVoteSubmit={handleVoteSubmit}
|
onVoteSubmit={handleVoteSubmit}
|
||||||
disabled={submitVoteMutation.isPending}
|
disabled={submitVoteMutation.isPending}
|
||||||
|
highlighted={phase === 'SCORING'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,150 +1,326 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { use } from 'react';
|
import { use, useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import Link from 'next/link'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CheckCircle2 } from 'lucide-react';
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from 'sonner';
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'
|
||||||
|
import { LiveVotingForm } from '@/components/jury/live-voting-form'
|
||||||
|
import { CheckCircle2, ChevronDown, FileText, PenLine, StickyNote } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) {
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
const params = use(paramsPromise);
|
BUSINESS_CONCEPT: 'Business Concepts',
|
||||||
const utils = trpc.useUtils();
|
STARTUP: 'Startups',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-project review context during deliberation: the juror's finale scores
|
||||||
|
* (revisable in place — "keep" is simply not touching them), their ceremony
|
||||||
|
* notes, and a pointer to the project documents.
|
||||||
|
*/
|
||||||
|
function ProjectReviewCard({
|
||||||
|
project,
|
||||||
|
roundId,
|
||||||
|
finaleInputs,
|
||||||
|
votingMode,
|
||||||
|
criteria,
|
||||||
|
}: {
|
||||||
|
project: { id: string; title: string; teamName?: string | null }
|
||||||
|
roundId: string
|
||||||
|
finaleInputs: any
|
||||||
|
votingMode: 'simple' | 'criteria'
|
||||||
|
criteria?: Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const myVote = finaleInputs?.votes?.find((v: any) => v.projectId === project.id)
|
||||||
|
const myNote = finaleInputs?.notes?.find((n: any) => n.projectId === project.id)
|
||||||
|
|
||||||
|
const voteMutation = trpc.liveVoting.vote.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.liveVoting.getMyFinaleInputs.invalidate({ roundId })
|
||||||
|
toast.success('Score updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<Card>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<CardHeader className="cursor-pointer py-4 hover:bg-muted/40">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||||
|
{project.teamName && (
|
||||||
|
<CardDescription className="mt-0.5">{project.teamName}</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{myVote ? (
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
My score: {myVote.score}/10
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Not scored</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="space-y-4 border-t pt-4">
|
||||||
|
{myNote?.content && (
|
||||||
|
<div className="rounded-lg bg-muted/40 p-3">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
<StickyNote className="h-3.5 w-3.5" />
|
||||||
|
Your ceremony notes
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{myNote.content}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
<PenLine className="h-3.5 w-3.5" />
|
||||||
|
Your grand-finale score — edit to revise, or leave as-is to keep it
|
||||||
|
</p>
|
||||||
|
{finaleInputs?.session?.id ? (
|
||||||
|
<LiveVotingForm
|
||||||
|
key={`${project.id}-${myVote?.votedAt ?? 'fresh'}`}
|
||||||
|
projectId={project.id}
|
||||||
|
votingMode={votingMode}
|
||||||
|
criteria={criteria}
|
||||||
|
existingVote={
|
||||||
|
myVote
|
||||||
|
? {
|
||||||
|
score: myVote.score,
|
||||||
|
criterionScoresJson: myVote.criterionScoresJson as
|
||||||
|
| Record<string, number>
|
||||||
|
| undefined,
|
||||||
|
comment: myVote.comment,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onVoteSubmit={(vote) =>
|
||||||
|
voteMutation.mutate({
|
||||||
|
sessionId: finaleInputs.session.id,
|
||||||
|
projectId: project.id,
|
||||||
|
score: vote.score,
|
||||||
|
criterionScores: vote.criterionScores,
|
||||||
|
comment: vote.comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={voteMutation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No finale voting session found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/jury/finals-documents">
|
||||||
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Open project documents
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JuryDeliberationPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ sessionId: string }>
|
||||||
|
}) {
|
||||||
|
const params = use(paramsPromise)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: me } = trpc.user.me.useQuery()
|
||||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
||||||
{ sessionId: params.sessionId },
|
{ sessionId: params.sessionId },
|
||||||
{ refetchInterval: 10_000 },
|
{ refetchInterval: 10_000 }
|
||||||
);
|
)
|
||||||
|
// The deliberation session points at its round; finale inputs live on the
|
||||||
|
// LIVE_FINAL round's voting session — resolve via my ceremony context.
|
||||||
|
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery()
|
||||||
|
const finaleRoundId = ceremony?.liveRoundId ?? null
|
||||||
|
const { data: finaleInputs } = trpc.liveVoting.getMyFinaleInputs.useQuery(
|
||||||
|
{ roundId: finaleRoundId ?? '' },
|
||||||
|
{ enabled: !!finaleRoundId }
|
||||||
|
)
|
||||||
|
|
||||||
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
|
const [submitting, setSubmitting] = useState(false)
|
||||||
onSuccess: () => {
|
const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
|
||||||
utils.deliberation.getSession.invalidate();
|
|
||||||
toast.success('Vote submitted successfully');
|
const handleSubmitVote = async (
|
||||||
},
|
votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
|
||||||
onError: (err) => {
|
) => {
|
||||||
toast.error(err.message);
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
for (const vote of votes) {
|
||||||
|
await submitVoteMutation.mutateAsync({
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
projectId: vote.projectId,
|
||||||
|
rank: vote.rank,
|
||||||
|
isWinnerPick: vote.isWinnerPick,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success('Your ranking has been submitted')
|
||||||
|
utils.deliberation.getSession.invalidate({ sessionId: params.sessionId })
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to submit vote')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
|
if (isLoading || !me) {
|
||||||
votes.forEach((vote) => {
|
|
||||||
submitVoteMutation.mutate({
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
|
|
||||||
projectId: vote.projectId,
|
|
||||||
rank: vote.rank,
|
|
||||||
isWinnerPick: vote.isWinnerPick
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
<Card>
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<p className="text-muted-foreground">Loading session…</p>
|
||||||
<p className="text-muted-foreground">Loading session...</p>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
<Card>
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
<CardContent className="flex items-center justify-center py-12">
|
<p className="text-muted-foreground">Session not found</p>
|
||||||
<p className="text-muted-foreground">Session not found</p>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasVoted = false; // TODO: check if current user has voted in this session
|
const isParticipant = (session.participants ?? []).some(
|
||||||
|
(p: any) => p.user?.user?.id === me.id
|
||||||
|
)
|
||||||
|
const hasVoted = (session.votes ?? []).some(
|
||||||
|
(v: any) => v.juryMember?.user?.id === me.id && v.runoffRound === 0
|
||||||
|
)
|
||||||
|
const projects = ((session as any).projects ?? []) as Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
teamName?: string | null
|
||||||
|
}>
|
||||||
|
const votingMode = (finaleInputs?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
|
||||||
|
const criteria = finaleInputs?.session?.criteriaJson as
|
||||||
|
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
|
||||||
|
| undefined
|
||||||
|
|
||||||
if (session.status !== 'VOTING') {
|
const header = (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Deliberation — {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
|
||||||
|
<CardDescription className="mt-1">{session.round?.name}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge>{session.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
const reviewSection = projects.length > 0 && finaleRoundId && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Review Before You Rank</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your grand-finale scores, notes and the project documents — revise a score or keep it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<ProjectReviewCard
|
||||||
|
key={p.id}
|
||||||
|
project={p}
|
||||||
|
roundId={finaleRoundId}
|
||||||
|
finaleInputs={finaleInputs}
|
||||||
|
votingMode={votingMode}
|
||||||
|
criteria={criteria}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{header}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
<CardTitle>Deliberation Session</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{session.round?.name} - {session.category}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{session.status === 'DELIB_OPEN'
|
{session.status === 'DELIB_OPEN'
|
||||||
? 'Voting has not started yet. Please wait for the admin to open voting.'
|
? 'Voting has not started yet — you can already review the projects below.'
|
||||||
: session.status === 'TALLYING'
|
: session.status === 'TALLYING'
|
||||||
? 'Voting is closed. Results are being tallied.'
|
? 'Voting is closed. Results are being tallied.'
|
||||||
: 'This session is locked.'}
|
: 'This session is locked.'}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{session.status === 'DELIB_OPEN' && reviewSection}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVoted) {
|
if (!isParticipant) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{header}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
<div className="flex items-start justify-between">
|
<p className="text-muted-foreground">
|
||||||
<div>
|
You are not a participant of this deliberation session.
|
||||||
<CardTitle>Deliberation Session</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
{session.round?.name} - {session.category}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge>{session.status}</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
|
|
||||||
<p className="font-medium">Vote Submitted</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Thank you for your participation in this deliberation
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
{header}
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Deliberation Session</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
{session.round?.name} - {session.category}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Badge>{session.status}</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{session.mode === 'SINGLE_WINNER_VOTE'
|
|
||||||
? 'Select your top choice for this category.'
|
|
||||||
: 'Rank all projects from best to least preferred.'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<DeliberationRankingForm
|
{hasVoted ? (
|
||||||
projects={session.results?.map((r) => r.project) ?? []}
|
<Card>
|
||||||
mode={session.mode}
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
onSubmit={handleSubmitVote}
|
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
|
||||||
disabled={submitVoteMutation.isPending}
|
<p className="font-medium">Ranking Submitted</p>
|
||||||
/>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Thank you — the chair will review the collective result.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{reviewSection}
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">
|
||||||
|
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Pick Your Winner' : 'Your Ranking'}
|
||||||
|
</h2>
|
||||||
|
<DeliberationRankingForm
|
||||||
|
projects={projects}
|
||||||
|
mode={session.mode}
|
||||||
|
onSubmit={handleSubmitVote}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
7
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function FinalsDocumentsPage() {
|
||||||
|
return <FinalsDocumentsReview />
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ import {
|
|||||||
Waves,
|
Waves,
|
||||||
Send,
|
Send,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -42,6 +44,70 @@ function getGreeting(): string {
|
|||||||
return 'Good evening'
|
return 'Good evening'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prominent entry point to the finalist documents review, shown only to
|
||||||
|
* Grand-Final jury members (and admins). Rendered at the top of the dashboard
|
||||||
|
* regardless of whether the juror has individual assignments, so finals jurors
|
||||||
|
* can always find the teams' files in one obvious place.
|
||||||
|
*/
|
||||||
|
async function FinalsJuryBanner() {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
if (!userId) return null
|
||||||
|
|
||||||
|
const program = await prisma.program.findFirst({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
orderBy: { year: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!program) return null
|
||||||
|
|
||||||
|
const canReview = await userCanReviewFinals(prisma, userId, session.user.role, program.id)
|
||||||
|
if (!canReview) return null
|
||||||
|
|
||||||
|
const round = await getOpenFinaleRound(prisma, program.id)
|
||||||
|
const teamCount = round
|
||||||
|
? await prisma.projectRoundState.count({ where: { roundId: round.id } })
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedCard index={0}>
|
||||||
|
<Card className="overflow-hidden border-0 shadow-lg">
|
||||||
|
<div className="rounded-lg bg-gradient-to-r from-brand-blue to-brand-teal p-[1px]">
|
||||||
|
<CardContent className="flex flex-col gap-4 rounded-[7px] bg-background p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="shrink-0 rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
|
||||||
|
<Trophy className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-teal">
|
||||||
|
Grand Final
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-brand-blue">Finalist Documents</h2>
|
||||||
|
<p className="mt-0.5 max-w-md text-sm text-muted-foreground">
|
||||||
|
{teamCount > 0 ? `All ${teamCount} finalist teams’ ` : 'Every finalist team’s '}
|
||||||
|
pitch decks, business plans, executive summaries and videos — in one place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="w-full shrink-0 bg-brand-blue shadow-md hover:bg-brand-blue-light sm:w-auto"
|
||||||
|
>
|
||||||
|
<Link href="/jury/finals-documents">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Review Finalist Documents
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function JuryDashboardContent() {
|
async function JuryDashboardContent() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
const userId = session?.user?.id
|
const userId = session?.user?.id
|
||||||
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
|
|||||||
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
||||||
<JuryPreferencesBanner />
|
<JuryPreferencesBanner />
|
||||||
|
|
||||||
|
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<FinalsJuryBanner />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<DashboardSkeleton />}>
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
<JuryDashboardContent />
|
<JuryDashboardContent />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
|
||||||
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { FinalDocumentsPanel } from '@/components/applicant/final-documents-panel'
|
||||||
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
import { ArrowLeft, MessageSquare, FileText, Upload, Users } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -186,6 +187,9 @@ export default function MentorWorkspaceDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Final Documents (self-hides when not a finalist) */}
|
||||||
|
<FinalDocumentsPanel variant="mentor" projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
535
src/app/(public)/live/ceremony/[roundId]/page.tsx
Normal file
535
src/app/(public)/live/ceremony/[roundId]/page.tsx
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Big-screen ceremony view — projected on stage at the grand finale.
|
||||||
|
* Award-night broadcast aesthetic: deep layered ocean field, extreme
|
||||||
|
* Montserrat scale contrast, red as a scalpel accent, gold reserved for the
|
||||||
|
* winner moment. Pure derivation of server state (poll 2s), full-bleed over
|
||||||
|
* the public layout, no interactive chrome.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { use, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { remainingSeconds, formatClock } from '@/lib/live-timer'
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
BUSINESS_CONCEPT: 'Business Concepts',
|
||||||
|
STARTUP: 'Startups',
|
||||||
|
}
|
||||||
|
const WINDOW_TITLE: Record<string, string> = {
|
||||||
|
'CATEGORY:BUSINESS_CONCEPT': 'Vote for your favorite Business Concept',
|
||||||
|
'CATEGORY:STARTUP': 'Vote for your favorite Startup',
|
||||||
|
OVERALL: 'Vote for your favorite project of the night',
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTick() {
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Atmosphere ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OceanField({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-hidden bg-[#021f2e] font-[Montserrat,sans-serif] text-white">
|
||||||
|
{/* Layered ocean-light gradients */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(120% 90% at 50% 110%, #0a5a7c 0%, #053d57 45%, #021f2e 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute -inset-x-1/4 top-[-40%] h-[80%] opacity-25"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(50% 100% at 50% 0%, rgba(85,127,140,0.9) 0%, transparent 70%)',
|
||||||
|
}}
|
||||||
|
animate={{ x: ['-8%', '8%', '-8%'] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 18, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
{/* Grain for projector richness */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBar({ programName, label }: { programName: string | null; label?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-12 py-8">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
|
||||||
|
{programName ?? 'Monaco Ocean Protection Challenge'}
|
||||||
|
</p>
|
||||||
|
{label && (
|
||||||
|
<p className="flex items-center gap-3 text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-[#de0f1e]" />
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignatureRule() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-48 items-center gap-0">
|
||||||
|
<div className="h-px flex-1 bg-white/25" />
|
||||||
|
<div className="h-[3px] w-10 bg-[#de0f1e]" />
|
||||||
|
<div className="h-px flex-1 bg-white/25" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slideIn = {
|
||||||
|
initial: { opacity: 0, y: 36, scale: 0.985, filter: 'blur(6px)' },
|
||||||
|
animate: { opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' },
|
||||||
|
exit: { opacity: 0, y: -24, scale: 0.99, filter: 'blur(4px)' },
|
||||||
|
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Slides ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StaticSlide({ kind, programName }: { kind: string; programName: string | null }) {
|
||||||
|
const copy: Record<string, { eyebrow: string; title: string; sub?: string }> = {
|
||||||
|
welcome: {
|
||||||
|
eyebrow: programName ?? 'Monaco Ocean Protection Challenge',
|
||||||
|
title: 'Grand Finale',
|
||||||
|
sub: 'Welcome',
|
||||||
|
},
|
||||||
|
break: { eyebrow: 'Intermission', title: 'Back shortly', sub: 'Enjoy the break' },
|
||||||
|
deliberation: {
|
||||||
|
eyebrow: 'The jury has retired',
|
||||||
|
title: 'Deliberation in progress',
|
||||||
|
sub: 'Results follow shortly',
|
||||||
|
},
|
||||||
|
thanks: { eyebrow: programName ?? 'Grand Finale', title: 'Thank you', sub: 'See you next year' },
|
||||||
|
}
|
||||||
|
const c = copy[kind] ?? copy.welcome
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||||
|
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">{c.eyebrow}</p>
|
||||||
|
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold leading-none tracking-tight">
|
||||||
|
{c.title}
|
||||||
|
</h1>
|
||||||
|
<SignatureRule />
|
||||||
|
{c.sub && <p className="text-2xl font-light text-white/70">{c.sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseSlide({
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
state: {
|
||||||
|
phase: {
|
||||||
|
projectPhase: string
|
||||||
|
phaseStartedAt: string | Date | null
|
||||||
|
phaseDurationSeconds: number | null
|
||||||
|
phasePausedAt: string | Date | null
|
||||||
|
phasePausedAccumMs: number
|
||||||
|
} | null
|
||||||
|
activeProject: { title: string; teamName: string | null; competitionCategory: string | null } | null
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
useTick()
|
||||||
|
const phase = state.phase
|
||||||
|
const project = state.activeProject
|
||||||
|
if (!phase || !project) return <StaticSlide kind="welcome" programName={null} />
|
||||||
|
|
||||||
|
const remaining = remainingSeconds(phase)
|
||||||
|
const over = remaining !== null && remaining < 0
|
||||||
|
const category = project.competitionCategory
|
||||||
|
? CATEGORY_LABEL[project.competitionCategory]
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (phase.projectPhase === 'ON_DECK') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-xl font-semibold uppercase tracking-[0.5em] text-[#557f8c]"
|
||||||
|
>
|
||||||
|
Up next
|
||||||
|
</motion.p>
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 30, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 80, damping: 16, delay: 0.15 }}
|
||||||
|
className="max-w-[90vw] text-[clamp(3.5rem,9vw,8rem)] font-extrabold leading-[1.02] tracking-tight"
|
||||||
|
>
|
||||||
|
{project.teamName ?? project.title}
|
||||||
|
</motion.h1>
|
||||||
|
<SignatureRule />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{project.teamName && <p className="text-3xl font-light text-white/80">{project.title}</p>}
|
||||||
|
{category && (
|
||||||
|
<p className="text-lg font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||||
|
{category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase.projectPhase === 'SCORING') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||||
|
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">
|
||||||
|
{project.teamName ?? project.title}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-[clamp(3rem,7vw,6rem)] font-extrabold tracking-tight">
|
||||||
|
The jury is scoring
|
||||||
|
</h1>
|
||||||
|
<SignatureRule />
|
||||||
|
<motion.div
|
||||||
|
className="flex gap-3"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{ visible: { transition: { staggerChildren: 0.25 } } }}
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<motion.span
|
||||||
|
key={i}
|
||||||
|
className="h-3 w-3 rounded-full bg-[#557f8c]"
|
||||||
|
animate={{ opacity: [0.25, 1, 0.25] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.6, delay: i * 0.3 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRESENTING / QA
|
||||||
|
const phaseLabel = phase.projectPhase === 'QA' ? 'Q&A' : 'Presentation'
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{category && (
|
||||||
|
<p className="text-base font-semibold uppercase tracking-[0.4em] text-white/45">
|
||||||
|
{category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h1 className="max-w-[92vw] text-[clamp(3rem,8vw,7rem)] font-extrabold leading-[1.03] tracking-tight">
|
||||||
|
{project.teamName ?? project.title}
|
||||||
|
</h1>
|
||||||
|
{project.teamName && (
|
||||||
|
<p className="text-2xl font-light text-white/70">{project.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SignatureRule />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#557f8c]">
|
||||||
|
{phaseLabel}
|
||||||
|
</p>
|
||||||
|
{remaining !== null && (
|
||||||
|
<motion.p
|
||||||
|
className={`text-[clamp(4rem,9vw,8rem)] font-bold tabular-nums leading-none ${
|
||||||
|
over ? 'text-[#de0f1e]' : 'text-white'
|
||||||
|
}`}
|
||||||
|
animate={over ? { opacity: [1, 0.55, 1] } : {}}
|
||||||
|
transition={over ? { repeat: Infinity, duration: 1.2 } : {}}
|
||||||
|
style={over ? { textShadow: '0 0 60px rgba(222,15,30,0.55)' } : {}}
|
||||||
|
>
|
||||||
|
{formatClock(remaining)}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
{phase.phasePausedAt && (
|
||||||
|
<p className="text-base font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||||
|
paused
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudienceVoteSlide({
|
||||||
|
windowKey,
|
||||||
|
closesAt,
|
||||||
|
voteCount,
|
||||||
|
voteUrl,
|
||||||
|
}: {
|
||||||
|
windowKey: string | null
|
||||||
|
closesAt: string | Date | null
|
||||||
|
voteCount: number
|
||||||
|
voteUrl: string
|
||||||
|
}) {
|
||||||
|
useTick()
|
||||||
|
const secondsLeft = closesAt
|
||||||
|
? Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center gap-24 px-20">
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.015, 1] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
|
||||||
|
className="shrink-0 rounded-[2.5rem] bg-white p-10 shadow-[0_0_120px_rgba(85,127,140,0.45)]"
|
||||||
|
>
|
||||||
|
{voteUrl && <QRCodeSVG value={voteUrl} size={400} />}
|
||||||
|
</motion.div>
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#de0f1e]">
|
||||||
|
Audience vote — open now
|
||||||
|
</p>
|
||||||
|
<h1 className="text-[clamp(2.5rem,5.5vw,4.5rem)] font-extrabold leading-tight tracking-tight">
|
||||||
|
{WINDOW_TITLE[windowKey ?? ''] ?? 'Vote for your favorite'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-2xl font-light text-white/70">
|
||||||
|
Scan the code with your phone — one vote each
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end gap-14 pt-2">
|
||||||
|
{secondsLeft !== null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||||
|
Closes in
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-7xl font-bold tabular-nums ${
|
||||||
|
secondsLeft <= 30 ? 'text-[#de0f1e]' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatClock(secondsLeft)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
|
||||||
|
Votes cast
|
||||||
|
</p>
|
||||||
|
<motion.p
|
||||||
|
key={voteCount}
|
||||||
|
initial={{ scale: 1.25, color: '#de0f1e' }}
|
||||||
|
animate={{ scale: 1, color: '#ffffff' }}
|
||||||
|
className="text-7xl font-bold tabular-nums"
|
||||||
|
>
|
||||||
|
{voteCount}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reveal ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Confetti({ gold }: { gold?: boolean }) {
|
||||||
|
const pieces = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 56 }, (_, i) => ({
|
||||||
|
left: ((i * 137.5) % 100),
|
||||||
|
delay: (i % 14) * 0.09,
|
||||||
|
duration: 2.6 + ((i * 7) % 10) / 6,
|
||||||
|
size: 7 + ((i * 13) % 9),
|
||||||
|
rotate: (i * 73) % 360,
|
||||||
|
color: gold
|
||||||
|
? ['#e8c34a', '#de0f1e', '#ffffff', '#f0d98c'][i % 4]
|
||||||
|
: ['#de0f1e', '#557f8c', '#ffffff', '#9fc3cf'][i % 4],
|
||||||
|
})),
|
||||||
|
[gold]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
{pieces.map((p, i) => (
|
||||||
|
<motion.span
|
||||||
|
key={i}
|
||||||
|
className="absolute top-[-5%] block"
|
||||||
|
style={{
|
||||||
|
left: `${p.left}%`,
|
||||||
|
width: p.size,
|
||||||
|
height: p.size * 0.45,
|
||||||
|
backgroundColor: p.color,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
initial={{ y: '-10vh', rotate: p.rotate, opacity: 0 }}
|
||||||
|
animate={{ y: '115vh', rotate: p.rotate + 540, opacity: [0, 1, 1, 0.8] }}
|
||||||
|
transition={{ duration: p.duration, delay: p.delay, ease: 'easeIn' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevealStep = {
|
||||||
|
kind: string
|
||||||
|
category?: string
|
||||||
|
place?: number
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevealSlide({ step }: { step: RevealStep }) {
|
||||||
|
const isWinner = step.kind === 'place' && step.place === 1
|
||||||
|
const isAudience = step.kind === 'audience-award' || step.kind === 'overall-favorite'
|
||||||
|
|
||||||
|
if (step.kind === 'category-intro') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||||
|
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">Results</p>
|
||||||
|
<h1 className="text-[clamp(4rem,9vw,8rem)] font-extrabold tracking-tight">
|
||||||
|
{step.title ?? CATEGORY_LABEL[step.category ?? ''] ?? ''}
|
||||||
|
</h1>
|
||||||
|
<SignatureRule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (step.kind === 'thanks') {
|
||||||
|
return <StaticSlide kind="thanks" programName={null} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
|
||||||
|
{(isWinner || isAudience) && <Confetti gold={isWinner} />}
|
||||||
|
{isWinner && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(55% 45% at 50% 52%, rgba(232,195,74,0.16) 0%, transparent 70%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={`text-xl font-semibold uppercase tracking-[0.5em] ${
|
||||||
|
isAudience ? 'text-[#de0f1e]' : isWinner ? 'text-[#e8c34a]' : 'text-[#557f8c]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.subtitle ?? ''}
|
||||||
|
</motion.p>
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 60, scale: 0.92 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 70, damping: 14, delay: 0.35 }}
|
||||||
|
className={`max-w-[92vw] font-extrabold leading-[1.02] tracking-tight ${
|
||||||
|
isWinner
|
||||||
|
? 'text-[clamp(4.5rem,11vw,10rem)]'
|
||||||
|
: 'text-[clamp(3.5rem,8vw,7.5rem)]'
|
||||||
|
}`}
|
||||||
|
style={isWinner ? { textShadow: '0 0 90px rgba(232,195,74,0.35)' } : undefined}
|
||||||
|
>
|
||||||
|
{step.title ?? ''}
|
||||||
|
</motion.h1>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.8 }}
|
||||||
|
>
|
||||||
|
<SignatureRule />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultsSplash() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
|
||||||
|
<motion.p
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2.4 }}
|
||||||
|
className="text-lg font-semibold uppercase tracking-[0.5em] text-[#de0f1e]"
|
||||||
|
>
|
||||||
|
The moment has come
|
||||||
|
</motion.p>
|
||||||
|
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold tracking-tight">Results</h1>
|
||||||
|
<SignatureRule />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CeremonyPage({
|
||||||
|
params: paramsPromise,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ roundId: string }>
|
||||||
|
}) {
|
||||||
|
const params = use(paramsPromise)
|
||||||
|
const { data: state } = trpc.liveVoting.getCeremonyState.useQuery(
|
||||||
|
{ roundId: params.roundId },
|
||||||
|
{ refetchInterval: 2000 }
|
||||||
|
)
|
||||||
|
const voteUrl =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}/vote/competition/${params.roundId}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<OceanField>
|
||||||
|
<div className="flex h-full items-center justify-center" />
|
||||||
|
</OceanField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display precedence: override → reveal → audience window → phase → welcome
|
||||||
|
const reveal = state.reveal
|
||||||
|
const revealStep =
|
||||||
|
reveal && (reveal.status === 'REVEALING' || reveal.status === 'DONE')
|
||||||
|
? ((reveal.steps[reveal.currentStepIndex] ?? null) as RevealStep | null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
let slideKey: string
|
||||||
|
let slide: React.ReactNode
|
||||||
|
let statusLabel: string | null = null
|
||||||
|
|
||||||
|
if (state.overrideSlide) {
|
||||||
|
slideKey = `override-${state.overrideSlide}`
|
||||||
|
slide = <StaticSlide kind={state.overrideSlide} programName={state.programName} />
|
||||||
|
} else if (reveal && reveal.status === 'ARMED') {
|
||||||
|
slideKey = 'reveal-armed'
|
||||||
|
slide = <ResultsSplash />
|
||||||
|
statusLabel = 'Results'
|
||||||
|
} else if (revealStep) {
|
||||||
|
slideKey = `reveal-${reveal!.currentStepIndex}`
|
||||||
|
slide = <RevealSlide step={revealStep} />
|
||||||
|
statusLabel = 'Results'
|
||||||
|
} else if (state.audience.open) {
|
||||||
|
slideKey = `audience-${state.audience.windowKey}`
|
||||||
|
slide = (
|
||||||
|
<AudienceVoteSlide
|
||||||
|
windowKey={state.audience.windowKey}
|
||||||
|
closesAt={state.audience.closesAt}
|
||||||
|
voteCount={state.audience.voteCount}
|
||||||
|
voteUrl={voteUrl}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
statusLabel = 'Live'
|
||||||
|
} else if (state.phase && state.activeProject) {
|
||||||
|
slideKey = `phase-${state.phase.projectPhase}-${state.activeProject.title}`
|
||||||
|
slide = <PhaseSlide state={state} />
|
||||||
|
statusLabel = 'Live'
|
||||||
|
} else {
|
||||||
|
slideKey = 'welcome'
|
||||||
|
slide = <StaticSlide kind="welcome" programName={state.programName} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OceanField>
|
||||||
|
<StatusBar programName={state.programName} label={statusLabel} />
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div key={slideKey} className="absolute inset-0" {...slideIn}>
|
||||||
|
{slide}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</OceanField>
|
||||||
|
)
|
||||||
|
}
|
||||||
327
src/app/(public)/lunch/pick/[token]/page.tsx
Normal file
327
src/app/(public)/lunch/pick/[token]/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Suspense, use, useEffect, useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { AlertCircle, CheckCircle2, Loader2, Salad, UtensilsCrossed } from 'lucide-react'
|
||||||
|
|
||||||
|
const ALLERGENS = [
|
||||||
|
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
|
||||||
|
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
|
||||||
|
] as const
|
||||||
|
type Allergen = (typeof ALLERGENS)[number]
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ token: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTag(t: string): string {
|
||||||
|
return t.replace('_', ' ').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhen(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountdownLabel({ deadline }: { deadline: Date }) {
|
||||||
|
const [now, setNow] = useState<number>(Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
const ms = deadline.getTime() - now
|
||||||
|
if (ms <= 0) return <span className="text-destructive font-medium">closed</span>
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const hours = Math.floor(totalSec / 3600)
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||||
|
const seconds = totalSec % 60
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{days}d {hours % 24}h remaining
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
|
||||||
|
{seconds.toString().padStart(2, '0')} remaining
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendlyError({ title, message }: { title: string; message: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto max-w-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="text-muted-foreground h-5 w-5" />
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">{message}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DishPickContent({ token }: { token: string }) {
|
||||||
|
const { data, isLoading, error } = trpc.lunch.getExternalByToken.useQuery(
|
||||||
|
{ token },
|
||||||
|
{ retry: false },
|
||||||
|
)
|
||||||
|
const setPick = trpc.lunch.setExternalPick.useMutation()
|
||||||
|
|
||||||
|
const [dishId, setDishId] = useState<string>('')
|
||||||
|
const [allergens, setAllergens] = useState<Allergen[]>([])
|
||||||
|
const [allergenOther, setAllergenOther] = useState<string>('')
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated && data) {
|
||||||
|
setDishId(data.external.dishId ?? '')
|
||||||
|
setAllergens((data.external.allergens as Allergen[]) ?? [])
|
||||||
|
setAllergenOther(data.external.allergenOther ?? '')
|
||||||
|
setHydrated(true)
|
||||||
|
}
|
||||||
|
}, [data, hydrated])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-4">
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const msg = error.message ?? ''
|
||||||
|
if (/expired/i.test(msg)) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="This link has expired"
|
||||||
|
message="Please contact us at info@monaco-opc.com and we'll sort out your lunch."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (/signature|malformed|parseable/i.test(msg)) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="This link is not valid"
|
||||||
|
message="Please check your email or contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="Something went wrong"
|
||||||
|
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="Not found"
|
||||||
|
message="Please check your email link or contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = data.changeDeadline ? new Date(data.changeDeadline) : null
|
||||||
|
const deadlinePassed = deadline ? new Date() > deadline : false
|
||||||
|
const eventAt = data.event.eventAt ? new Date(data.event.eventAt) : null
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
await setPick.mutateAsync({
|
||||||
|
token,
|
||||||
|
dishId: dishId || null,
|
||||||
|
allergens,
|
||||||
|
allergenOther: allergenOther.trim() || null,
|
||||||
|
})
|
||||||
|
setSaved(true)
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventCard = (
|
||||||
|
<Card className="border-primary/40 bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UtensilsCrossed className="text-primary h-5 w-5" />
|
||||||
|
<CardTitle>
|
||||||
|
{data.event.venue ? `Lunch at ${data.event.venue}` : 'Lunch'}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
Hi <strong>{data.external.name}</strong>, please choose your dish below.
|
||||||
|
</p>
|
||||||
|
{eventAt && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<strong>When:</strong> {formatWhen(eventAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.event.notes && (
|
||||||
|
<p className="text-muted-foreground">{data.event.notes}</p>
|
||||||
|
)}
|
||||||
|
{deadline && !deadlinePassed && (
|
||||||
|
<p className="text-muted-foreground pt-1">
|
||||||
|
Choose by {formatWhen(deadline)} · <CountdownLabel deadline={deadline} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Past the change deadline → read-only.
|
||||||
|
if (deadlinePassed) {
|
||||||
|
const chosen = data.dishes.find((d) => d.id === data.external.dishId)
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
{eventCard}
|
||||||
|
<FriendlyError
|
||||||
|
title="Dish selection is now closed"
|
||||||
|
message={
|
||||||
|
chosen
|
||||||
|
? `Your choice is "${chosen.name}". To change it, please contact us at info@monaco-opc.com.`
|
||||||
|
: 'The deadline to choose a dish has passed. Please contact us at info@monaco-opc.com.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
{eventCard}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Salad className="h-4 w-4 text-emerald-600" /> Your dish
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<RadioGroup value={dishId} onValueChange={setDishId} className="gap-2">
|
||||||
|
{data.dishes.map((d) => (
|
||||||
|
<label
|
||||||
|
key={d.id}
|
||||||
|
htmlFor={`dish-${d.id}`}
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`dish-${d.id}`} value={d.id} className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{d.name}</div>
|
||||||
|
{d.dietaryTags.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{d.dietaryTags.map((t) => (
|
||||||
|
<Badge key={t} variant="secondary" className="text-xs">
|
||||||
|
{formatTag(t)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{data.dishes.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No dishes have been published yet. Please check back later.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">Allergens</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{ALLERGENS.map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={allergens.includes(a)}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setAllergens(
|
||||||
|
v ? [...allergens, a] : allergens.filter((x) => x !== a),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{formatTag(a)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">Other allergens / dietary notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={allergenOther}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAllergenOther(e.target.value)
|
||||||
|
setSaved(false)
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="e.g. severe nut allergy, no shellfish"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{saved && !setPick.isPending ? (
|
||||||
|
<span className="flex items-center gap-2 text-sm text-emerald-600">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> Saved — you can change it until the deadline.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<Button size="lg" onClick={handleSave} disabled={setPick.isPending}>
|
||||||
|
{setPick.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" /> Save my choice
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LunchPickPage({ params }: PageProps) {
|
||||||
|
const { token } = use(params)
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||||
|
<DishPickContent token={token} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,88 +1,274 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { use, useEffect, useState } from 'react';
|
/**
|
||||||
import { trpc } from '@/lib/trpc/client';
|
* Audience voting page — reached by scanning the QR code on the big screen.
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
* Zero-instruction flow: scan → (auto token) → wait → tap your favorite →
|
||||||
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
|
* done. Votes can be changed until the window closes. Uses ONLY public
|
||||||
import { toast } from 'sonner';
|
* procedures: attendees have no account.
|
||||||
|
*/
|
||||||
|
|
||||||
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
|
import { use, useEffect, useState } from 'react'
|
||||||
const params = use(paramsPromise);
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
const utils = trpc.useUtils();
|
import { trpc } from '@/lib/trpc/client'
|
||||||
const [hasVoted, setHasVoted] = useState(false);
|
import { formatClock } from '@/lib/live-timer'
|
||||||
|
import { Check, Heart, Hourglass, Vote } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
|
const WINDOW_TITLE: Record<string, string> = {
|
||||||
|
'CATEGORY:BUSINESS_CONCEPT': 'Pick your favorite Business Concept',
|
||||||
|
'CATEGORY:STARTUP': 'Pick your favorite Startup',
|
||||||
|
OVERALL: 'Pick your favorite project of the night',
|
||||||
|
}
|
||||||
|
|
||||||
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
|
function useCountdown(closesAt: string | Date | null | undefined) {
|
||||||
onSuccess: () => {
|
const [, tick] = useState(0)
|
||||||
setHasVoted(true);
|
|
||||||
// Store in localStorage to prevent duplicate votes
|
|
||||||
if (cursor?.activeProject?.id) {
|
|
||||||
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
|
|
||||||
}
|
|
||||||
toast.success('Vote submitted! Thank you for participating.');
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check localStorage on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cursor?.activeProject?.id) {
|
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||||
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
|
return () => clearInterval(id)
|
||||||
if (voted === 'true') {
|
}, [])
|
||||||
setHasVoted(true);
|
if (!closesAt) return null
|
||||||
}
|
return Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudienceVotePage({
|
||||||
|
params: paramsPromise,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ roundId: string }>
|
||||||
|
}) {
|
||||||
|
const params = use(paramsPromise)
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: context, isLoading: contextLoading } =
|
||||||
|
trpc.liveVoting.getAudienceContextByRound.useQuery({ roundId: params.roundId })
|
||||||
|
const sessionId = context?.sessionId ?? null
|
||||||
|
|
||||||
|
// ── Anonymous voter token, persisted per session in this browser ─────────
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (sessionId) localStorage.setItem(`mopc-audience-${sessionId}`, res.token)
|
||||||
|
setToken(res.token)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId || !context?.allowAudienceVotes) return
|
||||||
|
const stored = localStorage.getItem(`mopc-audience-${sessionId}`)
|
||||||
|
if (stored) {
|
||||||
|
setToken(stored)
|
||||||
|
} else if (!register.isPending && !token) {
|
||||||
|
register.mutate({ sessionId })
|
||||||
}
|
}
|
||||||
}, [cursor?.activeProject?.id, params.roundId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sessionId, context?.allowAudienceVotes])
|
||||||
|
|
||||||
const handleVote = () => {
|
const { data: win } = trpc.liveVoting.getAudienceWindow.useQuery(
|
||||||
if (!cursor?.activeProject?.id) return;
|
{ sessionId: sessionId ?? '', token: token ?? undefined },
|
||||||
|
{ enabled: !!sessionId, refetchInterval: 3000 }
|
||||||
|
)
|
||||||
|
|
||||||
submitVoteMutation.mutate({
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
projectId: cursor.activeProject.id,
|
const cast = trpc.liveVoting.castFavoriteVote.useMutation({
|
||||||
sessionId: params.roundId,
|
onSuccess: () => {
|
||||||
score: 1,
|
utils.liveVoting.getAudienceWindow.invalidate()
|
||||||
token: `audience-${Date.now()}`
|
setSelected(null)
|
||||||
});
|
toast.success('Vote recorded!')
|
||||||
};
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
if (!cursor?.activeProject) {
|
const secondsLeft = useCountdown(win?.closesAt)
|
||||||
|
const open = !!win?.open && (secondsLeft === null || secondsLeft > 0)
|
||||||
|
const myVote = win?.myVoteProjectId ?? null
|
||||||
|
|
||||||
|
// Reset local selection when a new window opens
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(null)
|
||||||
|
}, [win?.windowKey])
|
||||||
|
|
||||||
|
if (contextLoading) {
|
||||||
|
return <CenteredState icon={Hourglass} title="Loading…" />
|
||||||
|
}
|
||||||
|
if (!context) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
<CenteredState
|
||||||
<Card className="w-full max-w-2xl">
|
icon={Vote}
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
title="No vote here yet"
|
||||||
<p className="text-center text-lg text-muted-foreground">
|
subtitle="This voting link isn't active. Keep an eye on the big screen!"
|
||||||
No project is currently being presented
|
/>
|
||||||
</p>
|
)
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
}
|
||||||
Please wait for the ceremony to begin
|
if (!context.allowAudienceVotes) {
|
||||||
</p>
|
return (
|
||||||
</CardContent>
|
<CenteredState
|
||||||
</Card>
|
icon={Vote}
|
||||||
</div>
|
title="Audience voting is not open"
|
||||||
);
|
subtitle="Voting will be enabled during the event."
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
|
<div className="mx-auto max-w-lg">
|
||||||
<div className="w-full">
|
{/* Gala header */}
|
||||||
<div className="mb-8 text-center">
|
<div className="-mx-4 -mt-8 mb-6 bg-gradient-to-br from-[#021f2e] via-[#053d57] to-[#0a5a7c] px-6 py-8 text-center text-white sm:rounded-b-2xl">
|
||||||
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-white/60">
|
||||||
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
|
{context.programName ?? 'Grand Finale'}
|
||||||
</div>
|
|
||||||
|
|
||||||
<AudienceVoteCard
|
|
||||||
project={cursor.activeProject}
|
|
||||||
onVote={handleVote}
|
|
||||||
hasVoted={hasVoted}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
|
||||||
Live voting in progress
|
|
||||||
</p>
|
</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold">Audience Vote</h1>
|
||||||
|
{open && secondsLeft !== null && (
|
||||||
|
<div className="mx-auto mt-3 inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1.5 text-sm tabular-nums">
|
||||||
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[#de0f1e]" />
|
||||||
|
closes in {formatClock(secondsLeft)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!open ? (
|
||||||
|
<motion.div
|
||||||
|
key="waiting"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="py-10 text-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, -6, 0] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2.4, ease: 'easeInOut' }}
|
||||||
|
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8"
|
||||||
|
>
|
||||||
|
<Hourglass className="h-7 w-7 text-[#053d57]" />
|
||||||
|
</motion.div>
|
||||||
|
<h2 className="text-lg font-semibold text-[#053d57]">
|
||||||
|
Voting opens after the presentations
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">
|
||||||
|
Keep this page open — the ballot appears here the moment voting starts.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key={`open-${win?.windowKey}`}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="space-y-3 pb-28"
|
||||||
|
>
|
||||||
|
<h2 className="text-center text-lg font-semibold text-[#053d57]">
|
||||||
|
{WINDOW_TITLE[win?.windowKey ?? ''] ?? 'Pick your favorite'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
One vote — you can change it until voting closes
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 pt-2">
|
||||||
|
{(win?.projects ?? []).map((project) => {
|
||||||
|
const isPicked = (selected ?? myVote) === project.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => setSelected(project.id)}
|
||||||
|
className={`w-full rounded-2xl border-2 p-4 text-left transition-all active:scale-[0.99] ${
|
||||||
|
isPicked
|
||||||
|
? 'border-[#de0f1e] bg-[#053d57] text-white shadow-lg'
|
||||||
|
: 'border-border bg-card hover:border-[#557f8c]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
isPicked ? 'bg-[#de0f1e]' : 'bg-[#053d57]/8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPicked ? (
|
||||||
|
<Check className="h-5 w-5 text-white" />
|
||||||
|
) : (
|
||||||
|
<Heart className="h-4 w-4 text-[#557f8c]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">
|
||||||
|
{project.teamName ?? project.title}
|
||||||
|
</p>
|
||||||
|
{project.teamName && (
|
||||||
|
<p
|
||||||
|
className={`truncate text-xs ${
|
||||||
|
isPicked ? 'text-white/70' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{project.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned confirm bar */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selected && selected !== myVote && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 80 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: 80 }}
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur"
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-lg">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
sessionId &&
|
||||||
|
token &&
|
||||||
|
cast.mutate({ sessionId, token, projectId: selected })
|
||||||
|
}
|
||||||
|
disabled={cast.isPending || !token}
|
||||||
|
className="w-full rounded-xl bg-[#de0f1e] py-3.5 font-semibold text-white shadow-lg transition-transform active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{cast.isPending
|
||||||
|
? 'Submitting…'
|
||||||
|
: myVote
|
||||||
|
? 'Change my vote'
|
||||||
|
: 'Confirm my vote'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{myVote && (!selected || selected === myVote) && (
|
||||||
|
<div className="pt-2 text-center">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-600/10 px-4 py-1.5 text-sm font-medium text-green-700">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Vote recorded — tap another to change it
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CenteredState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
icon: typeof Vote
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8">
|
||||||
|
<Icon className="h-7 w-7 text-[#053d57]" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-[#053d57]">{title}</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/app/api/cron/final-document-reminders/route.ts
Normal file
17
src/app/api/cron/final-document-reminders/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { sendDueFinalDocReminders } from '@/server/services/final-documents'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const cronSecret = request.headers.get('x-cron-secret')
|
||||||
|
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await sendDueFinalDocReminders(prisma)
|
||||||
|
return NextResponse.json({ ok: true, ...result })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cron] final-document-reminders failed:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendLunchReminderEmail } from '@/lib/email'
|
import { sendLunchReminderEmail } from '@/lib/email'
|
||||||
import { selectUnpickedAttendees } from '@/server/services/lunch-reminders'
|
import {
|
||||||
|
selectUnpickedAttendees,
|
||||||
|
selectUnpickedExternals,
|
||||||
|
} from '@/server/services/lunch-reminders'
|
||||||
|
import { sendExternalDishInvite } from '@/server/services/lunch-external-invite'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron: send a single reminder email per attending member who hasn't picked
|
* Cron: send a single reminder email per attending member who hasn't picked
|
||||||
@@ -53,6 +57,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External attendees: emailed + no dish yet → their tokenized pick page.
|
||||||
|
const externals = await selectUnpickedExternals(prisma, { id: event.id })
|
||||||
|
for (const ext of externals) {
|
||||||
|
if (!ext.email) continue
|
||||||
|
try {
|
||||||
|
await sendExternalDishInvite(prisma, ext, event)
|
||||||
|
sent++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch-reminders] external send failed for', ext.email, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.lunchEvent.update({
|
await prisma.lunchEvent.update({
|
||||||
where: { id: event.id },
|
where: { id: event.id },
|
||||||
data: { reminderSentAt: new Date() },
|
data: { reminderSentAt: new Date() },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||||
import { appRouter } from '@/server/routers/_app'
|
import { appRouter } from '@/server/routers/_app'
|
||||||
import { createContext } from '@/server/context'
|
import { createContext } from '@/server/context'
|
||||||
import { checkRateLimit } from '@/lib/rate-limit'
|
import { checkRateLimit, isCeremonyTraffic } from '@/lib/rate-limit'
|
||||||
|
|
||||||
// Allow long-running operations (AI filtering, bulk imports)
|
// Allow long-running operations (AI filtering, bulk imports)
|
||||||
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
|
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
|
||||||
@@ -9,6 +9,9 @@ export const maxDuration = 300 // 5 minutes
|
|||||||
|
|
||||||
const RATE_LIMIT = 100 // requests per window
|
const RATE_LIMIT = 100 // requests per window
|
||||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
|
// Ceremony-day polling: whole venues share one IP (NAT) and every screen
|
||||||
|
// polls a few cheap public reads — see CEREMONY_PROCEDURES in lib/rate-limit.
|
||||||
|
const CEREMONY_RATE_LIMIT = 6000
|
||||||
|
|
||||||
function getClientIp(req: Request): string {
|
function getClientIp(req: Request): string {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +23,10 @@ function getClientIp(req: Request): string {
|
|||||||
|
|
||||||
const handler = (req: Request) => {
|
const handler = (req: Request) => {
|
||||||
const ip = getClientIp(req)
|
const ip = getClientIp(req)
|
||||||
const { success, remaining, resetAt } = checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
|
const ceremony = isCeremonyTraffic(new URL(req.url).pathname)
|
||||||
|
const { success, remaining, resetAt } = ceremony
|
||||||
|
? checkRateLimit(`trpc-ceremony:${ip}`, CEREMONY_RATE_LIMIT, RATE_WINDOW_MS)
|
||||||
|
: checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export function AdminOverrideDialog({
|
|||||||
<Label>Project Rankings</Label>
|
<Label>Project Rankings</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{projectIds.map((projectId) => {
|
{projectIds.map((projectId) => {
|
||||||
const project = session?.results?.find((r) => r.project.id === projectId)?.project;
|
const project =
|
||||||
|
(session as any)?.projects?.find((p: any) => p.id === projectId) ??
|
||||||
|
session?.results?.find((r) => r.project.id === projectId)?.project;
|
||||||
return (
|
return (
|
||||||
<div key={projectId} className="flex items-center gap-3">
|
<div key={projectId} className="flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
260
src/components/admin/deliberation/deliberation-control-panel.tsx
Normal file
260
src/components/admin/deliberation/deliberation-control-panel.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { ResultsPanel } from './results-panel'
|
||||||
|
import { AdminOverrideDialog } from './admin-override-dialog'
|
||||||
|
import { Gavel, Lock, Play, Plus, Square, Users } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
BUSINESS_CONCEPT: 'Business Concepts',
|
||||||
|
STARTUP: 'Startups',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, { label: string; variant: 'secondary' | 'default' | 'destructive' | 'outline' }> = {
|
||||||
|
DELIB_OPEN: { label: 'Open — voting not started', variant: 'secondary' },
|
||||||
|
VOTING: { label: 'Voting', variant: 'default' },
|
||||||
|
TALLYING: { label: 'Tallying', variant: 'outline' },
|
||||||
|
RUNOFF: { label: 'Runoff', variant: 'destructive' },
|
||||||
|
DELIB_LOCKED: { label: 'Locked', variant: 'secondary' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCard({ session, competitionId }: { session: any; competitionId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||||
|
const { data: detail } = trpc.deliberation.getSession.useQuery(
|
||||||
|
{ sessionId: session.id },
|
||||||
|
{ refetchInterval: 10_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
utils.deliberation.getSession.invalidate({ sessionId: session.id })
|
||||||
|
utils.deliberation.listSessions.invalidate({ competitionId })
|
||||||
|
}
|
||||||
|
const onError = (err: { message: string }) => toast.error(err.message)
|
||||||
|
const openVoting = trpc.deliberation.openVoting.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
toast.success('Deliberation voting opened — jurors can now rank')
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const closeVoting = trpc.deliberation.closeVoting.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
toast.success('Voting closed — tallying')
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = detail?.status ?? session.status
|
||||||
|
const badge = STATUS_BADGE[status] ?? { label: status, variant: 'outline' as const }
|
||||||
|
const voteCount = detail?.votes?.length ?? session._count?.votes ?? 0
|
||||||
|
const participantCount = detail?.participants?.length ?? session._count?.participants ?? 0
|
||||||
|
const votedJurors = new Set(
|
||||||
|
(detail?.votes ?? []).map((v: any) => v.juryMember?.id ?? v.juryMemberId)
|
||||||
|
).size
|
||||||
|
|
||||||
|
const projectIds: string[] =
|
||||||
|
detail?.projects?.map((p: any) => p.id) ??
|
||||||
|
detail?.results?.map((r: any) => r.project.id) ??
|
||||||
|
[]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{CATEGORY_LABEL[session.category] ?? session.category}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-0.5 flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{votedJurors}/{participantCount} jurors voted
|
||||||
|
</span>
|
||||||
|
<span>{voteCount} ballots</span>
|
||||||
|
<span>{session.mode === 'FULL_RANKING' ? 'Full ranking' : 'Single winner'}</span>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{status === 'DELIB_OPEN' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => openVoting.mutate({ sessionId: session.id })}
|
||||||
|
disabled={openVoting.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Open voting
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(status === 'VOTING' || status === 'RUNOFF') && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => closeVoting.mutate({ sessionId: session.id })}
|
||||||
|
disabled={closeVoting.isPending}
|
||||||
|
>
|
||||||
|
<Square className="mr-2 h-4 w-4" />
|
||||||
|
Close voting & tally
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{status !== 'DELIB_LOCKED' && (
|
||||||
|
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
|
||||||
|
<Gavel className="mr-2 h-4 w-4" />
|
||||||
|
Set rankings manually
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{status === 'DELIB_LOCKED' && (
|
||||||
|
<Badge variant="secondary" className="gap-1 py-1.5">
|
||||||
|
<Lock className="h-3.5 w-3.5" />
|
||||||
|
Results locked
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aggregated results + runoff/finalize controls */}
|
||||||
|
{(status === 'TALLYING' || status === 'RUNOFF' || status === 'DELIB_LOCKED') && (
|
||||||
|
<ResultsPanel sessionId={session.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminOverrideDialog
|
||||||
|
sessionId={session.id}
|
||||||
|
open={overrideOpen}
|
||||||
|
onOpenChange={setOverrideOpen}
|
||||||
|
projectIds={projectIds}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin deliberation console for a DELIBERATION round: create the per-category
|
||||||
|
* sessions from the round's jury group, drive voting open/close, tally,
|
||||||
|
* resolve ties, override manually (the "jury went analog" path) and finalize.
|
||||||
|
*/
|
||||||
|
export function DeliberationControlPanel({
|
||||||
|
roundId,
|
||||||
|
competitionId,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
competitionId: string
|
||||||
|
}) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: round } = trpc.round.getById.useQuery({ id: roundId })
|
||||||
|
const juryGroupId = round?.juryGroupId ?? ''
|
||||||
|
const { data: juryGroup } = trpc.juryGroup.getById.useQuery(
|
||||||
|
{ id: juryGroupId },
|
||||||
|
{ enabled: !!juryGroupId }
|
||||||
|
)
|
||||||
|
const { data: sessions } = trpc.deliberation.listSessions.useQuery(
|
||||||
|
{ competitionId },
|
||||||
|
{ refetchInterval: 15_000 }
|
||||||
|
)
|
||||||
|
const [mode, setMode] = useState<'FULL_RANKING' | 'SINGLE_WINNER_VOTE'>('FULL_RANKING')
|
||||||
|
|
||||||
|
const createSession = trpc.deliberation.createSession.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.deliberation.listSessions.invalidate({ competitionId })
|
||||||
|
toast.success('Deliberation session created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const roundSessions = (sessions ?? []).filter((s: any) => s.roundId === roundId)
|
||||||
|
const existingCategories = new Set(roundSessions.map((s: any) => s.category))
|
||||||
|
const votingMembers = (juryGroup?.members ?? []).filter((m: any) => m.role !== 'OBSERVER')
|
||||||
|
|
||||||
|
const handleCreate = (category: 'STARTUP' | 'BUSINESS_CONCEPT') => {
|
||||||
|
if (votingMembers.length === 0) {
|
||||||
|
toast.error('The round has no jury group members to deliberate')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createSession.mutate({
|
||||||
|
competitionId,
|
||||||
|
roundId,
|
||||||
|
category,
|
||||||
|
mode,
|
||||||
|
tieBreakMethod: 'TIE_ADMIN_DECIDES',
|
||||||
|
showPriorJuryData: true,
|
||||||
|
participantUserIds: votingMembers.map((m: any) => m.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(['BUSINESS_CONCEPT', 'STARTUP'] as const).some((c) => !existingCategories.has(c)) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Deliberation Sessions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
One session per category · participants come from the round's jury group (
|
||||||
|
{votingMembers.length} voting member{votingMembers.length === 1 ? '' : 's'})
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Mode</Label>
|
||||||
|
<Select value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
|
||||||
|
<SelectTrigger className="w-56">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="FULL_RANKING">Full ranking (Borda)</SelectItem>
|
||||||
|
<SelectItem value="SINGLE_WINNER_VOTE">Single winner pick</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['BUSINESS_CONCEPT', 'STARTUP'] as const)
|
||||||
|
.filter((c) => !existingCategories.has(c))
|
||||||
|
.map((category) => (
|
||||||
|
<Button
|
||||||
|
key={category}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleCreate(category)}
|
||||||
|
disabled={createSession.isPending || !juryGroupId}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{CATEGORY_LABEL[category]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!juryGroupId && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Assign a jury group to this round first (Config tab).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roundSessions.map((s: any) => (
|
||||||
|
<SessionCard key={s.id} session={s} competitionId={competitionId} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{roundSessions.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No deliberation sessions yet — create one per category above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||||
|
import { Eye, Mail, Send } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const REMINDER_TYPE = 'GRAND_FINAL_DOCS_REMINDER'
|
||||||
|
|
||||||
|
export function FinalDocsReminderButton({ programId }: { programId: string }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
|
||||||
|
const preview = trpc.notification.previewEmailTemplate.useQuery(
|
||||||
|
{ notificationType: REMINDER_TYPE },
|
||||||
|
{ enabled: previewOpen },
|
||||||
|
)
|
||||||
|
|
||||||
|
const send = trpc.finalist.sendDocumentReminders.useMutation({
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast.success(`Reminder sent to ${r.sent} team${r.sent === 1 ? '' : 's'}`)
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Mail className="mr-2 h-4 w-4" /> Remind teams to upload final documents
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remind finalist teams</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Sends an in-app + email reminder to every finalist team with missing required
|
||||||
|
documents.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="sm:justify-between">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" /> Preview email
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
|
||||||
|
<Send className="mr-2 h-4 w-4" /> {send.isPending ? 'Sending…' : 'Send reminders'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Self-contained preview dialog — rendered as a sibling so it is not nested
|
||||||
|
inside the confirm dialog's content. */}
|
||||||
|
<EmailPreviewDialog
|
||||||
|
open={previewOpen}
|
||||||
|
onOpenChange={setPreviewOpen}
|
||||||
|
title="Final Documents Reminder"
|
||||||
|
description="Preview of the email finalist teams receive."
|
||||||
|
recipientCount={0}
|
||||||
|
previewHtml={preview.data?.html}
|
||||||
|
isPreviewLoading={preview.isLoading}
|
||||||
|
onSend={() => {}}
|
||||||
|
isSending={false}
|
||||||
|
previewOnly
|
||||||
|
showCustomMessage={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin toggle: whether finalist teams may upload *revised* grand-final documents.
|
||||||
|
* Off by default — judges always see the teams' existing prior-round submissions
|
||||||
|
* regardless; this only controls whether teams are prompted/allowed to upload new
|
||||||
|
* revised versions (and whether the upload reminder cron runs).
|
||||||
|
*/
|
||||||
|
export function FinalDocsUploadsToggle({ roundId }: { roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data } = trpc.finalist.getRevisedUploadSetting.useQuery({ roundId })
|
||||||
|
const set = trpc.finalist.setRevisedUploadSetting.useMutation({
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast.success(r.enabled ? 'Finalist revised uploads enabled' : 'Finalist revised uploads disabled')
|
||||||
|
utils.finalist.getRevisedUploadSetting.invalidate({ roundId })
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="finalist-revised-uploads"
|
||||||
|
checked={!!data?.enabled}
|
||||||
|
disabled={set.isPending}
|
||||||
|
onCheckedChange={(v) => set.mutate({ roundId, enabled: v })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="finalist-revised-uploads" className="text-sm text-muted-foreground cursor-pointer">
|
||||||
|
Allow finalists to upload revised documents
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
src/components/admin/grand-finale/review-docs-picker.tsx
Normal file
79
src/components/admin/grand-finale/review-docs-picker.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin picker: which previously-submitted documents finale judges see on the
|
||||||
|
* review page. Default (switch off) shows everything; switching to curated
|
||||||
|
* mode starts with all slots ticked, and the admin unticks what to hide.
|
||||||
|
* Grand Final round uploads are always visible regardless.
|
||||||
|
*/
|
||||||
|
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
|
||||||
|
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
|
||||||
|
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
if (!data || data.options.length === 0) return null
|
||||||
|
|
||||||
|
const curated = data.selectedIds !== null
|
||||||
|
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
|
||||||
|
const toggleSlot = (id: string, on: boolean) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
if (on) next.add(id)
|
||||||
|
else next.delete(id)
|
||||||
|
set.mutate({ roundId, requirementIds: [...next] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Eye className="h-5 w-5" /> Documents shown to judges
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Choose which previously submitted documents judges see on the finalist review page.
|
||||||
|
Documents uploaded directly to this Grand Final round are always visible.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="curate-review-docs"
|
||||||
|
checked={curated}
|
||||||
|
disabled={set.isPending}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
|
||||||
|
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{curated && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.options.map((o) => (
|
||||||
|
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(o.requirementId)}
|
||||||
|
disabled={set.isPending}
|
||||||
|
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
|
||||||
|
/>
|
||||||
|
<span>{o.name} — {o.roundName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
src/components/admin/live/audience-window-panel.tsx
Normal file
278
src/components/admin/live/audience-window-panel.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import { formatClock } from '@/lib/live-timer'
|
||||||
|
import { ChevronDown, QrCode, Users, Vote, XCircle } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const WINDOW_LABEL: Record<string, string> = {
|
||||||
|
'CATEGORY:BUSINESS_CONCEPT': 'Business Concepts',
|
||||||
|
'CATEGORY:STARTUP': 'Startups',
|
||||||
|
OVERALL: 'Overall favorite',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audience favorite-vote control: open a per-category (or overall) window for
|
||||||
|
* N minutes, watch the live vote count, close early, and project the QR code.
|
||||||
|
*/
|
||||||
|
export function AudienceWindowPanel({ roundId }: { roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: session } = trpc.liveVoting.getSession.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ refetchInterval: 3000 }
|
||||||
|
)
|
||||||
|
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
|
||||||
|
{ sessionId: session?.id ?? '' },
|
||||||
|
{ enabled: !!session?.id, refetchInterval: 3000 }
|
||||||
|
)
|
||||||
|
const [durationMin, setDurationMin] = useState('5')
|
||||||
|
const [talliesOpen, setTalliesOpen] = useState(false)
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
utils.liveVoting.getSession.invalidate({ roundId })
|
||||||
|
if (session?.id) utils.liveVoting.getFavoriteTallies.invalidate({ sessionId: session.id })
|
||||||
|
}
|
||||||
|
const onError = (err: { message: string }) => toast.error(err.message)
|
||||||
|
const openWindow = trpc.liveVoting.openAudienceWindow.useMutation({
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const closeWindow = trpc.liveVoting.closeAudienceWindow.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
toast.success('Audience voting closed')
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const updateConfig = trpc.liveVoting.updateSessionConfig.useMutation({
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
const closesAt = session.audienceWindowClosesAt ? new Date(session.audienceWindowClosesAt) : null
|
||||||
|
const secondsLeft = closesAt ? Math.floor((closesAt.getTime() - Date.now()) / 1000) : null
|
||||||
|
const isOpen = session.audiencePhase === 'OPEN' && secondsLeft !== null && secondsLeft > 0
|
||||||
|
const openKey = isOpen ? session.audienceWindowKey : null
|
||||||
|
|
||||||
|
const currentWindow = tallies?.windows.find((w) => w.windowKey === openKey)
|
||||||
|
const voteUrl =
|
||||||
|
typeof window !== 'undefined' ? `${window.location.origin}/vote/competition/${roundId}` : ''
|
||||||
|
|
||||||
|
const duration = Math.max(1, parseInt(durationMin, 10) || 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Vote className="h-5 w-5" />
|
||||||
|
Audience Vote
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{session.allowAudienceVotes
|
||||||
|
? 'Favorite-pick windows, one vote per phone per window'
|
||||||
|
: 'Audience voting is disabled in session config'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<QrCode className="mr-2 h-4 w-4" />
|
||||||
|
Show QR
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-center">Scan to vote</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-lg">
|
||||||
|
{voteUrl && <QRCodeSVG value={voteUrl} size={420} />}
|
||||||
|
</div>
|
||||||
|
<p className="select-all break-all text-center text-sm text-muted-foreground">
|
||||||
|
{voteUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="space-y-3 rounded-lg border border-[#de0f1e]/30 bg-[#de0f1e]/5 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge className="bg-[#de0f1e] hover:bg-[#de0f1e]">
|
||||||
|
OPEN — {WINDOW_LABEL[openKey ?? ''] ?? openKey}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-2xl font-bold tabular-nums">
|
||||||
|
{formatClock(Math.max(0, secondsLeft ?? 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{currentWindow?.totalVotes ?? 0}
|
||||||
|
</span>
|
||||||
|
votes cast
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => closeWindow.mutate({ sessionId: session.id })}
|
||||||
|
disabled={closeWindow.isPending}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Close voting now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Duration (min)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="120"
|
||||||
|
value={durationMin}
|
||||||
|
onChange={(e) => setDurationMin(e.target.value)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="pb-2 text-xs text-muted-foreground">
|
||||||
|
Voting closes automatically — server-enforced
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={openWindow.isPending || !session.allowAudienceVotes}
|
||||||
|
onClick={() =>
|
||||||
|
openWindow.mutate({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
|
||||||
|
durationMinutes: duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open vote — Business Concepts
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={openWindow.isPending || !session.allowAudienceVotes}
|
||||||
|
onClick={() =>
|
||||||
|
openWindow.mutate({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:STARTUP',
|
||||||
|
durationMinutes: duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open vote — Startups
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Overall favorite (across both categories)</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Decide day-of — off by default</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={!!session.allowOverallFavorite}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig.mutate({ sessionId: session.id, allowOverallFavorite: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
openWindow.isPending || !session.allowOverallFavorite || !session.allowAudienceVotes
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
openWindow.mutate({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'OVERALL',
|
||||||
|
durationMinutes: duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!session.allowAudienceVotes && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() =>
|
||||||
|
updateConfig.mutate({ sessionId: session.id, allowAudienceVotes: true })
|
||||||
|
}
|
||||||
|
disabled={updateConfig.isPending}
|
||||||
|
>
|
||||||
|
Enable audience voting for this session
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tallies — admin eyes only */}
|
||||||
|
{tallies && tallies.windows.length > 0 && (
|
||||||
|
<Collapsible open={talliesOpen} onOpenChange={setTalliesOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||||
|
Tallies (admin only)
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 transition-transform ${talliesOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="space-y-3 pt-2">
|
||||||
|
{tallies.windows.map((w) => (
|
||||||
|
<div key={w.windowKey} className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{WINDOW_LABEL[w.windowKey] ?? w.windowKey}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground">{w.totalVotes} votes</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{w.projects.map((p) => (
|
||||||
|
<div key={p.projectId} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate">{p.teamName ?? p.title}</span>
|
||||||
|
<span className="font-semibold tabular-nums">{p.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,238 +1,159 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import Link from 'next/link'
|
||||||
import { Button } from '@/components/ui/button';
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { toast } from 'sonner';
|
import { PhaseControls } from './phase-controls'
|
||||||
|
import { RunOrderList } from './run-order-list'
|
||||||
|
import { AudienceWindowPanel } from './audience-window-panel'
|
||||||
|
import { TimingLogCard } from './timing-log-card'
|
||||||
|
import { RevealPanel } from './reveal-panel'
|
||||||
|
import { Coffee, ExternalLink, Hand, MonitorPlay, PartyPopper, Play, Scale, X } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface LiveControlPanelProps {
|
interface LiveControlPanelProps {
|
||||||
roundId: string;
|
roundId: string
|
||||||
competitionId: string;
|
competitionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OVERRIDE_SLIDES = [
|
||||||
|
{ value: 'welcome', label: 'Welcome', icon: Hand },
|
||||||
|
{ value: 'break', label: 'Break', icon: Coffee },
|
||||||
|
{ value: 'deliberation', label: 'Deliberation', icon: Scale },
|
||||||
|
{ value: 'thanks', label: 'Thank you', icon: PartyPopper },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grand-finale ceremony console. Everything an admin touches during the
|
||||||
|
* event lives here: run order, phase driver with real timers, audience vote
|
||||||
|
* windows + QR, big-screen override slides, timing log, and the results
|
||||||
|
* reveal stepper.
|
||||||
|
*/
|
||||||
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
|
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils()
|
||||||
const [timerSeconds, setTimerSeconds] = useState(300);
|
const { data: cursor, isLoading } = trpc.live.getCursor.useQuery(
|
||||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
|
||||||
|
|
||||||
const { data: cursor } = trpc.live.getCursor.useQuery(
|
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ refetchInterval: 5000 }
|
{ refetchInterval: 2000 }
|
||||||
);
|
)
|
||||||
|
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
|
||||||
|
{ roundId },
|
||||||
|
{ enabled: !cursor && !isLoading }
|
||||||
|
)
|
||||||
|
const [starting, setStarting] = useState(false)
|
||||||
|
|
||||||
const jumpMutation = trpc.live.jump.useMutation({
|
const startMutation = trpc.live.start.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.live.getCursor.invalidate({ roundId });
|
utils.live.getCursor.invalidate({ roundId })
|
||||||
|
toast.success('Ceremony session started')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
});
|
onSettled: () => setStarting(false),
|
||||||
|
})
|
||||||
const pauseMutation = trpc.live.pause.useMutation({
|
const overrideMutation = trpc.live.setOverrideSlide.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
|
||||||
utils.live.getCursor.invalidate({ roundId });
|
|
||||||
toast.success('Live session paused');
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
});
|
})
|
||||||
|
|
||||||
const resumeMutation = trpc.live.resume.useMutation({
|
const handleStart = () => {
|
||||||
onSuccess: () => {
|
// Default run order: Business Concepts block first, then Startups
|
||||||
utils.live.getCursor.invalidate({ roundId });
|
const projects = (projectStates ?? [])
|
||||||
toast.success('Live session resumed');
|
.map((ps: any) => ps.project)
|
||||||
},
|
.filter(Boolean)
|
||||||
onError: (err) => toast.error(err.message),
|
const order = [
|
||||||
});
|
...projects.filter((p: any) => p.competitionCategory === 'BUSINESS_CONCEPT'),
|
||||||
|
...projects.filter((p: any) => p.competitionCategory === 'STARTUP'),
|
||||||
useEffect(() => {
|
...projects.filter(
|
||||||
if (!isTimerRunning) return;
|
(p: any) => p.competitionCategory !== 'BUSINESS_CONCEPT' && p.competitionCategory !== 'STARTUP'
|
||||||
|
),
|
||||||
const interval = setInterval(() => {
|
].map((p: any) => p.id)
|
||||||
setTimerSeconds((prev) => {
|
if (order.length === 0) {
|
||||||
if (prev <= 1) {
|
toast.error('No projects in this round yet')
|
||||||
setIsTimerRunning(false);
|
return
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return prev - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isTimerRunning]);
|
|
||||||
|
|
||||||
const currentIndex = cursor?.activeOrderIndex ?? 0;
|
|
||||||
const totalProjects = cursor?.totalProjects ?? 0;
|
|
||||||
const isNavigating = jumpMutation.isPending;
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
if (currentIndex <= 0) {
|
|
||||||
toast.info('Already at the first project');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
|
setStarting(true)
|
||||||
};
|
startMutation.mutate({ roundId, projectOrder: order })
|
||||||
|
}
|
||||||
|
|
||||||
const handleNext = () => {
|
// ── Not started yet ───────────────────────────────────────────────────────
|
||||||
if (currentIndex >= totalProjects - 1) {
|
if (!cursor) {
|
||||||
toast.info('Already at the last project');
|
return (
|
||||||
return;
|
|
||||||
}
|
|
||||||
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Current Project Card */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Current Project</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{cursor && (
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
{currentIndex + 1} / {totalProjects}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handlePrevious}
|
|
||||||
disabled={isNavigating || currentIndex <= 0}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={isNavigating || currentIndex >= totalProjects - 1}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{cursor?.activeProject ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
|
|
||||||
{cursor.activeProject.teamName && (
|
|
||||||
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(cursor.activeProject.tags as string[]).map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-xs">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{cursor ? 'No project selected' : 'No live session active for this round'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Timer Card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Timer className="h-5 w-5" />
|
<MonitorPlay className="h-5 w-5" />
|
||||||
Timer
|
Ceremony Console
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Start the live session when the event begins — it creates the presentation cursor
|
||||||
|
every screen follows. The set start time is indicative; nothing moves until you click.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3">
|
||||||
<div className="text-center">
|
<Button size="lg" className="w-full" onClick={handleStart} disabled={isLoading || starting}>
|
||||||
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
|
<Play className="mr-2 h-4 w-4" />
|
||||||
</div>
|
{starting ? 'Starting…' : 'Start ceremony session'}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
</Button>
|
||||||
{!isTimerRunning ? (
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Run order defaults to Business Concepts → Startups; reorder anytime after starting.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Big-screen quick bar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2 py-3">
|
||||||
|
<span className="mr-1 text-sm font-medium">Big screen:</span>
|
||||||
|
{OVERRIDE_SLIDES.map((slide) => {
|
||||||
|
const active = cursor.overrideSlide === slide.value
|
||||||
|
const SlideIcon = slide.icon
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
key={slide.value}
|
||||||
onClick={() => setIsTimerRunning(true)}
|
variant={active ? 'default' : 'outline'}
|
||||||
disabled={timerSeconds === 0}
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
overrideMutation.mutate({ roundId, slide: active ? null : slide.value })
|
||||||
|
}
|
||||||
|
disabled={overrideMutation.isPending}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<SlideIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Start Timer
|
{slide.label}
|
||||||
|
{active && <X className="ml-1.5 h-3 w-3" />}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
)
|
||||||
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive">
|
})}
|
||||||
<Square className="mr-2 h-4 w-4" />
|
{cursor.overrideSlide && (
|
||||||
Stop Timer
|
<Badge variant="destructive" className="ml-auto">
|
||||||
</Button>
|
Override active — live content hidden
|
||||||
)}
|
</Badge>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
<Button asChild variant="ghost" size="sm" className={cursor.overrideSlide ? '' : 'ml-auto'}>
|
||||||
onClick={() => {
|
<Link href={`/live/ceremony/${roundId}`} target="_blank">
|
||||||
setTimerSeconds(300);
|
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||||
setIsTimerRunning(false);
|
Open big screen
|
||||||
}}
|
</Link>
|
||||||
>
|
</Button>
|
||||||
Reset (5:00)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Session Controls */}
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
<PhaseControls roundId={roundId} />
|
||||||
<CardTitle>Session Controls</CardTitle>
|
<RunOrderList roundId={roundId} />
|
||||||
<CardDescription>Pause or resume the live presentation</CardDescription>
|
</div>
|
||||||
</CardHeader>
|
<div className="space-y-4">
|
||||||
<CardContent className="space-y-3">
|
<AudienceWindowPanel roundId={roundId} />
|
||||||
{cursor?.isPaused ? (
|
<RevealPanel roundId={roundId} competitionId={competitionId} />
|
||||||
<Button
|
<TimingLogCard roundId={roundId} />
|
||||||
className="w-full"
|
</div>
|
||||||
onClick={() => resumeMutation.mutate({ roundId })}
|
</div>
|
||||||
disabled={resumeMutation.isPending}
|
|
||||||
>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => pauseMutation.mutate({ roundId })}
|
|
||||||
disabled={pauseMutation.isPending || !cursor}
|
|
||||||
>
|
|
||||||
<Pause className="mr-2 h-4 w-4" />
|
|
||||||
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{cursor?.isPaused && (
|
|
||||||
<Badge variant="destructive" className="w-full justify-center py-1">
|
|
||||||
Session Paused
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
|
|
||||||
{cursor.openCohorts.map((cohort: any) => (
|
|
||||||
<div key={cohort.id} className="flex items-center justify-between text-sm">
|
|
||||||
<span>{cohort.name}</span>
|
|
||||||
<Badge variant="outline">{cohort.votingMode}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
222
src/components/admin/live/phase-controls.tsx
Normal file
222
src/components/admin/live/phase-controls.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { remainingSeconds, formatClock, parseClock } from '@/lib/live-timer'
|
||||||
|
import {
|
||||||
|
Mic2,
|
||||||
|
MessageCircleQuestion,
|
||||||
|
PenLine,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
SkipForward,
|
||||||
|
MonitorUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const PHASE_LABEL: Record<string, string> = {
|
||||||
|
ON_DECK: 'On deck',
|
||||||
|
PRESENTING: 'Presenting',
|
||||||
|
QA: 'Q&A',
|
||||||
|
SCORING: 'Scoring',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ceremony driver: one primary button for the next phase transition, a
|
||||||
|
* server-derived countdown that goes red past zero, pause/resume, and
|
||||||
|
* per-run duration overrides.
|
||||||
|
*/
|
||||||
|
export function PhaseControls({ roundId }: { roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 2000 })
|
||||||
|
const [presentationMin, setPresentationMin] = useState<string>('')
|
||||||
|
const [qaMin, setQaMin] = useState<string>('')
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => tick((t) => t + 1), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const invalidate = () => utils.live.getCursor.invalidate({ roundId })
|
||||||
|
const onError = (err: { message: string }) => toast.error(err.message)
|
||||||
|
const startPresentation = trpc.live.startPresentation.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const startQA = trpc.live.startQA.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const openScoring = trpc.live.openScoring.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const sendToScreens = trpc.live.sendToScreens.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const pausePhase = trpc.live.pausePhase.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const resumePhase = trpc.live.resumePhase.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
|
||||||
|
if (!cursor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = cursor.projectPhase
|
||||||
|
const remaining = remainingSeconds(cursor)
|
||||||
|
const over = remaining !== null && remaining < 0
|
||||||
|
const paused = !!cursor.phasePausedAt
|
||||||
|
const busy =
|
||||||
|
startPresentation.isPending ||
|
||||||
|
startQA.isPending ||
|
||||||
|
openScoring.isPending ||
|
||||||
|
sendToScreens.isPending
|
||||||
|
|
||||||
|
const durationSeconds = (raw: string) => parseClock(raw) ?? undefined
|
||||||
|
|
||||||
|
const nextProject = (() => {
|
||||||
|
const order = cursor.orderedProjects ?? []
|
||||||
|
const idx = order.findIndex((p) => p.id === cursor.activeProjectId)
|
||||||
|
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const primaryAction = (() => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'ON_DECK':
|
||||||
|
return {
|
||||||
|
label: 'Start presentation',
|
||||||
|
icon: Mic2,
|
||||||
|
run: () =>
|
||||||
|
startPresentation.mutate({
|
||||||
|
roundId,
|
||||||
|
durationSeconds: durationSeconds(presentationMin),
|
||||||
|
}),
|
||||||
|
disabled: !cursor.activeProjectId,
|
||||||
|
}
|
||||||
|
case 'PRESENTING':
|
||||||
|
return {
|
||||||
|
label: 'Start Q&A',
|
||||||
|
icon: MessageCircleQuestion,
|
||||||
|
run: () => startQA.mutate({ roundId, durationSeconds: durationSeconds(qaMin) }),
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
case 'QA':
|
||||||
|
return {
|
||||||
|
label: 'Open scoring',
|
||||||
|
icon: PenLine,
|
||||||
|
run: () => openScoring.mutate({ roundId }),
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
case 'SCORING':
|
||||||
|
default:
|
||||||
|
return nextProject
|
||||||
|
? {
|
||||||
|
label: `Send next: ${nextProject.teamName ?? nextProject.title}`,
|
||||||
|
icon: MonitorUp,
|
||||||
|
run: () => sendToScreens.mutate({ roundId, projectId: nextProject.id }),
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: 'End of run order',
|
||||||
|
icon: SkipForward,
|
||||||
|
run: () => undefined,
|
||||||
|
disabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const PrimaryIcon = primaryAction.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Ceremony Control</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{cursor.activeProject
|
||||||
|
? `${cursor.activeProject.title}${cursor.activeProject.teamName ? ` — ${cursor.activeProject.teamName}` : ''}`
|
||||||
|
: 'No project on screens yet'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={phase === 'SCORING' ? 'default' : 'secondary'}>
|
||||||
|
{PHASE_LABEL[phase] ?? phase}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{/* Server-derived countdown */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className={`text-6xl font-bold tabular-nums ${
|
||||||
|
over ? 'animate-pulse text-[#de0f1e]' : remaining === null ? 'text-muted-foreground/40' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{remaining === null ? '–:––' : formatClock(remaining)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{remaining === null
|
||||||
|
? 'No timer running'
|
||||||
|
: over
|
||||||
|
? `Over time${paused ? ' · paused' : ''} — noted, not penalized`
|
||||||
|
: paused
|
||||||
|
? 'Paused'
|
||||||
|
: phase === 'PRESENTING'
|
||||||
|
? 'Presentation time remaining'
|
||||||
|
: 'Q&A time remaining'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary transition + pause */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
size="lg"
|
||||||
|
onClick={primaryAction.run}
|
||||||
|
disabled={primaryAction.disabled || busy}
|
||||||
|
>
|
||||||
|
<PrimaryIcon className="mr-2 h-4 w-4" />
|
||||||
|
{primaryAction.label}
|
||||||
|
</Button>
|
||||||
|
{remaining !== null &&
|
||||||
|
(paused ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => resumePhase.mutate({ roundId })}
|
||||||
|
disabled={resumePhase.isPending}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => pausePhase.mutate({ roundId })}
|
||||||
|
disabled={pausePhase.isPending}
|
||||||
|
>
|
||||||
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* One-off duration overrides for the NEXT start only (m:ss).
|
||||||
|
Per-project durations live in the Run Order list. */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Presentation override (m:ss, next start only)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 7:30"
|
||||||
|
className="tabular-nums"
|
||||||
|
value={presentationMin}
|
||||||
|
onChange={(e) => setPresentationMin(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Q&A override (m:ss, next start only)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 2:00"
|
||||||
|
className="tabular-nums"
|
||||||
|
value={qaMin}
|
||||||
|
onChange={(e) => setQaMin(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
342
src/components/admin/live/reveal-panel.tsx
Normal file
342
src/components/admin/live/reveal-panel.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { ArrowDown, ArrowUp, PartyPopper, Play, RotateCcw, Sparkles, Trash2, Wand2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
type RevealStep = {
|
||||||
|
kind: 'category-intro' | 'place' | 'audience-award' | 'overall-favorite' | 'thanks'
|
||||||
|
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
|
||||||
|
place?: number
|
||||||
|
projectId?: string
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
BUSINESS_CONCEPT: 'Business Concepts',
|
||||||
|
STARTUP: 'Startups',
|
||||||
|
}
|
||||||
|
const PLACE_LABEL: Record<number, string> = { 1: 'Winner', 2: '2nd place', 3: '3rd place' }
|
||||||
|
|
||||||
|
function describeStep(step: RevealStep): string {
|
||||||
|
switch (step.kind) {
|
||||||
|
case 'category-intro':
|
||||||
|
return `— ${CATEGORY_LABEL[step.category ?? ''] ?? 'Category'} —`
|
||||||
|
case 'place':
|
||||||
|
return `${PLACE_LABEL[step.place ?? 0] ?? `${step.place}th`} · ${step.title ?? '?'} (${CATEGORY_LABEL[step.category ?? ''] ?? ''})`
|
||||||
|
case 'audience-award':
|
||||||
|
return `Audience Choice (${CATEGORY_LABEL[step.category ?? ''] ?? ''}) · ${step.title ?? '?'}`
|
||||||
|
case 'overall-favorite':
|
||||||
|
return `Audience Favorite Overall · ${step.title ?? '?'}`
|
||||||
|
case 'thanks':
|
||||||
|
return 'Thank-you slide'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Results reveal builder + stepper. Compose privately from deliberation
|
||||||
|
* results / jury scores / audience tallies, preview every step, then arm the
|
||||||
|
* big screen and fire one step at a time. Nothing reaches the projector
|
||||||
|
* before "Arm".
|
||||||
|
*/
|
||||||
|
export function RevealPanel({ roundId, competitionId }: { roundId: string; competitionId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: session } = trpc.liveVoting.getSession.useQuery({ roundId })
|
||||||
|
const sessionId = session?.id ?? ''
|
||||||
|
const { data: reveal } = trpc.liveVoting.getRevealAdmin.useQuery(
|
||||||
|
{ sessionId },
|
||||||
|
{ enabled: !!sessionId, refetchInterval: 3000 }
|
||||||
|
)
|
||||||
|
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId })
|
||||||
|
const { data: results } = trpc.liveVoting.getResults.useQuery(
|
||||||
|
{ sessionId },
|
||||||
|
{ enabled: !!sessionId }
|
||||||
|
)
|
||||||
|
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
|
||||||
|
{ sessionId },
|
||||||
|
{ enabled: !!sessionId }
|
||||||
|
)
|
||||||
|
const { data: delibSessions } = trpc.deliberation.listSessions.useQuery({ competitionId })
|
||||||
|
|
||||||
|
const [draftSteps, setDraftSteps] = useState<RevealStep[] | null>(null)
|
||||||
|
|
||||||
|
const invalidate = () => utils.liveVoting.getRevealAdmin.invalidate({ sessionId })
|
||||||
|
const onError = (err: { message: string }) => toast.error(err.message)
|
||||||
|
const saveReveal = trpc.liveVoting.saveReveal.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setDraftSteps(null) // the saved copy is now canonical — unlocks Arm
|
||||||
|
invalidate()
|
||||||
|
toast.success('Reveal draft saved')
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const armReveal = trpc.liveVoting.armReveal.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const revealNext = trpc.liveVoting.revealNext.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
const resetReveal = trpc.liveVoting.resetReveal.useMutation({ onSuccess: invalidate, onError })
|
||||||
|
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
const savedSteps = (reveal?.stepsJson as RevealStep[] | undefined) ?? []
|
||||||
|
const steps = draftSteps ?? savedSteps
|
||||||
|
const status = reveal?.status ?? 'DRAFT'
|
||||||
|
const currentIndex = reveal?.currentStepIndex ?? -1
|
||||||
|
|
||||||
|
const categoryOf = (projectId: string) =>
|
||||||
|
cursor?.orderedProjects?.find((p) => p.id === projectId)?.competitionCategory ?? null
|
||||||
|
const displayName = (projectId: string) => {
|
||||||
|
const p = cursor?.orderedProjects?.find((p) => p.id === projectId)
|
||||||
|
return p?.teamName ?? p?.title ?? 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const compose = () => {
|
||||||
|
const composed: RevealStep[] = []
|
||||||
|
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
|
||||||
|
|
||||||
|
let usedDeliberation = false
|
||||||
|
for (const category of categories) {
|
||||||
|
// Locked deliberation results take precedence; jury score order is the fallback
|
||||||
|
const delib = (delibSessions ?? []).find(
|
||||||
|
(s: any) => s.category === category && s.status === 'DELIB_LOCKED' && s.results?.length > 0
|
||||||
|
)
|
||||||
|
let rankedProjectIds: string[]
|
||||||
|
if (delib) {
|
||||||
|
rankedProjectIds = delib.results.map((r: any) => r.projectId)
|
||||||
|
usedDeliberation = true
|
||||||
|
} else {
|
||||||
|
rankedProjectIds = (results?.results ?? [])
|
||||||
|
.filter((r: any) => r.project?.id && categoryOf(r.project.id) === category)
|
||||||
|
.map((r: any) => r.project.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rankedProjectIds.length === 0) continue
|
||||||
|
composed.push({
|
||||||
|
kind: 'category-intro',
|
||||||
|
category,
|
||||||
|
title: CATEGORY_LABEL[category],
|
||||||
|
})
|
||||||
|
const top = rankedProjectIds.slice(0, 3)
|
||||||
|
// Reverse order: 3rd → 2nd → 1st
|
||||||
|
top
|
||||||
|
.map((projectId, idx) => ({ projectId, place: idx + 1 }))
|
||||||
|
.reverse()
|
||||||
|
.forEach(({ projectId, place }) => {
|
||||||
|
composed.push({
|
||||||
|
kind: 'place',
|
||||||
|
category,
|
||||||
|
place,
|
||||||
|
projectId,
|
||||||
|
title: displayName(projectId),
|
||||||
|
subtitle: `${PLACE_LABEL[place] ?? `${place}th place`} — ${CATEGORY_LABEL[category]}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const audienceWindow = tallies?.windows.find((w) => w.windowKey === `CATEGORY:${category}`)
|
||||||
|
const audienceTop = audienceWindow?.projects[0]
|
||||||
|
if (audienceTop) {
|
||||||
|
composed.push({
|
||||||
|
kind: 'audience-award',
|
||||||
|
category,
|
||||||
|
projectId: audienceTop.projectId,
|
||||||
|
title: audienceTop.teamName ?? audienceTop.title,
|
||||||
|
subtitle: `Audience Choice — ${CATEGORY_LABEL[category]}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overallWindow = tallies?.windows.find((w) => w.windowKey === 'OVERALL')
|
||||||
|
const overallTop = overallWindow?.projects[0]
|
||||||
|
if (overallTop) {
|
||||||
|
composed.push({
|
||||||
|
kind: 'overall-favorite',
|
||||||
|
projectId: overallTop.projectId,
|
||||||
|
title: overallTop.teamName ?? overallTop.title,
|
||||||
|
subtitle: 'Audience Favorite — Overall',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
composed.push({ kind: 'thanks', title: 'Thank you' })
|
||||||
|
|
||||||
|
if (composed.length <= 1) {
|
||||||
|
toast.info('No results to compose from yet — scores and votes are still empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
usedDeliberation
|
||||||
|
? 'Composed from locked deliberation results + audience tallies'
|
||||||
|
: 'Composed from jury scores + audience tallies (no locked deliberation yet)'
|
||||||
|
)
|
||||||
|
setDraftSteps(composed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveStep = (index: number, delta: -1 | 1) => {
|
||||||
|
const next = [...steps]
|
||||||
|
const target = index + delta
|
||||||
|
if (target < 0 || target >= next.length) return
|
||||||
|
;[next[index], next[target]] = [next[target], next[index]]
|
||||||
|
setDraftSteps(next)
|
||||||
|
}
|
||||||
|
const removeStep = (index: number) => {
|
||||||
|
setDraftSteps(steps.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDraft = status === 'DRAFT'
|
||||||
|
const isLive = status === 'REVEALING' || status === 'DONE'
|
||||||
|
const nextStep = steps[currentIndex + 1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<PartyPopper className="h-5 w-5" />
|
||||||
|
Results Reveal
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Compose privately, arm the big screen, reveal step by step
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={isDraft ? 'secondary' : 'default'}
|
||||||
|
className={isLive ? 'bg-[#de0f1e] hover:bg-[#de0f1e]' : undefined}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isDraft && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={compose}>
|
||||||
|
<Wand2 className="mr-2 h-4 w-4" />
|
||||||
|
Compose from results
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!draftSteps || saveReveal.isPending}
|
||||||
|
onClick={() => sessionId && saveReveal.mutate({ sessionId, steps })}
|
||||||
|
>
|
||||||
|
Save draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Locked deliberation results take precedence; otherwise jury scores (top 3 per
|
||||||
|
category, revealed 3rd → 1st), plus audience tallies. Adjust the steps below
|
||||||
|
before saving if needed.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{steps.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const revealed = i <= currentIndex
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border p-2 text-sm ${
|
||||||
|
revealed
|
||||||
|
? 'border-green-600/30 bg-green-600/5'
|
||||||
|
: i === currentIndex + 1 && !isDraft
|
||||||
|
? 'border-[#de0f1e]/40 bg-[#de0f1e]/5'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-5 text-center text-xs tabular-nums text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{describeStep(step)}</span>
|
||||||
|
{revealed && <Badge variant="outline" className="text-xs">revealed</Badge>}
|
||||||
|
{isDraft && (
|
||||||
|
<div className="flex shrink-0 gap-0.5">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => moveStep(i, -1)}>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === steps.length - 1} onClick={() => moveStep(i, 1)}>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStep(i)}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDraft && savedSteps.length > 0 && !draftSteps && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
Arm reveal
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Arm the results reveal?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
The big screen switches to the Results splash immediately. Nothing is revealed
|
||||||
|
until you press “Reveal next”.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => sessionId && armReveal.mutate({ sessionId })}>
|
||||||
|
Arm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDraft && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
className="w-full bg-[#de0f1e] hover:bg-[#c00d1a]"
|
||||||
|
size="lg"
|
||||||
|
disabled={revealNext.isPending || status === 'DONE'}
|
||||||
|
onClick={() => sessionId && revealNext.mutate({ sessionId })}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
{status === 'DONE'
|
||||||
|
? 'All revealed'
|
||||||
|
: `Reveal next (${currentIndex + 2}/${steps.length})`}
|
||||||
|
</Button>
|
||||||
|
{nextStep && status !== 'DONE' && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Next on screen: <span className="font-medium">{describeStep(nextStep)}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => sessionId && resetReveal.mutate({ sessionId })}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Reset to draft (leaves reveal mode)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
219
src/components/admin/live/run-order-list.tsx
Normal file
219
src/components/admin/live/run-order-list.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { ArrowDown, ArrowUp, MonitorUp, Timer } from 'lucide-react'
|
||||||
|
import { formatClock, parseClock } from '@/lib/live-timer'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const CATEGORY_LABEL: Record<string, string> = {
|
||||||
|
BUSINESS_CONCEPT: 'Business Concepts',
|
||||||
|
STARTUP: 'Startups',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ceremony run order, grouped by category, with quick reorder (▲▼) and a
|
||||||
|
* "Send to screens" action per project — built for last-minute schedule
|
||||||
|
* shuffles without leaving the console.
|
||||||
|
*/
|
||||||
|
export function RunOrderList({ roundId }: { roundId: string }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
|
||||||
|
// Local drafts for the per-project minute inputs (committed on blur)
|
||||||
|
const [timingDrafts, setTimingDrafts] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const reorderMutation = trpc.live.reorder.useMutation({
|
||||||
|
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const timingMutation = trpc.live.setProjectTiming.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.live.getCursor.invalidate({ roundId })
|
||||||
|
toast.success('Project timing saved')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const commitTiming = (projectId: string, field: 'presentationSeconds' | 'qaSeconds', raw: string) => {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
const seconds = trimmed === '' ? null : parseClock(trimmed)
|
||||||
|
if (trimmed !== '' && seconds === null) {
|
||||||
|
toast.error('Use minutes:seconds, e.g. 7:30')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = cursor?.projectTimingOverrides?.[projectId]?.[field] ?? null
|
||||||
|
if (seconds === current) return
|
||||||
|
timingMutation.mutate({ roundId, projectId, [field]: seconds })
|
||||||
|
}
|
||||||
|
const sendMutation = trpc.live.sendToScreens.useMutation({
|
||||||
|
onSuccess: (_d, vars) => {
|
||||||
|
utils.live.getCursor.invalidate({ roundId })
|
||||||
|
const p = cursor?.orderedProjects?.find((p) => p.id === vars.projectId)
|
||||||
|
toast.success(`${p?.teamName ?? p?.title ?? 'Project'} is now on screens (up next)`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const projects = cursor?.orderedProjects ?? []
|
||||||
|
if (!cursor || projects.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (index: number, delta: -1 | 1) => {
|
||||||
|
const order = projects.map((p) => p.id)
|
||||||
|
const target = index + delta
|
||||||
|
if (target < 0 || target >= order.length) return
|
||||||
|
;[order[index], order[target]] = [order[target], order[index]]
|
||||||
|
reorderMutation.mutate({ roundId, projectOrder: order })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group rows under category headings while preserving the global order
|
||||||
|
const rows: Array<{ type: 'heading'; label: string } | { type: 'project'; index: number }> = []
|
||||||
|
let lastCategory: string | null = null
|
||||||
|
projects.forEach((p, index) => {
|
||||||
|
const cat = p.competitionCategory ?? 'OTHER'
|
||||||
|
if (cat !== lastCategory) {
|
||||||
|
rows.push({ type: 'heading', label: CATEGORY_LABEL[cat] ?? 'Other' })
|
||||||
|
lastCategory = cat
|
||||||
|
}
|
||||||
|
rows.push({ type: 'project', index })
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Run Order</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Reorder presentations on the fly · “Send to screens” puts a team up next everywhere
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
if (row.type === 'heading') {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={`h-${i}`}
|
||||||
|
className="pt-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground first:pt-0"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const project = projects[row.index]
|
||||||
|
const isActive = project.id === cursor.activeProjectId
|
||||||
|
const override = cursor.projectTimingOverrides?.[project.id]
|
||||||
|
const presKey = `${project.id}:pres`
|
||||||
|
const qaKey = `${project.id}:qa`
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border p-2.5 ${
|
||||||
|
isActive ? 'border-[#de0f1e]/40 bg-[#de0f1e]/5' : 'border-transparent hover:bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-6 text-center text-sm tabular-nums text-muted-foreground">
|
||||||
|
{row.index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{project.title}</p>
|
||||||
|
{project.teamName && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{project.teamName}</p>
|
||||||
|
)}
|
||||||
|
{/* Per-project durations (m:ss) — empty = round default */}
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Timer className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
Pres
|
||||||
|
<Input
|
||||||
|
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
|
||||||
|
placeholder="default"
|
||||||
|
value={
|
||||||
|
timingDrafts[presKey] ??
|
||||||
|
(override?.presentationSeconds != null
|
||||||
|
? formatClock(override.presentationSeconds)
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTimingDrafts((d) => ({ ...d, [presKey]: e.target.value }))
|
||||||
|
}
|
||||||
|
onBlur={(e) => {
|
||||||
|
commitTiming(project.id, 'presentationSeconds', e.target.value)
|
||||||
|
setTimingDrafts((d) => {
|
||||||
|
const next = { ...d }
|
||||||
|
delete next[presKey]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
Q&A
|
||||||
|
<Input
|
||||||
|
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
|
||||||
|
placeholder="default"
|
||||||
|
value={
|
||||||
|
timingDrafts[qaKey] ??
|
||||||
|
(override?.qaSeconds != null ? formatClock(override.qaSeconds) : '')
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTimingDrafts((d) => ({ ...d, [qaKey]: e.target.value }))
|
||||||
|
}
|
||||||
|
onBlur={(e) => {
|
||||||
|
commitTiming(project.id, 'qaSeconds', e.target.value)
|
||||||
|
setTimingDrafts((d) => {
|
||||||
|
const next = { ...d }
|
||||||
|
delete next[qaKey]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">m:ss</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<Badge className="shrink-0 bg-[#de0f1e] hover:bg-[#de0f1e]">
|
||||||
|
{cursor.projectPhase === 'ON_DECK' ? 'on deck' : 'live'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={reorderMutation.isPending || row.index === 0}
|
||||||
|
onClick={() => move(row.index, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={reorderMutation.isPending || row.index === projects.length - 1}
|
||||||
|
onClick={() => move(row.index, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 px-2 text-xs"
|
||||||
|
disabled={sendMutation.isPending || isActive}
|
||||||
|
onClick={() => sendMutation.mutate({ roundId, projectId: project.id })}
|
||||||
|
>
|
||||||
|
<MonitorUp className="h-3.5 w-3.5" />
|
||||||
|
Send to screens
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
src/components/admin/live/timing-log-card.tsx
Normal file
73
src/components/admin/live/timing-log-card.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { formatClock } from '@/lib/live-timer'
|
||||||
|
import { Timer } from 'lucide-react'
|
||||||
|
|
||||||
|
type TimingEntry = {
|
||||||
|
projectId: string
|
||||||
|
phase: 'PRESENTING' | 'QA'
|
||||||
|
startedAt: string
|
||||||
|
endedAt: string
|
||||||
|
configuredSeconds: number | null
|
||||||
|
elapsedSeconds: number
|
||||||
|
overranSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factual per-project timing record: configured vs actual, with overruns
|
||||||
|
* highlighted (noted, never penalized).
|
||||||
|
*/
|
||||||
|
export function TimingLogCard({ roundId }: { roundId: string }) {
|
||||||
|
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
|
||||||
|
|
||||||
|
const log = (cursor?.timingLogJson as TimingEntry[] | null) ?? []
|
||||||
|
if (!cursor || log.length === 0) return null
|
||||||
|
|
||||||
|
const titleFor = (projectId: string) => {
|
||||||
|
const p = cursor.orderedProjects?.find((p) => p.id === projectId)
|
||||||
|
return p?.teamName ?? p?.title ?? 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Timer className="h-5 w-5" />
|
||||||
|
Timing Log
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Configured vs actual — overruns are noted, not penalized</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{log.map((entry, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-lg border p-2.5 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{titleFor(entry.projectId)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{entry.phase === 'PRESENTING' ? 'Presentation' : 'Q&A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{entry.configuredSeconds != null ? formatClock(entry.configuredSeconds) : '–'} planned
|
||||||
|
{' · '}
|
||||||
|
{formatClock(entry.elapsedSeconds ?? 0)} actual
|
||||||
|
</span>
|
||||||
|
{entry.overranSeconds > 0 ? (
|
||||||
|
<Badge variant="destructive" className="shrink-0 tabular-nums">
|
||||||
|
+{formatClock(entry.overranSeconds).replace('+', '')} over
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
on time
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Download, ExternalLink, Hotel as HotelIcon, Loader2, Plus, Trash2, Pencil } from 'lucide-react'
|
import { Download, ExternalLink, Hotel as HotelIcon, Loader2, Plus, Trash2, Pencil } from 'lucide-react'
|
||||||
|
|
||||||
|
// Radix <SelectItem> forbids an empty-string value, so the "unassigned" option
|
||||||
|
// uses this sentinel; handleHotelChange maps it back to an unassign.
|
||||||
|
const UNASSIGN_VALUE = '__unassign__'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
programId: string
|
programId: string
|
||||||
}
|
}
|
||||||
@@ -439,7 +443,7 @@ function AttendeeRoomRow({
|
|||||||
const currentHotelId = row.stay?.hotelId ?? ''
|
const currentHotelId = row.stay?.hotelId ?? ''
|
||||||
|
|
||||||
const handleHotelChange = (value: string) => {
|
const handleHotelChange = (value: string) => {
|
||||||
if (!value) {
|
if (!value || value === UNASSIGN_VALUE) {
|
||||||
unassignMutation.mutate({ attendingMemberId: row.attendingMemberId })
|
unassignMutation.mutate({ attendingMemberId: row.attendingMemberId })
|
||||||
} else {
|
} else {
|
||||||
assignMutation.mutate({
|
assignMutation.mutate({
|
||||||
@@ -506,7 +510,7 @@ function AttendeeRoomRow({
|
|||||||
<SelectValue placeholder="— Unassigned —" />
|
<SelectValue placeholder="— Unassigned —" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">— Unassigned —</SelectItem>
|
<SelectItem value={UNASSIGN_VALUE}>— Unassigned —</SelectItem>
|
||||||
{hotels.map((h) => (
|
{hotels.map((h) => (
|
||||||
<SelectItem key={h.id} value={h.id}>
|
<SelectItem key={h.id} value={h.id}>
|
||||||
{h.name}
|
{h.name}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Plus, Pencil, Trash2, Mail, MailCheck, Utensils } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const ALLERGENS = [
|
const ALLERGENS = [
|
||||||
@@ -90,6 +91,13 @@ export const LunchExternals = forwardRef<
|
|||||||
onSuccess: invalidateAll,
|
onSuccess: invalidateAll,
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
const sendInvite = trpc.lunch.sendExternalInvite.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll()
|
||||||
|
toast.success('Dish invite sent')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
const editingRow =
|
const editingRow =
|
||||||
editing?.mode === 'edit'
|
editing?.mode === 'edit'
|
||||||
@@ -123,7 +131,40 @@ export const LunchExternals = forwardRef<
|
|||||||
{e.project?.title ?? 'Standalone'}
|
{e.project?.title ?? 'Standalone'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
|
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
|
||||||
|
<td>
|
||||||
|
{e.dishId ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Utensils className="h-3 w-3" /> Picked
|
||||||
|
</Badge>
|
||||||
|
) : !e.email ? (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
No email
|
||||||
|
</Badge>
|
||||||
|
) : e.inviteSentAt ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<MailCheck className="h-3 w-3" /> Invited
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
Not invited
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
|
{e.email && !e.dishId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title={e.inviteSentAt ? 'Resend dish invite' : 'Send dish invite'}
|
||||||
|
disabled={
|
||||||
|
sendInvite.isPending &&
|
||||||
|
sendInvite.variables?.externalId === e.id
|
||||||
|
}
|
||||||
|
onClick={() => sendInvite.mutate({ externalId: e.id })}
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
64
src/components/applicant/final-documents-banner.tsx
Normal file
64
src/components/applicant/final-documents-banner.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FileText, Video, CheckCircle2, Circle, Clock, Upload } from 'lucide-react'
|
||||||
|
|
||||||
|
export function FinalDocumentsBanner() {
|
||||||
|
const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery()
|
||||||
|
if (!status || status.requirements.length === 0) return null
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
|
||||||
|
const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
|
||||||
|
.formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value
|
||||||
|
const uploadedCount = status.requirements.filter((r) => r.uploaded).length
|
||||||
|
const total = status.requirements.length
|
||||||
|
const optionalMode = !status.hasRequired
|
||||||
|
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}>
|
||||||
|
<CardContent className="py-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{done
|
||||||
|
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
|
||||||
|
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
|
||||||
|
</div>
|
||||||
|
{status.deadline && (
|
||||||
|
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{status.deadlinePassed ? 'Deadline passed' : 'Due'}: {fmt.format(new Date(status.deadline))}
|
||||||
|
{zone ? ` (${zone})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{status.requirements.map((r) => {
|
||||||
|
const Icon = r.acceptedMimeTypes.some((m) => m.startsWith('video/')) ? Video : FileText
|
||||||
|
return (
|
||||||
|
<span key={r.id} className="flex items-center gap-1.5 text-sm">
|
||||||
|
{r.uploaded ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-muted-foreground/50" />}
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!done && (
|
||||||
|
<Button asChild size="sm" className="bg-brand-blue hover:bg-brand-blue-light">
|
||||||
|
<Link href="/applicant/documents">
|
||||||
|
<Upload className="mr-2 h-4 w-4" /> Upload documents
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/applicant/final-documents-panel.tsx
Normal file
61
src/components/applicant/final-documents-panel.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { FileCheck2, Clock, Upload, CheckCircle2, Circle } from 'lucide-react'
|
||||||
|
|
||||||
|
type Props =
|
||||||
|
| { variant: 'team' }
|
||||||
|
| { variant: 'mentor'; projectId: string }
|
||||||
|
|
||||||
|
export function FinalDocumentsPanel(props: Props) {
|
||||||
|
const teamQuery = trpc.applicant.getFinalDocumentStatus.useQuery(undefined, { enabled: props.variant === 'team' })
|
||||||
|
const mentorQuery = trpc.mentor.getProjectFinalDocuments.useQuery(
|
||||||
|
{ projectId: props.variant === 'mentor' ? props.projectId : '' },
|
||||||
|
{ enabled: props.variant === 'mentor' },
|
||||||
|
)
|
||||||
|
const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data
|
||||||
|
if (!status || status.requirements.length === 0) return null
|
||||||
|
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle>
|
||||||
|
{done
|
||||||
|
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">{status.hasRequired ? 'Submitted' : 'Uploaded'}</Badge>
|
||||||
|
: status.deadline && (
|
||||||
|
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
|
<Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
|
||||||
|
{!status.hasRequired && ' These uploads are optional.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{status.requirements.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
{r.uploaded ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <Circle className="h-4 w-4 text-muted-foreground/50" />}
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{props.variant === 'team' && !done && (
|
||||||
|
<Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light">
|
||||||
|
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/components/finals/finals-documents-review.tsx
Normal file
125
src/components/finals/finals-documents-review.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { FilePreview } from '@/components/shared/file-viewer'
|
||||||
|
import { FileText, Download, ShieldAlert } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only judge review of finalist grand-final documents. Self-resolves the
|
||||||
|
* active program and self-gates on the `finalist.listReviewDocuments` FORBIDDEN
|
||||||
|
* check, so it can be mounted on any route reachable by either the finals jury
|
||||||
|
* (`/jury/finals-documents`) or program admins (`/admin/finals-documents`).
|
||||||
|
*/
|
||||||
|
export function FinalsDocumentsReview() {
|
||||||
|
const { data: programId, isLoading: programLoading } =
|
||||||
|
trpc.competition.getActiveProgramId.useQuery()
|
||||||
|
const { data, isLoading, error } = trpc.finalist.listReviewDocuments.useQuery(
|
||||||
|
{ programId: programId! },
|
||||||
|
{ enabled: !!programId, retry: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error?.data?.code === 'FORBIDDEN') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||||
|
<ShieldAlert className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="font-medium">No access</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This review is for the Grand-Final jury and program admins.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active program resolved — nothing to review.
|
||||||
|
if (!programLoading && !programId) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||||
|
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="font-medium">No active program</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Finalist documents will appear here once a program is active.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||||
|
Finalist Documents
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{data.totalCount} finalist team{data.totalCount === 1 ? '' : 's'} · every file each team
|
||||||
|
has submitted across all rounds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data.teams.map((team) => (
|
||||||
|
<Card key={team.projectId}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">{team.teamName}</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{team.category && <Badge variant="secondary">{team.category}</Badge>}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{team.files.length} file{team.files.length === 1 ? '' : 's'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
{team.files.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center md:col-span-2">
|
||||||
|
No files submitted.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{team.files.map((f) => (
|
||||||
|
<div key={f.id} className="rounded-lg border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm flex items-center gap-2 min-w-0">
|
||||||
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{f.docLabel}</span>
|
||||||
|
</span>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-7 px-2 text-xs shrink-0">
|
||||||
|
<a href={f.url} target="_blank" rel="noreferrer">
|
||||||
|
<Download className="h-3 w-3 mr-1" /> Open
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{f.roundLabel}
|
||||||
|
</Badge>
|
||||||
|
{f.isFinaleUpload && (
|
||||||
|
<Badge className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
|
||||||
|
Revised for finals
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FilePreview
|
||||||
|
file={{ mimeType: f.mimeType, fileName: f.fileName }}
|
||||||
|
url={f.url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { CheckCircle2 } from 'lucide-react'
|
import { CheckCircle2, Pencil } from 'lucide-react'
|
||||||
|
|
||||||
interface LiveVotingCriterion {
|
interface LiveVotingCriterion {
|
||||||
id: string
|
id: string
|
||||||
@@ -30,12 +31,19 @@ interface LiveVotingFormProps {
|
|||||||
projectId: string
|
projectId: string
|
||||||
votingMode?: 'simple' | 'criteria'
|
votingMode?: 'simple' | 'criteria'
|
||||||
criteria?: LiveVotingCriterion[]
|
criteria?: LiveVotingCriterion[]
|
||||||
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void
|
onVoteSubmit: (vote: {
|
||||||
|
score: number
|
||||||
|
criterionScores?: Record<string, number>
|
||||||
|
comment?: string
|
||||||
|
}) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
existingVote?: {
|
existingVote?: {
|
||||||
score: number
|
score: number
|
||||||
criterionScoresJson?: Record<string, number>
|
criterionScoresJson?: Record<string, number>
|
||||||
|
comment?: string | null
|
||||||
} | null
|
} | null
|
||||||
|
/** Visual emphasis when the admin opens the scoring phase */
|
||||||
|
highlighted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveVotingForm({
|
export function LiveVotingForm({
|
||||||
@@ -45,73 +53,115 @@ export function LiveVotingForm({
|
|||||||
onVoteSubmit,
|
onVoteSubmit,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
existingVote,
|
existingVote,
|
||||||
|
highlighted = false,
|
||||||
}: LiveVotingFormProps) {
|
}: LiveVotingFormProps) {
|
||||||
const [score, setScore] = useState(existingVote?.score ?? 50)
|
const [score, setScore] = useState(existingVote?.score ?? 5)
|
||||||
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
|
const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
|
||||||
existingVote?.criterionScoresJson ?? {}
|
existingVote?.criterionScoresJson ?? {}
|
||||||
)
|
)
|
||||||
|
const [comment, setComment] = useState(existingVote?.comment ?? '')
|
||||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
|
||||||
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote)
|
const [editing, setEditing] = useState(!existingVote)
|
||||||
|
|
||||||
const handleSubmit = () => {
|
// When the ceremony cursor moves to a new project, reset the form state
|
||||||
setConfirmDialogOpen(true)
|
useEffect(() => {
|
||||||
}
|
setScore(existingVote?.score ?? 5)
|
||||||
|
setCriterionScores(existingVote?.criterionScoresJson ?? {})
|
||||||
|
setComment(existingVote?.comment ?? '')
|
||||||
|
setEditing(!existingVote)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (votingMode === 'criteria' && criteria) {
|
if (votingMode === 'criteria' && criteria) {
|
||||||
// Compute weighted score for display
|
// The server recomputes the weighted score from criterionScores; this
|
||||||
|
// 1-10 value is only a fallback and MUST stay within the API's range.
|
||||||
let weightedSum = 0
|
let weightedSum = 0
|
||||||
for (const c of criteria) {
|
for (const c of criteria) {
|
||||||
const normalizedScore = (criterionScores[c.id] / c.scale) * 10
|
const normalizedScore = ((criterionScores[c.id] ?? 0) / c.scale) * 10
|
||||||
weightedSum += normalizedScore * c.weight
|
weightedSum += normalizedScore * c.weight
|
||||||
}
|
}
|
||||||
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display
|
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||||
|
|
||||||
onVoteSubmit({
|
onVoteSubmit({
|
||||||
score: computedScore,
|
score: computedScore,
|
||||||
criterionScores,
|
criterionScores,
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
onVoteSubmit({ score })
|
onVoteSubmit({ score, comment: comment.trim() || undefined })
|
||||||
}
|
}
|
||||||
|
// NOTE: editing is NOT flipped here — the parent re-keys this component
|
||||||
setHasSubmitted(true)
|
// when the persisted vote actually lands, so a failed mutation leaves the
|
||||||
|
// form editable instead of lying with a "submitted" state.
|
||||||
setConfirmDialogOpen(false)
|
setConfirmDialogOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSubmitted || disabled) {
|
if (!editing) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
|
<CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
|
||||||
<p className="font-medium">Vote Submitted</p>
|
<p className="font-medium">Vote Submitted</p>
|
||||||
{votingMode === 'simple' && (
|
{votingMode === 'criteria' && criteria ? (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
|
|
||||||
)}
|
|
||||||
{votingMode === 'criteria' && criteria && (
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground space-y-1">
|
<div className="mt-3 text-sm text-muted-foreground space-y-1">
|
||||||
{criteria.map((c) => (
|
{criteria.map((c) => (
|
||||||
<div key={c.id} className="flex justify-between gap-4">
|
<div key={c.id} className="flex justify-between gap-6">
|
||||||
<span>{c.label}:</span>
|
<span>{c.label}:</span>
|
||||||
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span>
|
<span className="font-medium">
|
||||||
|
{criterionScores[c.id] ?? 0}/{c.scale}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/10</p>
|
||||||
)}
|
)}
|
||||||
|
{comment.trim() && (
|
||||||
|
<p className="mt-3 max-w-md text-center text-xs italic text-muted-foreground">
|
||||||
|
“{comment.trim()}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit vote
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
You can revise your vote until the session closes
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentField = (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Comment (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Visible to admins alongside your scores…"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
// Criteria-based voting
|
// Criteria-based voting
|
||||||
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
|
if (votingMode === 'criteria' && criteria && criteria.length > 0) {
|
||||||
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0)
|
const allScored = criteria.every(
|
||||||
|
(c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Criteria-Based Voting</CardTitle>
|
<CardTitle>Score This Project</CardTitle>
|
||||||
<CardDescription>Score each criterion individually</CardDescription>
|
<CardDescription>Score each criterion individually</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -153,7 +203,7 @@ export function LiveVotingForm({
|
|||||||
onValueChange={(values) =>
|
onValueChange={(values) =>
|
||||||
setCriterionScores({
|
setCriterionScores({
|
||||||
...criterionScores,
|
...criterionScores,
|
||||||
[criterion.id]: values[0],
|
[criterion.id]: Math.max(1, values[0]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -164,13 +214,15 @@ export function LiveVotingForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{commentField}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={() => setConfirmDialogOpen(true)}
|
||||||
disabled={!allScored}
|
disabled={!allScored || disabled}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
Submit Vote
|
{existingVote ? 'Update Vote' : 'Submit Vote'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -179,17 +231,19 @@ export function LiveVotingForm({
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
|
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription asChild>
|
||||||
<div className="space-y-2 mt-2">
|
<div className="space-y-2 mt-2">
|
||||||
<p className="font-medium">Your scores:</p>
|
<p className="font-medium">Your scores:</p>
|
||||||
{criteria.map((c) => (
|
{criteria.map((c) => (
|
||||||
<div key={c.id} className="flex justify-between text-sm">
|
<div key={c.id} className="flex justify-between text-sm">
|
||||||
<span>{c.label}:</span>
|
<span>{c.label}:</span>
|
||||||
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span>
|
<span className="font-semibold">
|
||||||
|
{criterionScores[c.id]}/{c.scale}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<p className="text-xs text-muted-foreground mt-3">
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
This action cannot be undone. Are you sure?
|
You can still revise your vote until the session closes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
@@ -204,13 +258,13 @@ export function LiveVotingForm({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple voting (0-100 slider)
|
// Simple voting (1-10 slider — matches the API contract)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Live Voting</CardTitle>
|
<CardTitle>Score This Project</CardTitle>
|
||||||
<CardDescription>Rate this project on a scale of 0-100</CardDescription>
|
<CardDescription>Rate this project on a scale of 1-10</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -219,10 +273,12 @@ export function LiveVotingForm({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="1"
|
||||||
max="100"
|
max="10"
|
||||||
value={score}
|
value={score}
|
||||||
onChange={(e) => setScore(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
|
onChange={(e) =>
|
||||||
|
setScore(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
|
||||||
|
}
|
||||||
className="w-20 text-center"
|
className="w-20 text-center"
|
||||||
/>
|
/>
|
||||||
<span className="text-2xl font-bold text-primary">{score}</span>
|
<span className="text-2xl font-bold text-primary">{score}</span>
|
||||||
@@ -231,34 +287,35 @@ export function LiveVotingForm({
|
|||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
value={[score]}
|
value={[score]}
|
||||||
onValueChange={(values) => setScore(values[0])}
|
onValueChange={(values) => setScore(Math.max(1, values[0]))}
|
||||||
min={0}
|
min={1}
|
||||||
max={100}
|
max={10}
|
||||||
step={1}
|
step={1}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>Poor (0)</span>
|
<span>Poor (1)</span>
|
||||||
<span>Average (50)</span>
|
<span>Average (5)</span>
|
||||||
<span>Excellent (100)</span>
|
<span>Excellent (10)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSubmit} className="w-full" size="lg">
|
{commentField}
|
||||||
Submit Vote
|
|
||||||
|
<Button onClick={() => setConfirmDialogOpen(true)} className="w-full" size="lg" disabled={disabled}>
|
||||||
|
{existingVote ? 'Update Vote' : 'Submit Vote'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
|
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
You are about to submit a score of <strong>{score}/100</strong>. This action cannot
|
You are about to submit a score of <strong>{score}/10</strong>. You can still revise
|
||||||
be undone. Are you sure?
|
it until the session closes.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { BookOpen, Home, Trophy, ClipboardList } from 'lucide-react'
|
import { BookOpen, Home, Trophy, ClipboardList, FileText, Radio, Scale } from 'lucide-react'
|
||||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -47,6 +47,14 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
{ refetchInterval: 60000 }
|
{ refetchInterval: 60000 }
|
||||||
)
|
)
|
||||||
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
||||||
|
// Only Grand-Final jury members (and admins) can open the finalist documents
|
||||||
|
// review — hide the link from everyone else so they don't hit a dead "No access" page.
|
||||||
|
const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery()
|
||||||
|
// Ceremony-day links: live scoring page while a ceremony cursor is active,
|
||||||
|
// plus any deliberation sessions the juror participates in.
|
||||||
|
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery(undefined, {
|
||||||
|
refetchInterval: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
||||||
|
|
||||||
@@ -61,6 +69,29 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
href: '/jury/competitions',
|
href: '/jury/competitions',
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
|
...(canReviewFinals
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Finalist Documents',
|
||||||
|
href: '/jury/finals-documents',
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(ceremony?.liveRoundId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Live Ceremony',
|
||||||
|
href: `/jury/competitions/${ceremony.liveRoundId}/live`,
|
||||||
|
icon: Radio,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(ceremony?.deliberations ?? []).map((d) => ({
|
||||||
|
name: `Deliberation — ${d.category === 'STARTUP' ? 'Startups' : 'Business Concepts'}`,
|
||||||
|
href: `/jury/competitions/deliberation/${d.id}`,
|
||||||
|
icon: Scale,
|
||||||
|
})),
|
||||||
...(myAwards && myAwards.length > 0
|
...(myAwards && myAwards.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,8 +58,13 @@ export const authConfig: NextAuthConfig = {
|
|||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
'/reset-password',
|
'/reset-password',
|
||||||
'/apply',
|
'/apply',
|
||||||
|
'/lunch/pick', // external attendees pick a dish via signed token (no account)
|
||||||
|
'/vote', // audience QR voting at the grand finale (token-based, no account)
|
||||||
|
'/live-scores', // public live scoreboard
|
||||||
|
'/live/ceremony', // big-screen ceremony view (projector, no account)
|
||||||
'/api/auth',
|
'/api/auth',
|
||||||
'/api/trpc', // tRPC handles its own auth via procedures
|
'/api/trpc', // tRPC handles its own auth via procedures
|
||||||
|
'/api/cron', // cron endpoints self-guard via x-cron-secret (CRON_SECRET)
|
||||||
]
|
]
|
||||||
|
|
||||||
// Check if it's a public path
|
// Check if it's a public path
|
||||||
|
|||||||
111
src/lib/email.ts
111
src/lib/email.ts
@@ -2238,6 +2238,42 @@ function getFinalistReminderTemplate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder email sent to a finalist team to upload their Grand Final documents.
|
||||||
|
*/
|
||||||
|
function getGrandFinalDocsReminderTemplate(
|
||||||
|
name: string,
|
||||||
|
projectTitle: string,
|
||||||
|
deadline: Date | null,
|
||||||
|
uploadUrl: string,
|
||||||
|
missing: string[],
|
||||||
|
): EmailTemplate {
|
||||||
|
const greeting = name ? `Hi ${name},` : 'Hi there,'
|
||||||
|
const formattedDeadline = deadline
|
||||||
|
? deadline.toLocaleString('en-GB', { timeZone: 'Europe/Paris', dateStyle: 'full', timeStyle: 'short' })
|
||||||
|
: null
|
||||||
|
const missingLine = missing.length
|
||||||
|
? `Still needed: <strong>${escapeHtml(missing.join(', '))}</strong>.`
|
||||||
|
: 'Please make sure all required documents are uploaded.'
|
||||||
|
const content = `
|
||||||
|
${sectionTitle('Grand Final — Final Documents')}
|
||||||
|
${paragraph(greeting)}
|
||||||
|
${paragraph(`Please upload the final documents for <strong>${escapeHtml(projectTitle)}</strong> ahead of the Monaco Ocean Protection Challenge Grand Finale.`)}
|
||||||
|
${paragraph(missingLine)}
|
||||||
|
${formattedDeadline ? infoBox(`Deadline: <strong>${escapeHtml(formattedDeadline)} (Paris time)</strong>.`, 'warning') : ''}
|
||||||
|
${ctaButton(uploadUrl, 'Upload documents')}
|
||||||
|
${paragraph('If you have any questions, please reach out to the MOPC team.')}
|
||||||
|
`
|
||||||
|
const text = [
|
||||||
|
greeting, '',
|
||||||
|
`Please upload the final documents for "${projectTitle}" ahead of the Grand Finale.`,
|
||||||
|
missing.length ? `Still needed: ${missing.join(', ')}.` : 'Please make sure all required documents are uploaded.',
|
||||||
|
formattedDeadline ? `Deadline: ${formattedDeadline} (Paris time).` : '',
|
||||||
|
`Upload documents: ${uploadUrl}`, '', 'The MOPC team',
|
||||||
|
].join('\n')
|
||||||
|
return { subject: 'Action needed: upload your Grand Final documents', html: getEmailWrapper(content), text }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification email sent to a team when their confirmed finalist slot is withdrawn by an admin.
|
* Notification email sent to a team when their confirmed finalist slot is withdrawn by an admin.
|
||||||
*/
|
*/
|
||||||
@@ -2685,6 +2721,14 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
new Date((ctx.metadata?.deadline as string) || Date.now()),
|
new Date((ctx.metadata?.deadline as string) || Date.now()),
|
||||||
ctx.linkUrl || '',
|
ctx.linkUrl || '',
|
||||||
),
|
),
|
||||||
|
GRAND_FINAL_DOCS_REMINDER: (ctx) =>
|
||||||
|
getGrandFinalDocsReminderTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.projectTitle as string) || 'Your project',
|
||||||
|
ctx.metadata?.deadline ? new Date(ctx.metadata.deadline as string) : null,
|
||||||
|
ctx.linkUrl || '',
|
||||||
|
(ctx.metadata?.missing as string[]) || [],
|
||||||
|
),
|
||||||
FINALIST_WITHDRAWN: (ctx) =>
|
FINALIST_WITHDRAWN: (ctx) =>
|
||||||
getFinalistWithdrawnTemplate(
|
getFinalistWithdrawnTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
@@ -3650,6 +3694,73 @@ ${opts.pickUrl}`
|
|||||||
await sendEmail({ to: opts.to, subject, text, html })
|
await sendEmail({ to: opts.to, subject, text, html })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite an external lunch attendee to choose their dish via a tokenized,
|
||||||
|
* no-login page. Used for the initial invite (auto on add + admin resend) and
|
||||||
|
* for reminder sweeps — one template serves both.
|
||||||
|
*/
|
||||||
|
export async function sendExternalDishInviteEmail(opts: {
|
||||||
|
to: string
|
||||||
|
name: string
|
||||||
|
eventAt: Date | null
|
||||||
|
venue: string | null
|
||||||
|
notes: string | null
|
||||||
|
changeDeadline: Date | null
|
||||||
|
pickUrl: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
|
||||||
|
})
|
||||||
|
const subject = 'Choose your lunch dish — Monaco Ocean Protection Challenge'
|
||||||
|
const venuePhrase = opts.venue
|
||||||
|
? `lunch at ${escapeHtml(opts.venue)}`
|
||||||
|
: 'the Monaco Ocean Protection Challenge lunch'
|
||||||
|
const eventLine = opts.eventAt
|
||||||
|
? `<strong>When:</strong> ${escapeHtml(fmt.format(opts.eventAt))} (Europe/Monaco)`
|
||||||
|
: ''
|
||||||
|
const notesLine = opts.notes ? escapeHtml(opts.notes) : ''
|
||||||
|
const content = `
|
||||||
|
${sectionTitle('Choose your lunch dish')}
|
||||||
|
${paragraph(`Hi ${escapeHtml(opts.name)},`)}
|
||||||
|
${paragraph(
|
||||||
|
`You are joining us for ${venuePhrase}. Please pick your dish and let us know ` +
|
||||||
|
'about any allergies so the kitchen can cater for you.',
|
||||||
|
)}
|
||||||
|
${eventLine ? paragraph(eventLine) : ''}
|
||||||
|
${notesLine ? paragraph(notesLine) : ''}
|
||||||
|
${
|
||||||
|
opts.changeDeadline
|
||||||
|
? infoBox(
|
||||||
|
`<strong>Please choose by ${escapeHtml(fmt.format(opts.changeDeadline))}.</strong>`,
|
||||||
|
'warning',
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${ctaButton(opts.pickUrl, 'Choose my dish')}
|
||||||
|
${paragraph(
|
||||||
|
`<span style="color:#64748b;font-size:13px;">If you have any questions, reply to this email and we'll help.</span>`,
|
||||||
|
)}
|
||||||
|
`
|
||||||
|
const html = getEmailWrapper(content)
|
||||||
|
const text = [
|
||||||
|
`Choose your lunch dish — Monaco Ocean Protection Challenge`,
|
||||||
|
``,
|
||||||
|
`Hi ${opts.name},`,
|
||||||
|
``,
|
||||||
|
`Please pick your dish for ${
|
||||||
|
opts.venue ? `lunch at ${opts.venue}` : 'the Monaco Ocean Protection Challenge lunch'
|
||||||
|
}.`,
|
||||||
|
opts.eventAt ? `When: ${fmt.format(opts.eventAt)} (Europe/Monaco)` : '',
|
||||||
|
opts.notes ? opts.notes : '',
|
||||||
|
opts.changeDeadline ? `Please choose by: ${fmt.format(opts.changeDeadline)}` : '',
|
||||||
|
``,
|
||||||
|
`Choose your dish: ${opts.pickUrl}`,
|
||||||
|
]
|
||||||
|
.filter((l) => l !== '')
|
||||||
|
.join('\n')
|
||||||
|
await sendEmail({ to: opts.to, subject, text, html })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the lunch recap manifest to admins + extra recipients.
|
* Send the lunch recap manifest to admins + extra recipients.
|
||||||
* Caller passes the assembled recap payload from `buildRecapPayload`.
|
* Caller passes the assembled recap payload from `buildRecapPayload`.
|
||||||
|
|||||||
45
src/lib/external-lunch-token.ts
Normal file
45
src/lib/external-lunch-token.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto'
|
||||||
|
|
||||||
|
export type ExternalLunchTokenPayload = {
|
||||||
|
externalId: string
|
||||||
|
/** Unix seconds. Token is rejected after this. */
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const s = process.env.NEXTAUTH_SECRET
|
||||||
|
if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign external lunch tokens')
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(payloadB64: string): string {
|
||||||
|
return createHmac('sha256', getSecret()).update(payloadB64).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signExternalLunchToken(payload: ExternalLunchTokenPayload): string {
|
||||||
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
|
const sig = hmac(payloadB64)
|
||||||
|
return `${payloadB64}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyExternalLunchToken(token: string): ExternalLunchTokenPayload {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 2) throw new Error('Invalid external lunch token: malformed')
|
||||||
|
const [payloadB64, sig] = parts
|
||||||
|
const expected = hmac(payloadB64)
|
||||||
|
const a = Buffer.from(sig, 'hex')
|
||||||
|
const b = Buffer.from(expected, 'hex')
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
throw new Error('Invalid external lunch token: signature mismatch')
|
||||||
|
}
|
||||||
|
let payload: ExternalLunchTokenPayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'))
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid external lunch token: payload not parseable')
|
||||||
|
}
|
||||||
|
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
|
throw new Error('Invalid external lunch token: expired')
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
54
src/lib/live-timer.ts
Normal file
54
src/lib/live-timer.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Server-stamped phase timer math for the grand-finale ceremony.
|
||||||
|
*
|
||||||
|
* The cursor stores `phaseStartedAt` + `phaseDurationSeconds` plus a pause
|
||||||
|
* accumulator; every client derives the countdown locally from those stamps,
|
||||||
|
* so all screens agree and overtime is just a negative remainder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PhaseTimerState = {
|
||||||
|
phaseStartedAt: Date | string | null
|
||||||
|
phaseDurationSeconds: number | null
|
||||||
|
phasePausedAt: Date | string | null
|
||||||
|
phasePausedAccumMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
|
||||||
|
if (!t.phaseStartedAt) return 0
|
||||||
|
const start = new Date(t.phaseStartedAt).getTime()
|
||||||
|
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
|
||||||
|
return Math.max(0, end - start - t.phasePausedAccumMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds left on the phase timer; negative = overtime; null = no timer running. */
|
||||||
|
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
|
||||||
|
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
|
||||||
|
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `5:05` for positive seconds, `+1:23` for overtime. */
|
||||||
|
export function formatClock(seconds: number): string {
|
||||||
|
const over = seconds < 0
|
||||||
|
const abs = Math.abs(seconds)
|
||||||
|
const m = Math.floor(abs / 60)
|
||||||
|
const s = abs % 60
|
||||||
|
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an admin duration input: `m:ss` / `mm:ss`, or plain minutes (`7`).
|
||||||
|
* Returns total seconds, or null for anything unparseable.
|
||||||
|
*/
|
||||||
|
export function parseClock(input: string): number | null {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const colonMatch = /^(\d{1,3}):([0-5]\d)$/.exec(trimmed)
|
||||||
|
if (colonMatch) {
|
||||||
|
return parseInt(colonMatch[1], 10) * 60 + parseInt(colonMatch[2], 10)
|
||||||
|
}
|
||||||
|
const plainMatch = /^(\d{1,3})$/.exec(trimmed)
|
||||||
|
if (plainMatch) {
|
||||||
|
return parseInt(plainMatch[1], 10) * 60
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -46,6 +46,39 @@ export function checkRateLimit(
|
|||||||
return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt }
|
return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceremony-day procedures: cheap, read-mostly public endpoints polled by every
|
||||||
|
* screen in the venue. Hundreds of phones share one IP behind the venue NAT,
|
||||||
|
* so the standard 100/min-per-IP budget would 429 the audience mid-vote.
|
||||||
|
* Requests consisting ONLY of these procedures get a separate, much larger
|
||||||
|
* per-IP bucket. Vote integrity is enforced server-side (token + IP cap on
|
||||||
|
* AudienceFavoriteVote), not by the rate limiter.
|
||||||
|
*/
|
||||||
|
const CEREMONY_PROCEDURES = new Set([
|
||||||
|
'liveVoting.getCeremonyState',
|
||||||
|
'liveVoting.getAudienceWindow',
|
||||||
|
'liveVoting.getAudienceContextByRound',
|
||||||
|
'liveVoting.registerAudienceVoter',
|
||||||
|
'liveVoting.castFavoriteVote',
|
||||||
|
'liveVoting.getSessionForVotingByRound',
|
||||||
|
'liveVoting.getPublicResults',
|
||||||
|
'liveVoting.getAudienceSession',
|
||||||
|
'liveVoting.getSessionForVoting',
|
||||||
|
'live.getCursor',
|
||||||
|
'live.getMyNotes',
|
||||||
|
'live.saveNote',
|
||||||
|
'live.getMyCeremonyContext',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** True when every (possibly batched) procedure in the tRPC URL is ceremony traffic. */
|
||||||
|
export function isCeremonyTraffic(pathname: string): boolean {
|
||||||
|
const path = pathname.replace(/^\/api\/trpc\/?/, '')
|
||||||
|
if (!path) return false
|
||||||
|
return path
|
||||||
|
.split(',')
|
||||||
|
.every((p) => CEREMONY_PROCEDURES.has(decodeURIComponent(p)))
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up stale entries every 5 minutes to prevent memory leaks
|
// Clean up stale entries every 5 minutes to prevent memory leaks
|
||||||
if (typeof setInterval !== 'undefined') {
|
if (typeof setInterval !== 'undefined') {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { generateLogoKey, createStorageProvider, type StorageProviderType } from
|
|||||||
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
||||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification, notifyProjectMentors, NotificationTypes } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||||
|
import { getFinalDocumentStatusForProject, finalistUploadsEnabled } from '../services/final-documents'
|
||||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||||
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
import type { PrismaClient, Prisma, RoundType } from '@prisma/client'
|
||||||
|
|
||||||
@@ -328,18 +329,29 @@ export const applicantRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch round info and verify it's active
|
// Fetch round info and verify uploads are open. Normally a round must be
|
||||||
|
// ROUND_ACTIVE; the grand-final documents are collected during the lead-up
|
||||||
|
// while the LIVE_FINAL round is still DRAFT (it only "opens" at the event),
|
||||||
|
// so allow uploads for a not-yet-closed LIVE_FINAL round too.
|
||||||
let roundName: string | undefined
|
let roundName: string | undefined
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
const round = await ctx.prisma.round.findUnique({
|
const round = await ctx.prisma.round.findUnique({
|
||||||
where: { id: input.roundId },
|
where: { id: input.roundId },
|
||||||
select: { name: true, status: true },
|
select: { name: true, status: true, roundType: true, finalizedAt: true, configJson: true },
|
||||||
})
|
})
|
||||||
if (round && round.status !== 'ROUND_ACTIVE') {
|
if (round) {
|
||||||
throw new TRPCError({
|
const uploadable =
|
||||||
code: 'BAD_REQUEST',
|
round.roundType === 'LIVE_FINAL'
|
||||||
message: 'This round is closed. Documents can no longer be uploaded.',
|
? !round.finalizedAt &&
|
||||||
})
|
(round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
|
||||||
|
finalistUploadsEnabled(round.configJson)
|
||||||
|
: round.status === 'ROUND_ACTIVE'
|
||||||
|
if (!uploadable) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'This round is closed. Documents can no longer be uploaded.',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
roundName = round?.name
|
roundName = round?.name
|
||||||
}
|
}
|
||||||
@@ -498,6 +510,24 @@ export const applicantRouter = router({
|
|||||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
||||||
).catch(() => {})
|
).catch(() => {})
|
||||||
|
|
||||||
|
// Notify the team's mentor(s) when a Grand-Final document is uploaded.
|
||||||
|
if (input.roundId) {
|
||||||
|
try {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { roundType: true } })
|
||||||
|
if (round?.roundType === 'LIVE_FINAL') {
|
||||||
|
await notifyProjectMentors(input.projectId, {
|
||||||
|
type: NotificationTypes.GRAND_FINAL_DOCS_SUBMITTED,
|
||||||
|
title: 'Final document uploaded',
|
||||||
|
message: `A team uploaded a Grand Final document: ${input.fileName}`,
|
||||||
|
linkUrl: `/mentor/workspace/${input.projectId}`,
|
||||||
|
metadata: { projectId: input.projectId, fileName: input.fileName },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[final-docs] mentor notify failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return file
|
return file
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -536,17 +566,26 @@ export const applicantRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round-specific files can only be deleted while the round is active
|
// Round-specific files can only be modified while the round is open. As with
|
||||||
|
// upload, a not-yet-closed LIVE_FINAL round (DRAFT, pre-event) counts as open.
|
||||||
if (file.roundId) {
|
if (file.roundId) {
|
||||||
const round = await ctx.prisma.round.findUnique({
|
const round = await ctx.prisma.round.findUnique({
|
||||||
where: { id: file.roundId },
|
where: { id: file.roundId },
|
||||||
select: { status: true },
|
select: { status: true, roundType: true, finalizedAt: true, configJson: true },
|
||||||
})
|
})
|
||||||
if (round && round.status !== 'ROUND_ACTIVE') {
|
if (round) {
|
||||||
throw new TRPCError({
|
const modifiable =
|
||||||
code: 'BAD_REQUEST',
|
round.roundType === 'LIVE_FINAL'
|
||||||
message: 'This round is closed. Documents can no longer be modified.',
|
? !round.finalizedAt &&
|
||||||
})
|
(round.status === 'ROUND_DRAFT' || round.status === 'ROUND_ACTIVE') &&
|
||||||
|
finalistUploadsEnabled(round.configJson)
|
||||||
|
: round.status === 'ROUND_ACTIVE'
|
||||||
|
if (!modifiable) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'This round is closed. Documents can no longer be modified.',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,7 +1462,14 @@ export const applicantRouter = router({
|
|||||||
const allActiveRounds = await ctx.prisma.round.findMany({
|
const allActiveRounds = await ctx.prisma.round.findMany({
|
||||||
where: {
|
where: {
|
||||||
competition: { programId },
|
competition: { programId },
|
||||||
status: 'ROUND_ACTIVE',
|
// Active rounds, plus the not-yet-opened (DRAFT) LIVE_FINAL round so
|
||||||
|
// enrolled finalists can upload their grand-final documents during the
|
||||||
|
// lead-up while the round itself stays "closed" until the event. The
|
||||||
|
// per-project membership filter below restricts this to teams in it.
|
||||||
|
OR: [
|
||||||
|
{ status: 'ROUND_ACTIVE' },
|
||||||
|
{ roundType: 'LIVE_FINAL', status: 'ROUND_DRAFT', finalizedAt: null },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
select: {
|
select: {
|
||||||
@@ -1432,6 +1478,7 @@ export const applicantRouter = router({
|
|||||||
slug: true,
|
slug: true,
|
||||||
roundType: true,
|
roundType: true,
|
||||||
windowCloseAt: true,
|
windowCloseAt: true,
|
||||||
|
configJson: true,
|
||||||
specialAwardId: true,
|
specialAwardId: true,
|
||||||
specialAward: { select: { name: true } },
|
specialAward: { select: { name: true } },
|
||||||
},
|
},
|
||||||
@@ -1450,6 +1497,9 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
openRounds = allActiveRounds
|
openRounds = allActiveRounds
|
||||||
.filter((r) => {
|
.filter((r) => {
|
||||||
|
// LIVE_FINAL (grand-final documents) only shows to enrolled finalists,
|
||||||
|
// and only when the admin has enabled revised uploads.
|
||||||
|
if (r.roundType === 'LIVE_FINAL' && (!projectRoundIds.has(r.id) || !finalistUploadsEnabled(r.configJson))) return false
|
||||||
// Award round project isn't in → hide
|
// Award round project isn't in → hide
|
||||||
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
if (r.specialAwardId && !projectRoundIds.has(r.id)) return false
|
||||||
// Main round when project is in award track and has no state in this round → hide
|
// Main round when project is in award track and has no state in this round → hide
|
||||||
@@ -1521,6 +1571,22 @@ export const applicantRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Grand-final document status for the caller's project (banner + mentor panel). */
|
||||||
|
getFinalDocumentStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const project = await ctx.prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ submittedByUserId: ctx.user.id },
|
||||||
|
{ teamMembers: { some: { userId: ctx.user.id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!project) return null
|
||||||
|
return getFinalDocumentStatusForProject(ctx.prisma, project.id)
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight flags for conditional nav rendering.
|
* Lightweight flags for conditional nav rendering.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ export const competitionRouter = router({
|
|||||||
return competition
|
return competition
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the id of the most-recent ACTIVE program for the logged-in user.
|
||||||
|
* Used by client pages (e.g. the finalist-documents judge review) that need a
|
||||||
|
* programId but don't have one in the route. Returns null when none is active.
|
||||||
|
*/
|
||||||
|
getActiveProgramId: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const program = await ctx.prisma.program.findFirst({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
orderBy: { year: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
return program?.id ?? null
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get competition by ID with rounds, jury groups, and submission windows
|
* Get competition by ID with rounds, jury groups, and submission windows
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ export const deliberationRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
sessionId: z.string(),
|
sessionId: z.string(),
|
||||||
juryMemberId: z.string(),
|
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
rank: z.number().int().min(1).optional(),
|
rank: z.number().int().min(1).optional(),
|
||||||
isWinnerPick: z.boolean().optional(),
|
isWinnerPick: z.boolean().optional(),
|
||||||
@@ -118,15 +117,26 @@ export const deliberationRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Enforce that jury members can only vote as themselves
|
// Resolve the caller's participant row server-side: DeliberationParticipant
|
||||||
if (input.juryMemberId !== ctx.user.id) {
|
// and DeliberationVote both reference JuryGroupMember ids, which the
|
||||||
|
// client has no business knowing. A juror can only ever vote as themself.
|
||||||
|
const participant = await ctx.prisma.deliberationParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
user: { userId: ctx.user.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!participant) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You can only submit votes as yourself',
|
message: 'You are not a participant in this deliberation',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const vote = await submitVote(input, ctx.prisma)
|
const vote = await submitVote(
|
||||||
|
{ ...input, juryMemberId: participant.userId },
|
||||||
|
ctx.prisma
|
||||||
|
)
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
@@ -310,6 +320,10 @@ export const deliberationRouter = router({
|
|||||||
participants: {
|
participants: {
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
},
|
},
|
||||||
|
results: {
|
||||||
|
select: { projectId: true, finalRank: true, isAdminOverridden: true },
|
||||||
|
orderBy: { finalRank: 'asc' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
resetOrCreatePendingConfirmation,
|
resetOrCreatePendingConfirmation,
|
||||||
confirmAttendanceInTx,
|
confirmAttendanceInTx,
|
||||||
} from '../services/finalist-enrollment'
|
} from '../services/finalist-enrollment'
|
||||||
|
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals, listReviewVisibilityOptions, reviewVisibleRequirementIds } from '../services/final-documents'
|
||||||
|
|
||||||
export const finalistRouter = router({
|
export const finalistRouter = router({
|
||||||
/** List all per-category finalist slot quotas for a program. */
|
/** List all per-category finalist slot quotas for a program. */
|
||||||
@@ -1660,4 +1661,110 @@ export const finalistRouter = router({
|
|||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Manually remind finalist teams to upload their Grand Final documents. */
|
||||||
|
sendDocumentReminders: adminProcedure
|
||||||
|
.input(z.object({ programId: z.string(), projectIds: z.array(z.string()).optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const result = await sendManualFinalDocReminders(ctx.prisma, {
|
||||||
|
programId: input.programId,
|
||||||
|
projectIds: input.projectIds,
|
||||||
|
actorId: ctx.user.id,
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FINALIST_DOCS_REMINDER_SENT',
|
||||||
|
entityType: 'Program',
|
||||||
|
entityId: input.programId,
|
||||||
|
detailsJson: { sent: result.sent, projectIds: input.projectIds ?? 'all-missing' },
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Read-only review of all finalists' grand-final documents (admins + finale jury). */
|
||||||
|
/** Lightweight boolean — may the current user open the finalist documents review? Self-resolves the active program so the nav can gate the link without fetching the full payload. */
|
||||||
|
canReviewDocuments: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const program = await ctx.prisma.program.findFirst({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
orderBy: { year: 'desc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!program) return false
|
||||||
|
return userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, program.id)
|
||||||
|
}),
|
||||||
|
|
||||||
|
listReviewDocuments: protectedProcedure
|
||||||
|
.input(z.object({ programId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const allowed = await userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, input.programId)
|
||||||
|
if (!allowed) throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have access to the finalist documents review.' })
|
||||||
|
return listFinalistDocumentsForReview(ctx.prisma, input.programId)
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Read whether finalists may upload revised grand-final documents (admin toggle). */
|
||||||
|
getRevisedUploadSetting: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
|
||||||
|
const cfg = (round?.configJson ?? {}) as { allowFinalistRevisedUploads?: boolean }
|
||||||
|
return { enabled: !!cfg.allowFinalistRevisedUploads }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Toggle whether finalists may upload revised grand-final documents (admin setting on the LIVE_FINAL round). */
|
||||||
|
setRevisedUploadSetting: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), enabled: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
|
||||||
|
if (!round || round.roundType !== 'LIVE_FINAL') {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
|
||||||
|
}
|
||||||
|
const cfg = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
await ctx.prisma.round.update({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
data: { configJson: { ...cfg, allowFinalistRevisedUploads: input.enabled } },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FINALIST_REVISED_UPLOADS_TOGGLED',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: { enabled: input.enabled },
|
||||||
|
})
|
||||||
|
return { ok: true, enabled: input.enabled }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Options + current selection for the "documents shown to judges" picker. */
|
||||||
|
getReviewDocSettings: adminProcedure
|
||||||
|
.input(z.object({ programId: z.string(), roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
|
||||||
|
return {
|
||||||
|
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
|
||||||
|
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
|
||||||
|
setReviewVisibleRequirements: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
|
||||||
|
if (!round || round.roundType !== 'LIVE_FINAL') {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
|
||||||
|
}
|
||||||
|
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
|
||||||
|
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next as object } })
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'FINALIST_REVIEW_DOCS_CURATED',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: { requirementIds: input.requirementIds },
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { PrismaClient } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
interface LiveVotingCriterion {
|
interface LiveVotingCriterion {
|
||||||
@@ -11,6 +12,114 @@ interface LiveVotingCriterion {
|
|||||||
weight: number
|
weight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Grand-finale audience favorite-vote windows ─────────────────────────────
|
||||||
|
|
||||||
|
const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])
|
||||||
|
|
||||||
|
const revealStepSchema = z.object({
|
||||||
|
kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']),
|
||||||
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
|
place: z.number().int().min(1).max(10).optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
title: z.string().max(200).optional(),
|
||||||
|
subtitle: z.string().max(300).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const MAX_FAVORITE_VOTERS_PER_IP = 3
|
||||||
|
|
||||||
|
/** Server-side window check — the source of truth even if no one closed the window. */
|
||||||
|
function windowIsOpen(
|
||||||
|
s: { audiencePhase: string; audienceWindowClosesAt: Date | null },
|
||||||
|
now = new Date()
|
||||||
|
) {
|
||||||
|
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
|
||||||
|
if (key === 'CATEGORY:STARTUP') return 'STARTUP'
|
||||||
|
if (key === 'CATEGORY:BUSINESS_CONCEPT') return 'BUSINESS_CONCEPT'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finale project order: the cursor system's round.configJson.projectOrder is
|
||||||
|
* the source of truth; session.projectOrderJson is the fallback.
|
||||||
|
*/
|
||||||
|
async function getOrderedFinaleProjects(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
session: { roundId: string | null; projectOrderJson: unknown }
|
||||||
|
) {
|
||||||
|
let order: string[] = []
|
||||||
|
if (session.roundId) {
|
||||||
|
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
|
||||||
|
order = (((round?.configJson as Record<string, unknown>) ?? {}).projectOrder as string[]) ?? []
|
||||||
|
}
|
||||||
|
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
|
||||||
|
if (order.length === 0) return []
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { id: { in: order } },
|
||||||
|
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||||
|
})
|
||||||
|
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
return order.map((id) => byId.get(id)).filter((p): p is NonNullable<typeof p> => !!p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared jury-voting payload for getSessionForVoting / getSessionForVotingByRound. */
|
||||||
|
async function buildVotingPayload(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
session: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
currentProjectId: string | null
|
||||||
|
votingStartedAt: Date | null
|
||||||
|
votingEndsAt: Date | null
|
||||||
|
votingMode: string
|
||||||
|
criteriaJson: unknown
|
||||||
|
round: unknown
|
||||||
|
},
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
let currentProject = null
|
||||||
|
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
|
||||||
|
currentProject = await prisma.project.findUnique({
|
||||||
|
where: { id: session.currentProjectId },
|
||||||
|
select: { id: true, title: true, teamName: true, description: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let userVote = null
|
||||||
|
if (session.currentProjectId) {
|
||||||
|
userVote = await prisma.liveVote.findFirst({
|
||||||
|
where: {
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: session.currentProjectId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeRemaining = null
|
||||||
|
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
|
||||||
|
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
|
||||||
|
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
id: session.id,
|
||||||
|
status: session.status,
|
||||||
|
votingStartedAt: session.votingStartedAt,
|
||||||
|
votingEndsAt: session.votingEndsAt,
|
||||||
|
votingMode: session.votingMode,
|
||||||
|
criteriaJson: session.criteriaJson,
|
||||||
|
},
|
||||||
|
round: session.round,
|
||||||
|
currentProject,
|
||||||
|
userVote,
|
||||||
|
timeRemaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const liveVotingRouter = router({
|
export const liveVotingRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get or create a live voting session for a round
|
* Get or create a live voting session for a round
|
||||||
@@ -97,46 +206,63 @@ export const liveVotingRouter = router({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
return buildVotingPayload(ctx.prisma, session, ctx.user.id)
|
||||||
|
}),
|
||||||
|
|
||||||
let currentProject = null
|
/**
|
||||||
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
|
* Same payload, resolved from a roundId (the jury live page only knows the
|
||||||
currentProject = await ctx.prisma.project.findUnique({
|
* round). Returns null — and creates nothing — when no session exists yet.
|
||||||
where: { id: session.currentProjectId },
|
*/
|
||||||
select: { id: true, title: true, teamName: true, description: true },
|
getSessionForVotingByRound: protectedProcedure
|
||||||
})
|
.input(z.object({ roundId: z.string() }))
|
||||||
}
|
.query(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||||
let userVote = null
|
where: { roundId: input.roundId },
|
||||||
if (session.currentProjectId) {
|
include: {
|
||||||
userVote = await ctx.prisma.liveVote.findFirst({
|
round: {
|
||||||
where: {
|
include: {
|
||||||
sessionId: session.id,
|
competition: {
|
||||||
projectId: session.currentProjectId,
|
include: {
|
||||||
userId: ctx.user.id,
|
program: { select: { name: true, year: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeRemaining = null
|
|
||||||
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
|
|
||||||
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
|
|
||||||
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
session: {
|
|
||||||
id: session.id,
|
|
||||||
status: session.status,
|
|
||||||
votingStartedAt: session.votingStartedAt,
|
|
||||||
votingEndsAt: session.votingEndsAt,
|
|
||||||
votingMode: session.votingMode,
|
|
||||||
criteriaJson: session.criteriaJson,
|
|
||||||
},
|
},
|
||||||
round: session.round,
|
})
|
||||||
currentProject,
|
if (!session) return null
|
||||||
userVote,
|
return buildVotingPayload(ctx.prisma, session, ctx.user.id)
|
||||||
timeRemaining,
|
}),
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* A juror's own finale inputs (votes incl. comments + ceremony notes) for a
|
||||||
|
* round — resurfaced during deliberation so they can review or revise.
|
||||||
|
*/
|
||||||
|
getMyFinaleInputs: protectedProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { id: true, status: true, votingMode: true, criteriaJson: true },
|
||||||
|
})
|
||||||
|
const [votes, notes] = await Promise.all([
|
||||||
|
session
|
||||||
|
? ctx.prisma.liveVote.findMany({
|
||||||
|
where: { sessionId: session.id, userId: ctx.user.id },
|
||||||
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
score: true,
|
||||||
|
criterionScoresJson: true,
|
||||||
|
comment: true,
|
||||||
|
votedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
ctx.prisma.liveNote.findMany({
|
||||||
|
where: { roundId: input.roundId, userId: ctx.user.id },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
return { session: session ?? null, votes, notes }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -447,6 +573,7 @@ export const liveVotingRouter = router({
|
|||||||
criterionScores: z
|
criterionScores: z
|
||||||
.record(z.string(), z.number())
|
.record(z.string(), z.number())
|
||||||
.optional(),
|
.optional(),
|
||||||
|
comment: z.string().max(5000).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -489,22 +616,29 @@ export const liveVotingRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status !== 'IN_PROGRESS') {
|
if (session.status !== 'IN_PROGRESS' && session.status !== 'PAUSED') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Voting is not currently active',
|
message: 'Voting is not currently active',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.currentProjectId !== input.projectId) {
|
const isCurrentProject = session.currentProjectId === input.projectId
|
||||||
throw new TRPCError({
|
if (!isCurrentProject) {
|
||||||
code: 'BAD_REQUEST',
|
// Revision path (deliberation / catching up): any project in the
|
||||||
message: 'Cannot vote for this project right now',
|
// finale run order may be (re)scored while the session is open.
|
||||||
})
|
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
|
||||||
|
if (!ordered.some((p) => p.id === input.projectId)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Cannot vote for this project right now',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if voting window is still open
|
// The timed voting window only applies to the live flow for the
|
||||||
if (session.votingEndsAt && new Date() > session.votingEndsAt) {
|
// currently presented project
|
||||||
|
if (isCurrentProject && session.votingEndsAt && new Date() > session.votingEndsAt) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Voting window has closed',
|
message: 'Voting window has closed',
|
||||||
@@ -568,11 +702,14 @@ export const liveVotingRouter = router({
|
|||||||
score: finalScore,
|
score: finalScore,
|
||||||
isAudienceVote: false,
|
isAudienceVote: false,
|
||||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||||
|
comment: input.comment ?? undefined,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
score: finalScore,
|
score: finalScore,
|
||||||
isAudienceVote: false,
|
isAudienceVote: false,
|
||||||
criterionScoresJson: criterionScoresJson ?? undefined,
|
criterionScoresJson: criterionScoresJson ?? undefined,
|
||||||
|
// An omitted comment leaves the existing one untouched
|
||||||
|
...(input.comment !== undefined ? { comment: input.comment } : {}),
|
||||||
votedAt: new Date(),
|
votedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -759,6 +896,7 @@ export const liveVotingRouter = router({
|
|||||||
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
|
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
|
||||||
audienceRequireId: z.boolean().optional(),
|
audienceRequireId: z.boolean().optional(),
|
||||||
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
|
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
|
||||||
|
allowOverallFavorite: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -917,6 +1055,506 @@ export const liveVotingRouter = router({
|
|||||||
return vote
|
return vote
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale audience favorite-vote windows
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an audience voting window ("favorite Startup", "favorite Business
|
||||||
|
* Concept", or — if enabled — "overall favorite") for a fixed duration.
|
||||||
|
*/
|
||||||
|
openAudienceWindow: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
windowKey: windowKeySchema,
|
||||||
|
durationMinutes: z.number().int().min(1).max(120).default(5),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
|
where: { id: input.sessionId },
|
||||||
|
})
|
||||||
|
if (windowIsOpen(session)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'An audience voting window is already open — close it first',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (input.windowKey === 'OVERALL' && !session.allowOverallFavorite) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'The overall favorite vote is not enabled for this session',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveVotingSession.update({
|
||||||
|
where: { id: input.sessionId },
|
||||||
|
data: {
|
||||||
|
audiencePhase: 'OPEN',
|
||||||
|
audienceWindowKey: input.windowKey,
|
||||||
|
audienceWindowOpenedAt: now,
|
||||||
|
audienceWindowClosesAt: new Date(now.getTime() + input.durationMinutes * 60_000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'AUDIENCE_WINDOW_OPENED',
|
||||||
|
entityType: 'LiveVotingSession',
|
||||||
|
entityId: input.sessionId,
|
||||||
|
detailsJson: { windowKey: input.windowKey, durationMinutes: input.durationMinutes },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the audience voting window early (allowed at any time).
|
||||||
|
*/
|
||||||
|
closeAudienceWindow: adminProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
|
where: { id: input.sessionId },
|
||||||
|
})
|
||||||
|
const updated = await ctx.prisma.liveVotingSession.update({
|
||||||
|
where: { id: input.sessionId },
|
||||||
|
data: {
|
||||||
|
audiencePhase: 'CLOSED',
|
||||||
|
audienceWindowKey: null,
|
||||||
|
audienceWindowOpenedAt: null,
|
||||||
|
audienceWindowClosesAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'AUDIENCE_WINDOW_CLOSED',
|
||||||
|
entityType: 'LiveVotingSession',
|
||||||
|
entityId: input.sessionId,
|
||||||
|
detailsJson: { windowKey: session.audienceWindowKey },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public window state for the audience voting page: open/closed, eligible
|
||||||
|
* projects (in run order), closing time, and the caller's current pick.
|
||||||
|
*/
|
||||||
|
getAudienceWindow: publicProcedure
|
||||||
|
.input(z.object({ sessionId: z.string(), token: z.string().optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
|
where: { id: input.sessionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
roundId: true,
|
||||||
|
projectOrderJson: true,
|
||||||
|
audiencePhase: true,
|
||||||
|
audienceWindowKey: true,
|
||||||
|
audienceWindowClosesAt: true,
|
||||||
|
allowAudienceVotes: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const open = windowIsOpen(session)
|
||||||
|
const windowKey = open ? session.audienceWindowKey : null
|
||||||
|
let projects: Awaited<ReturnType<typeof getOrderedFinaleProjects>> = []
|
||||||
|
if (open && windowKey) {
|
||||||
|
const cat = categoryForKey(windowKey)
|
||||||
|
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
|
||||||
|
projects = cat ? ordered.filter((p) => p.competitionCategory === cat) : ordered
|
||||||
|
}
|
||||||
|
let myVoteProjectId: string | null = null
|
||||||
|
if (input.token && windowKey) {
|
||||||
|
const voter = await ctx.prisma.audienceVoter.findUnique({
|
||||||
|
where: { token: input.token },
|
||||||
|
})
|
||||||
|
if (voter && voter.sessionId === input.sessionId) {
|
||||||
|
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
|
||||||
|
where: {
|
||||||
|
sessionId_windowKey_audienceVoterId: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
windowKey,
|
||||||
|
audienceVoterId: voter.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
myVoteProjectId = existing?.projectId ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
windowKey,
|
||||||
|
closesAt: open ? session.audienceWindowClosesAt : null,
|
||||||
|
projects,
|
||||||
|
myVoteProjectId,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast (or change) a pick-one-favorite vote in the open window.
|
||||||
|
* Gates, in order: token, window open, time, eligibility, category, IP cap.
|
||||||
|
*/
|
||||||
|
castFavoriteVote: publicProcedure
|
||||||
|
.input(z.object({ sessionId: z.string(), token: z.string(), projectId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const voter = await ctx.prisma.audienceVoter.findUnique({
|
||||||
|
where: { token: input.token },
|
||||||
|
})
|
||||||
|
if (!voter || voter.sessionId !== input.sessionId) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid voting token' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
|
where: { id: input.sessionId },
|
||||||
|
})
|
||||||
|
if (!windowIsOpen(session) || !session.audienceWindowKey) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'Voting is not open right now',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const windowKey = session.audienceWindowKey
|
||||||
|
|
||||||
|
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
|
||||||
|
const project = ordered.find((p) => p.id === input.projectId)
|
||||||
|
if (!project) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not part of this vote' })
|
||||||
|
}
|
||||||
|
const cat = categoryForKey(windowKey)
|
||||||
|
if (cat && project.competitionCategory !== cat) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Project is not in the category currently open for voting',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
|
||||||
|
where: {
|
||||||
|
sessionId_windowKey_audienceVoterId: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
windowKey,
|
||||||
|
audienceVoterId: voter.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// IP cap only gates NEW voters — an existing voter may always update.
|
||||||
|
if (!existing && ctx.ip) {
|
||||||
|
const ipCount = await ctx.prisma.audienceFavoriteVote.count({
|
||||||
|
where: { sessionId: input.sessionId, windowKey, ipAddress: ctx.ip },
|
||||||
|
})
|
||||||
|
if (ipCount >= MAX_FAVORITE_VOTERS_PER_IP) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'TOO_MANY_REQUESTS',
|
||||||
|
message: 'Vote limit reached for this network connection',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vote = await ctx.prisma.audienceFavoriteVote.upsert({
|
||||||
|
where: {
|
||||||
|
sessionId_windowKey_audienceVoterId: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
windowKey,
|
||||||
|
audienceVoterId: voter.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
windowKey,
|
||||||
|
projectId: input.projectId,
|
||||||
|
audienceVoterId: voter.id,
|
||||||
|
ipAddress: ctx.ip ?? null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
projectId: input.projectId,
|
||||||
|
ipAddress: ctx.ip ?? existing?.ipAddress ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { projectId: vote.projectId, windowKey }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-window per-project favorite-vote tallies (admin only — the big screen
|
||||||
|
* shows only the total count).
|
||||||
|
*/
|
||||||
|
getFavoriteTallies: adminProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const grouped = await ctx.prisma.audienceFavoriteVote.groupBy({
|
||||||
|
by: ['windowKey', 'projectId'],
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
_count: { _all: true },
|
||||||
|
})
|
||||||
|
const projectIds = [...new Set(grouped.map((g) => g.projectId))]
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectIds } },
|
||||||
|
select: { id: true, title: true, teamName: true },
|
||||||
|
})
|
||||||
|
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||||
|
const windowKeys = [...new Set(grouped.map((g) => g.windowKey))]
|
||||||
|
const windows = windowKeys.map((windowKey) => {
|
||||||
|
const rows = grouped.filter((g) => g.windowKey === windowKey)
|
||||||
|
return {
|
||||||
|
windowKey,
|
||||||
|
totalVotes: rows.reduce((sum, r) => sum + r._count._all, 0),
|
||||||
|
projects: rows
|
||||||
|
.map((r) => ({
|
||||||
|
projectId: r.projectId,
|
||||||
|
title: byId.get(r.projectId)?.title ?? 'Unknown',
|
||||||
|
teamName: byId.get(r.projectId)?.teamName ?? null,
|
||||||
|
count: r._count._all,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { windows }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the live voting session for a round (public — the audience page
|
||||||
|
* only knows the roundId from the QR code URL).
|
||||||
|
*/
|
||||||
|
getAudienceContextByRound: publicProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const session = await ctx.prisma.liveVotingSession.findUnique({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
allowAudienceVotes: true,
|
||||||
|
round: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
competition: {
|
||||||
|
select: { program: { select: { name: true, year: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!session) return null
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
allowAudienceVotes: session.allowAudienceVotes,
|
||||||
|
roundName: session.round?.name ?? null,
|
||||||
|
programName: session.round?.competition?.program?.name ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// Results reveal controller (big screen)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save (or replace) the reveal step list as a private DRAFT.
|
||||||
|
* Display strings are denormalized into the steps at compose time so the
|
||||||
|
* public endpoint needs no joins.
|
||||||
|
*/
|
||||||
|
saveReveal: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sessionId: z.string(),
|
||||||
|
steps: z.array(revealStepSchema).max(50),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.revealState.upsert({
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
create: {
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
stepsJson: input.steps,
|
||||||
|
status: 'DRAFT',
|
||||||
|
currentStepIndex: -1,
|
||||||
|
},
|
||||||
|
update: { stepsJson: input.steps, status: 'DRAFT', currentStepIndex: -1 },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the reveal: the big screen switches to the Results splash, nothing
|
||||||
|
* is revealed yet.
|
||||||
|
*/
|
||||||
|
armReveal: adminProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const reveal = await ctx.prisma.revealState.findUniqueOrThrow({
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
})
|
||||||
|
const steps = (reveal.stepsJson as unknown[]) ?? []
|
||||||
|
if (steps.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No reveal steps composed' })
|
||||||
|
}
|
||||||
|
const updated = await ctx.prisma.revealState.update({
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
data: { status: 'ARMED', currentStepIndex: -1 },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'REVEAL_ARMED',
|
||||||
|
entityType: 'RevealState',
|
||||||
|
entityId: updated.id,
|
||||||
|
detailsJson: { stepCount: steps.length },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal the next step. Clamps at the final step and flips to DONE.
|
||||||
|
*/
|
||||||
|
revealNext: adminProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const reveal = await ctx.prisma.revealState.findUniqueOrThrow({
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
})
|
||||||
|
if (reveal.status !== 'ARMED' && reveal.status !== 'REVEALING' && reveal.status !== 'DONE') {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Reveal is not armed' })
|
||||||
|
}
|
||||||
|
const steps = (reveal.stepsJson as unknown[]) ?? []
|
||||||
|
const newIndex = Math.min(reveal.currentStepIndex + 1, steps.length - 1)
|
||||||
|
const updated = await ctx.prisma.revealState.update({
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
data: {
|
||||||
|
currentStepIndex: newIndex,
|
||||||
|
status: newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'REVEAL_ADVANCED',
|
||||||
|
entityType: 'RevealState',
|
||||||
|
entityId: updated.id,
|
||||||
|
detailsJson: { stepIndex: newIndex },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the reveal to DRAFT — the big screen leaves reveal mode.
|
||||||
|
*/
|
||||||
|
resetReveal: adminProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const updated = await ctx.prisma.revealState.update({
|
||||||
|
where: { sessionId: input.sessionId },
|
||||||
|
data: { status: 'DRAFT', currentStepIndex: -1 },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'REVEAL_RESET',
|
||||||
|
entityType: 'RevealState',
|
||||||
|
entityId: updated.id,
|
||||||
|
detailsJson: {},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Full reveal state incl. all steps — admin preview only. */
|
||||||
|
getRevealAdmin: adminProcedure
|
||||||
|
.input(z.object({ sessionId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.revealState.findUnique({ where: { sessionId: input.sessionId } })
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the big-screen ceremony view needs, derived from existing
|
||||||
|
* state. Public. NEVER includes scores or un-revealed steps.
|
||||||
|
*/
|
||||||
|
getCeremonyState: publicProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [cursor, session] = await Promise.all([
|
||||||
|
ctx.prisma.liveProgressCursor.findUnique({ where: { roundId: input.roundId } }),
|
||||||
|
ctx.prisma.liveVotingSession.findUnique({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
audiencePhase: true,
|
||||||
|
audienceWindowKey: true,
|
||||||
|
audienceWindowClosesAt: true,
|
||||||
|
round: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
competition: {
|
||||||
|
select: { program: { select: { name: true, year: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
let activeProject = null
|
||||||
|
if (cursor?.activeProjectId) {
|
||||||
|
activeProject = await ctx.prisma.project.findUnique({
|
||||||
|
where: { id: cursor.activeProjectId },
|
||||||
|
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const audienceOpen = session ? windowIsOpen(session) : false
|
||||||
|
let voteCount = 0
|
||||||
|
if (session && audienceOpen && session.audienceWindowKey) {
|
||||||
|
voteCount = await ctx.prisma.audienceFavoriteVote.count({
|
||||||
|
where: { sessionId: session.id, windowKey: session.audienceWindowKey },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let reveal: { status: string; currentStepIndex: number; steps: unknown[] } | null = null
|
||||||
|
if (session) {
|
||||||
|
const revealState = await ctx.prisma.revealState.findUnique({
|
||||||
|
where: { sessionId: session.id },
|
||||||
|
})
|
||||||
|
if (revealState && revealState.status !== 'DRAFT') {
|
||||||
|
const steps = (revealState.stepsJson as unknown[]) ?? []
|
||||||
|
reveal = {
|
||||||
|
status: revealState.status,
|
||||||
|
currentStepIndex: revealState.currentStepIndex,
|
||||||
|
// ONLY steps revealed so far — never the full list
|
||||||
|
steps:
|
||||||
|
revealState.status === 'ARMED' ? [] : steps.slice(0, revealState.currentStepIndex + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
overrideSlide: cursor?.overrideSlide ?? null,
|
||||||
|
phase: cursor
|
||||||
|
? {
|
||||||
|
projectPhase: cursor.projectPhase,
|
||||||
|
phaseStartedAt: cursor.phaseStartedAt,
|
||||||
|
phaseDurationSeconds: cursor.phaseDurationSeconds,
|
||||||
|
phasePausedAt: cursor.phasePausedAt,
|
||||||
|
phasePausedAccumMs: cursor.phasePausedAccumMs,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
activeProject,
|
||||||
|
audience: {
|
||||||
|
open: audienceOpen,
|
||||||
|
windowKey: audienceOpen ? session?.audienceWindowKey ?? null : null,
|
||||||
|
closesAt: audienceOpen ? session?.audienceWindowClosesAt ?? null : null,
|
||||||
|
voteCount,
|
||||||
|
},
|
||||||
|
reveal,
|
||||||
|
programName: session?.round?.competition?.program?.name ?? null,
|
||||||
|
roundName: session?.round?.name ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get audience voter stats (admin)
|
* Get audience voter stats (admin)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,66 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma, type PrismaClient, type LiveProgressCursor } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
|
||||||
|
// ─── Grand-finale phase machine helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
type TimingEntry = {
|
||||||
|
projectId: string
|
||||||
|
phase: 'PRESENTING' | 'QA'
|
||||||
|
startedAt: string
|
||||||
|
endedAt: string
|
||||||
|
configuredSeconds: number | null
|
||||||
|
elapsedSeconds: number // pause-adjusted actual duration
|
||||||
|
overranSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When leaving a timed phase (PRESENTING/QA), compute the actual elapsed time
|
||||||
|
* (pause-adjusted) and append it to the cursor's timing log. Overtime is
|
||||||
|
* recorded as fact — never as a penalty.
|
||||||
|
*/
|
||||||
|
function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJsonValue | undefined {
|
||||||
|
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
|
||||||
|
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
|
||||||
|
const end = cursor.phasePausedAt ?? now
|
||||||
|
const elapsedSec = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000)
|
||||||
|
)
|
||||||
|
const entry: TimingEntry = {
|
||||||
|
projectId: cursor.activeProjectId,
|
||||||
|
phase: cursor.projectPhase,
|
||||||
|
startedAt: cursor.phaseStartedAt.toISOString(),
|
||||||
|
endedAt: now.toISOString(),
|
||||||
|
configuredSeconds: cursor.phaseDurationSeconds,
|
||||||
|
elapsedSeconds: elapsedSec,
|
||||||
|
overranSeconds:
|
||||||
|
cursor.phaseDurationSeconds == null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
|
||||||
|
}
|
||||||
|
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as unknown as TimingEntry[]) : []
|
||||||
|
return [...log, entry] as unknown as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectTimingOverride = { presentationSeconds?: number; qaSeconds?: number }
|
||||||
|
|
||||||
|
async function getRoundCeremonyConfig(prisma: PrismaClient, roundId: string) {
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||||
|
const cfg = (round.configJson as Record<string, unknown>) ?? {}
|
||||||
|
return {
|
||||||
|
presentationSeconds:
|
||||||
|
typeof cfg.presentationDurationMinutes === 'number'
|
||||||
|
? cfg.presentationDurationMinutes * 60
|
||||||
|
: 300,
|
||||||
|
qaSeconds: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
|
||||||
|
projectOrder: (cfg.projectOrder as string[]) ?? [],
|
||||||
|
timingOverrides: (cfg.projectTimingOverrides as Record<string, ProjectTimingOverride>) ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const liveRouter = router({
|
export const liveRouter = router({
|
||||||
/**
|
/**
|
||||||
* Start a live presentation session for a stage
|
* Start a live presentation session for a stage
|
||||||
@@ -72,6 +129,13 @@ export const liveRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Keep the voting session in lockstep from the very start so jury
|
||||||
|
// votes target the active project (a session may not exist yet).
|
||||||
|
await tx.liveVotingSession.updateMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
data: { currentProjectId: input.projectOrder[0], status: 'IN_PROGRESS' },
|
||||||
|
})
|
||||||
|
|
||||||
return created
|
return created
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -344,6 +408,410 @@ export const liveRouter = router({
|
|||||||
return updated
|
return updated
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grand-finale phase machine (ON_DECK → PRESENTING → QA → SCORING)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a project on every screen as "Up next" — the grace period before the
|
||||||
|
* presentation actually starts. Clears any timer and override slide.
|
||||||
|
*/
|
||||||
|
sendToScreens: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), projectId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
const { projectOrder } = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
|
||||||
|
const index = projectOrder.indexOf(input.projectId)
|
||||||
|
if (index === -1) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not in the session order' })
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
activeProjectId: input.projectId,
|
||||||
|
activeOrderIndex: index,
|
||||||
|
projectPhase: 'ON_DECK',
|
||||||
|
phaseStartedAt: null,
|
||||||
|
phaseDurationSeconds: null,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
overrideSlide: null,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Keep the voting session in lockstep so jury votes target this project
|
||||||
|
// (updateMany: a session may not exist yet — that's fine).
|
||||||
|
await ctx.prisma.liveVotingSession.updateMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
data: { currentProjectId: input.projectId, status: 'IN_PROGRESS' },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_SEND_TO_SCREENS',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { projectId: input.projectId, orderIndex: index },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the presentation timer for the on-deck project.
|
||||||
|
*/
|
||||||
|
startPresentation: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
durationSeconds: z.number().int().min(10).max(7200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.activeProjectId) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
|
||||||
|
}
|
||||||
|
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
|
||||||
|
const projectOverride = cfg.timingOverrides[cursor.activeProjectId]
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
projectPhase: 'PRESENTING',
|
||||||
|
phaseStartedAt: now,
|
||||||
|
phaseDurationSeconds:
|
||||||
|
input.durationSeconds ?? projectOverride?.presentationSeconds ?? cfg.presentationSeconds,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Sync the voting session even when the admin skips sendToScreens
|
||||||
|
await ctx.prisma.liveVotingSession.updateMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
data: { currentProjectId: cursor.activeProjectId, status: 'IN_PROGRESS' },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_STARTED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: 'PRESENTING', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close out the presentation (logging overtime) and start the Q&A timer.
|
||||||
|
*/
|
||||||
|
startQA: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
durationSeconds: z.number().int().min(10).max(7200).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.activeProjectId) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
|
||||||
|
}
|
||||||
|
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
|
||||||
|
const projectOverride = cfg.timingOverrides[cursor.activeProjectId]
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
projectPhase: 'QA',
|
||||||
|
phaseStartedAt: now,
|
||||||
|
phaseDurationSeconds: input.durationSeconds ?? projectOverride?.qaSeconds ?? cfg.qaSeconds,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_STARTED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: 'QA', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close out Q&A (logging overtime) and open jury scoring (no timer).
|
||||||
|
*/
|
||||||
|
openScoring: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
projectPhase: 'SCORING',
|
||||||
|
phaseStartedAt: null,
|
||||||
|
phaseDurationSeconds: null,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
...(closedOutTiming(cursor, now) !== undefined
|
||||||
|
? { timingLogJson: closedOutTiming(cursor, now) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_STARTED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: 'SCORING', projectId: cursor.activeProjectId },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the running phase timer (e.g. technical difficulties).
|
||||||
|
*/
|
||||||
|
pausePhase: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.phaseStartedAt || cursor.phasePausedAt) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: cursor.phasePausedAt ? 'Timer is already paused' : 'No timer is running',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: { phasePausedAt: new Date() },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_PAUSED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume the paused phase timer, folding paused time into the accumulator.
|
||||||
|
*/
|
||||||
|
resumePhase: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
if (!cursor.phasePausedAt) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Timer is not paused' })
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: {
|
||||||
|
phasePausedAccumMs:
|
||||||
|
cursor.phasePausedAccumMs + (now.getTime() - cursor.phasePausedAt.getTime()),
|
||||||
|
phasePausedAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PHASE_RESUMED',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-project presentation/Q&A durations. Precedence at phase start:
|
||||||
|
* explicit durationSeconds input > this override > round config default.
|
||||||
|
* Passing null clears a field.
|
||||||
|
*/
|
||||||
|
setProjectTiming: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
presentationSeconds: z.number().int().min(10).max(7200).nullable().optional(),
|
||||||
|
qaSeconds: z.number().int().min(10).max(7200).nullable().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
})
|
||||||
|
const cfg = ((round.configJson as Record<string, unknown>) ?? {}) as Record<string, unknown>
|
||||||
|
const overrides = { ...((cfg.projectTimingOverrides as Record<string, ProjectTimingOverride>) ?? {}) }
|
||||||
|
const entry: ProjectTimingOverride = { ...(overrides[input.projectId] ?? {}) }
|
||||||
|
|
||||||
|
if (input.presentationSeconds !== undefined) {
|
||||||
|
if (input.presentationSeconds === null) delete entry.presentationSeconds
|
||||||
|
else entry.presentationSeconds = input.presentationSeconds
|
||||||
|
}
|
||||||
|
if (input.qaSeconds !== undefined) {
|
||||||
|
if (input.qaSeconds === null) delete entry.qaSeconds
|
||||||
|
else entry.qaSeconds = input.qaSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(entry).length === 0) delete overrides[input.projectId]
|
||||||
|
else overrides[input.projectId] = entry
|
||||||
|
|
||||||
|
await ctx.prisma.round.update({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
data: {
|
||||||
|
configJson: { ...cfg, projectTimingOverrides: overrides } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_PROJECT_TIMING_SET',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: input.roundId,
|
||||||
|
detailsJson: { projectId: input.projectId, ...entry },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return { projectId: input.projectId, ...entry }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a static slide on the big screen (or clear it).
|
||||||
|
*/
|
||||||
|
setOverrideSlide: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
slide: z.enum(['welcome', 'break', 'deliberation', 'thanks']).nullable(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
})
|
||||||
|
const updated = await ctx.prisma.liveProgressCursor.update({
|
||||||
|
where: { id: cursor.id },
|
||||||
|
data: { overrideSlide: input.slide },
|
||||||
|
})
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LIVE_OVERRIDE_SLIDE',
|
||||||
|
entityType: 'LiveProgressCursor',
|
||||||
|
entityId: cursor.id,
|
||||||
|
detailsJson: { slide: input.slide },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted per-juror per-project ceremony notes (autosaved by the UI;
|
||||||
|
* resurfaced during deliberation).
|
||||||
|
*/
|
||||||
|
saveNote: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
content: z.string().max(20_000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.liveNote.upsert({
|
||||||
|
where: {
|
||||||
|
roundId_projectId_userId: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
content: input.content,
|
||||||
|
},
|
||||||
|
update: { content: input.content },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
getMyNotes: protectedProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return ctx.prisma.liveNote.findMany({
|
||||||
|
where: { roundId: input.roundId, userId: ctx.user.id },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceremony navigation context for jurors: the active live round (if a
|
||||||
|
* ceremony cursor exists) and any deliberation sessions they participate in.
|
||||||
|
*/
|
||||||
|
getMyCeremonyContext: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const cursor = await ctx.prisma.liveProgressCursor.findFirst({
|
||||||
|
where: { round: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' } },
|
||||||
|
select: { roundId: true },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
const participations = await ctx.prisma.deliberationParticipant.findMany({
|
||||||
|
where: {
|
||||||
|
user: { userId: ctx.user.id },
|
||||||
|
session: { status: { in: ['DELIB_OPEN', 'VOTING', 'RUNOFF'] } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
session: { select: { id: true, category: true, status: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
liveRoundId: cursor?.roundId ?? null,
|
||||||
|
deliberations: participations.map((p) => p.session),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current cursor state (for all users, including audience)
|
* Get current cursor state (for all users, including audience)
|
||||||
*/
|
*/
|
||||||
@@ -376,10 +844,22 @@ export const liveRouter = router({
|
|||||||
teamName: true,
|
teamName: true,
|
||||||
description: true,
|
description: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
|
competitionCategory: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full run order with categories (for the admin run-order list and
|
||||||
|
// category grouping on every surface)
|
||||||
|
const orderProjects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: projectOrder } },
|
||||||
|
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||||
|
})
|
||||||
|
const orderById = new Map(orderProjects.map((p) => [p.id, p]))
|
||||||
|
const orderedProjects = projectOrder
|
||||||
|
.map((id) => orderById.get(id))
|
||||||
|
.filter((p): p is NonNullable<typeof p> => !!p)
|
||||||
|
|
||||||
// Get open cohorts for this round (if any)
|
// Get open cohorts for this round (if any)
|
||||||
const openCohorts = await ctx.prisma.cohort.findMany({
|
const openCohorts = await ctx.prisma.cohort.findMany({
|
||||||
where: { roundId: input.roundId, isOpen: true },
|
where: { roundId: input.roundId, isOpen: true },
|
||||||
@@ -395,6 +875,9 @@ export const liveRouter = router({
|
|||||||
...cursor,
|
...cursor,
|
||||||
activeProject,
|
activeProject,
|
||||||
projectOrder,
|
projectOrder,
|
||||||
|
orderedProjects,
|
||||||
|
projectTimingOverrides:
|
||||||
|
((config.projectTimingOverrides as Record<string, { presentationSeconds?: number; qaSeconds?: number }>) ?? {}),
|
||||||
totalProjects: projectOrder.length,
|
totalProjects: projectOrder.length,
|
||||||
openCohorts,
|
openCohorts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
||||||
import { selectUnpickedAttendees } from '../services/lunch-reminders'
|
import { selectUnpickedAttendees, selectUnpickedExternals } from '../services/lunch-reminders'
|
||||||
|
import { sendExternalDishInvite } from '../services/lunch-external-invite'
|
||||||
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
|
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
|
||||||
|
import { verifyExternalLunchToken } from '@/lib/external-lunch-token'
|
||||||
import { csvCell } from '@/lib/csv'
|
import { csvCell } from '@/lib/csv'
|
||||||
|
|
||||||
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
||||||
@@ -179,6 +181,19 @@ export const lunchRouter = router({
|
|||||||
entityId: ext.id,
|
entityId: ext.id,
|
||||||
detailsJson: { name: ext.name, projectId: ext.projectId },
|
detailsJson: { name: ext.name, projectId: ext.projectId },
|
||||||
})
|
})
|
||||||
|
// Auto-send the dish-selection invite when an email is on file. Never throws
|
||||||
|
// — a failed send leaves inviteSentAt null so the admin can resend.
|
||||||
|
if (ext.email) {
|
||||||
|
try {
|
||||||
|
const event = await ctx.prisma.lunchEvent.findUnique({
|
||||||
|
where: { id: input.lunchEventId },
|
||||||
|
select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
|
||||||
|
})
|
||||||
|
if (event) await sendExternalDishInvite(ctx.prisma, ext, event)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch.createExternal] dish invite send failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
return ext
|
return ext
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -229,6 +244,159 @@ export const lunchRouter = router({
|
|||||||
return { ok: true as const }
|
return { ok: true as const }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send (or resend) the dish-selection invite to one external attendee.
|
||||||
|
* Stamps `inviteSentAt` and audit-logs. Fails if the attendee has no email.
|
||||||
|
*/
|
||||||
|
sendExternalInvite: adminProcedure
|
||||||
|
.input(z.object({ externalId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const external = await ctx.prisma.externalAttendee.findUnique({
|
||||||
|
where: { id: input.externalId },
|
||||||
|
include: {
|
||||||
|
lunchEvent: {
|
||||||
|
select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
if (!external.email) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'This attendee has no email address. Add one first.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendExternalDishInvite(ctx.prisma, external, external.lunchEvent)
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: `Failed to send invite: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
cause: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LUNCH_EXTERNAL_INVITE_SENT',
|
||||||
|
entityType: 'ExternalAttendee',
|
||||||
|
entityId: external.id,
|
||||||
|
detailsJson: { email: external.email },
|
||||||
|
})
|
||||||
|
return { ok: true as const }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Public tokenized external dish picker ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an external attendee + their event + dishes by a signed, no-login token.
|
||||||
|
* Token verification throws on bad signature / expiry; the page maps those to
|
||||||
|
* friendly states.
|
||||||
|
*/
|
||||||
|
getExternalByToken: publicProcedure
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const payload = verifyExternalLunchToken(input.token) // throws on bad sig / expired
|
||||||
|
const external = await ctx.prisma.externalAttendee.findUnique({
|
||||||
|
where: { id: payload.externalId },
|
||||||
|
include: {
|
||||||
|
lunchEvent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eventAt: true,
|
||||||
|
endAt: true,
|
||||||
|
venue: true,
|
||||||
|
notes: true,
|
||||||
|
changeCutoffHours: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
const dishes = await ctx.prisma.dish.findMany({
|
||||||
|
where: { lunchEventId: external.lunchEventId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
})
|
||||||
|
const eventAt = external.lunchEvent.eventAt
|
||||||
|
const changeDeadline = eventAt
|
||||||
|
? new Date(eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000)
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
external: {
|
||||||
|
id: external.id,
|
||||||
|
name: external.name,
|
||||||
|
dishId: external.dishId,
|
||||||
|
allergens: external.allergens,
|
||||||
|
allergenOther: external.allergenOther,
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
eventAt,
|
||||||
|
endAt: external.lunchEvent.endAt,
|
||||||
|
venue: external.lunchEvent.venue,
|
||||||
|
notes: external.lunchEvent.notes,
|
||||||
|
},
|
||||||
|
dishes,
|
||||||
|
changeDeadline,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an external attendee's dish pick via their signed token. Enforces the
|
||||||
|
* lunch change cutoff (eventAt − changeCutoffHours); past it, the attendee must
|
||||||
|
* contact an admin.
|
||||||
|
*/
|
||||||
|
setExternalPick: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
token: z.string(),
|
||||||
|
dishId: z.string().nullable(),
|
||||||
|
allergens,
|
||||||
|
allergenOther: z.string().max(500).nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const payload = verifyExternalLunchToken(input.token)
|
||||||
|
const external = await ctx.prisma.externalAttendee.findUnique({
|
||||||
|
where: { id: payload.externalId },
|
||||||
|
include: {
|
||||||
|
lunchEvent: { select: { eventAt: true, changeCutoffHours: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
|
||||||
|
const eventAt = external.lunchEvent.eventAt
|
||||||
|
if (eventAt) {
|
||||||
|
const deadline = new Date(
|
||||||
|
eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000,
|
||||||
|
)
|
||||||
|
if (new Date() > deadline) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'Past the lunch change deadline. Please contact an admin.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dish must belong to this attendee's event (defends against cross-event ids).
|
||||||
|
if (input.dishId) {
|
||||||
|
const dish = await ctx.prisma.dish.findFirst({
|
||||||
|
where: { id: input.dishId, lunchEventId: external.lunchEventId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!dish) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown dish' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await ctx.prisma.externalAttendee.update({
|
||||||
|
where: { id: external.id },
|
||||||
|
data: {
|
||||||
|
dishId: input.dishId,
|
||||||
|
allergens: input.allergens,
|
||||||
|
allergenOther: input.allergenOther,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ok: true as const, dishId: updated.dishId }
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
|
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -611,6 +779,19 @@ export const lunchRouter = router({
|
|||||||
console.error('[lunch.sendReminders] send failed for', am.user.email, e)
|
console.error('[lunch.sendReminders] send failed for', am.user.email, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External attendees: chase the ones with an email and no dish yet, via
|
||||||
|
// their own tokenized pick page.
|
||||||
|
const externals = await selectUnpickedExternals(ctx.prisma, { id: event.id })
|
||||||
|
for (const ext of externals) {
|
||||||
|
if (!ext.email) continue
|
||||||
|
try {
|
||||||
|
await sendExternalDishInvite(ctx.prisma, ext, event)
|
||||||
|
sent++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch.sendReminders] external send failed for', ext.email, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
return { sent }
|
return { sent }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
signMentorUploadToken,
|
signMentorUploadToken,
|
||||||
verifyMentorUploadToken,
|
verifyMentorUploadToken,
|
||||||
} from '@/lib/mentor-upload-token'
|
} from '@/lib/mentor-upload-token'
|
||||||
|
import { getFinalDocumentStatusForProject } from '../services/final-documents'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if the project is enrolled in a MENTORING round that is still
|
* True if the project is enrolled in a MENTORING round that is still
|
||||||
@@ -215,6 +216,21 @@ async function assertProjectWorkspaceAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mentorRouter = router({
|
export const mentorRouter = router({
|
||||||
|
/** Grand-final document status for a project, for the mentor workspace panel. */
|
||||||
|
getProjectFinalDocuments: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||||
|
if (!isAdmin) {
|
||||||
|
const [mentorAssignment, teamMembership] = await Promise.all([
|
||||||
|
ctx.prisma.mentorAssignment.findFirst({ where: { mentorId: ctx.user.id, projectId: input.projectId, droppedAt: null }, select: { id: true } }),
|
||||||
|
ctx.prisma.teamMember.findFirst({ where: { userId: ctx.user.id, projectId: input.projectId }, select: { id: true } }),
|
||||||
|
])
|
||||||
|
if (!mentorAssignment && !teamMembership) throw new TRPCError({ code: 'FORBIDDEN', message: 'No access to this project' })
|
||||||
|
}
|
||||||
|
return getFinalDocumentStatusForProject(ctx.prisma, input.projectId)
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get AI-suggested mentor matches for a project
|
* Get AI-suggested mentor matches for a project
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ const NOTIFICATION_SAMPLE_DATA: Record<string, Record<string, unknown>> = {
|
|||||||
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
FINALIST_EXPIRED: { projectTitle: 'Ocean Cleanup Initiative', category: 'STARTUP' },
|
||||||
FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', category: 'STARTUP' },
|
FINALIST_WAITLIST_PROMOTED: { projectTitle: 'Reef Guardians', category: 'STARTUP' },
|
||||||
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now() + 86_400_000).toISOString() },
|
FINALIST_REMINDER: { projectTitle: 'Ocean Cleanup Initiative', deadline: new Date(Date.now() + 86_400_000).toISOString() },
|
||||||
|
GRAND_FINAL_DOCS_REMINDER: {
|
||||||
|
projectTitle: 'Ocean Cleanup Initiative',
|
||||||
|
deadline: new Date(Date.now() + 5 * 86_400_000).toISOString(),
|
||||||
|
missing: ['Pitch deck', 'Final video', 'Team photo'],
|
||||||
|
},
|
||||||
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
|
FINALIST_WITHDRAWN: { projectTitle: 'Ocean Cleanup Initiative', reason: 'Schedule conflict' },
|
||||||
TRAVEL_CONFIRMED: {
|
TRAVEL_CONFIRMED: {
|
||||||
projectTitle: 'Ocean Cleanup Initiative',
|
projectTitle: 'Ocean Cleanup Initiative',
|
||||||
|
|||||||
@@ -159,11 +159,18 @@ export const roundRouter = router({
|
|||||||
|
|
||||||
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
|
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
|
||||||
|
|
||||||
// If configJson provided, validate it against the round type
|
// If configJson provided, validate it against the round type, then MERGE
|
||||||
|
// over the existing config: the validator strips keys it doesn't know,
|
||||||
|
// but configJson also carries operational state written outside this
|
||||||
|
// form (ceremony projectOrder, projectTimingOverrides, finals-docs
|
||||||
|
// upload toggle). Replacing wholesale would wipe a running ceremony.
|
||||||
let validatedConfig: Prisma.InputJsonValue | undefined
|
let validatedConfig: Prisma.InputJsonValue | undefined
|
||||||
if (configJson) {
|
if (configJson) {
|
||||||
const parsed = validateRoundConfig(existing.roundType, configJson)
|
const parsed = validateRoundConfig(existing.roundType, configJson)
|
||||||
validatedConfig = parsed as unknown as Prisma.InputJsonValue
|
validatedConfig = {
|
||||||
|
...((existing.configJson as Record<string, unknown>) ?? {}),
|
||||||
|
...(parsed as Record<string, unknown>),
|
||||||
|
} as unknown as Prisma.InputJsonValue
|
||||||
}
|
}
|
||||||
|
|
||||||
const round = await ctx.prisma.round.update({
|
const round = await ctx.prisma.round.update({
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ export async function getSessionWithVotes(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
) {
|
) {
|
||||||
return prisma.deliberationSession.findUnique({
|
const session = await prisma.deliberationSession.findUnique({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
include: {
|
include: {
|
||||||
votes: {
|
votes: {
|
||||||
@@ -648,6 +648,23 @@ export async function getSessionWithVotes(
|
|||||||
round: { select: { id: true, name: true, roundType: true } },
|
round: { select: { id: true, name: true, roundType: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
// Rankable projects: the round's projects in this session's category.
|
||||||
|
// (results are empty until finalize — the jury ranking form needs this list.)
|
||||||
|
const roundStates = await prisma.projectRoundState.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: session.roundId,
|
||||||
|
project: { competitionCategory: session.category },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: { id: true, title: true, teamName: true, competitionCategory: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...session, projects: roundStates.map((rs) => rs.project) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
||||||
|
|||||||
391
src/server/services/final-documents.ts
Normal file
391
src/server/services/final-documents.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import type { PrismaClient, RoundStatus } from '@prisma/client'
|
||||||
|
import { createNotification, NotificationTypes } from './in-app-notification'
|
||||||
|
import { getPresignedUrl } from '@/lib/minio'
|
||||||
|
|
||||||
|
export type FinalDocRequirement = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
acceptedMimeTypes: string[]
|
||||||
|
isRequired: boolean
|
||||||
|
uploaded: boolean
|
||||||
|
file: { id: string; fileName: string; mimeType: string; bucket: string; objectKey: string; createdAt: Date } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinalDocumentStatus = {
|
||||||
|
roundId: string
|
||||||
|
roundName: string
|
||||||
|
deadline: Date | null
|
||||||
|
deadlinePassed: boolean
|
||||||
|
requirements: FinalDocRequirement[]
|
||||||
|
allRequiredUploaded: boolean
|
||||||
|
hasRequired: boolean // any slot is marked required
|
||||||
|
allUploaded: boolean // every listed slot has a file (false when no slots exist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A LIVE_FINAL round is "open for documents" during the lead-up — while it is
|
||||||
|
// DRAFT (not yet opened for the live event) or ACTIVE — but not once CLOSED/
|
||||||
|
// finalized. This decouples document upload + judge review from the ceremony
|
||||||
|
// actually starting, so the Grand Final round can stay DRAFT until event time.
|
||||||
|
const OPEN_FINALE_STATUS: RoundStatus[] = ['ROUND_DRAFT', 'ROUND_ACTIVE']
|
||||||
|
|
||||||
|
/** Resolve the program's LIVE_FINAL round while it is open for documents (DRAFT or ACTIVE, not closed/finalized), or null. */
|
||||||
|
export async function getOpenFinaleRound(prisma: PrismaClient, programId: string) {
|
||||||
|
return prisma.round.findFirst({
|
||||||
|
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
||||||
|
orderBy: { sortOrder: 'desc' },
|
||||||
|
select: { id: true, name: true, windowCloseAt: true, configJson: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether finalist teams are allowed to upload *revised* documents for the
|
||||||
|
* grand final. This is an admin toggle on the LIVE_FINAL round's configJson.
|
||||||
|
* When false (default), judges still see the teams' existing prior-round
|
||||||
|
* submissions, but teams are not prompted/able to upload anything new.
|
||||||
|
*/
|
||||||
|
export function finalistUploadsEnabled(configJson: unknown): boolean {
|
||||||
|
return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which prior-round FileRequirement ids are visible to finale judges.
|
||||||
|
* null = no curation (show all prior files). Empty array = hide all prior
|
||||||
|
* files (Grand Final round uploads are always shown regardless).
|
||||||
|
*/
|
||||||
|
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
|
||||||
|
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-project grand-final document status. Returns null unless the project is
|
||||||
|
* enrolled (ProjectRoundState) in the program's active LIVE_FINAL round.
|
||||||
|
*/
|
||||||
|
export async function getFinalDocumentStatusForProject(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<FinalDocumentStatus | null> {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { id: true, programId: true },
|
||||||
|
})
|
||||||
|
if (!project) return null
|
||||||
|
|
||||||
|
const round = await getOpenFinaleRound(prisma, project.programId)
|
||||||
|
// Banner / upload status only applies when the admin has enabled revised uploads.
|
||||||
|
if (!round || !finalistUploadsEnabled(round.configJson)) return null
|
||||||
|
|
||||||
|
const enrolled = await prisma.projectRoundState.findFirst({
|
||||||
|
where: { projectId, roundId: round.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!enrolled) return null
|
||||||
|
|
||||||
|
const requirements = await prisma.fileRequirement.findMany({
|
||||||
|
where: { roundId: round.id },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true, acceptedMimeTypes: true, isRequired: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = await prisma.projectFile.findMany({
|
||||||
|
where: { projectId, roundId: round.id, requirementId: { in: requirements.map((r) => r.id) } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { id: true, requirementId: true, fileName: true, mimeType: true, bucket: true, objectKey: true, createdAt: true },
|
||||||
|
})
|
||||||
|
const fileByReq = new Map<string, (typeof files)[number]>()
|
||||||
|
for (const f of files) if (f.requirementId && !fileByReq.has(f.requirementId)) fileByReq.set(f.requirementId, f)
|
||||||
|
|
||||||
|
const reqStatuses: FinalDocRequirement[] = requirements.map((r) => {
|
||||||
|
const f = fileByReq.get(r.id) ?? null
|
||||||
|
return {
|
||||||
|
id: r.id, name: r.name, acceptedMimeTypes: r.acceptedMimeTypes, isRequired: r.isRequired,
|
||||||
|
uploaded: !!f,
|
||||||
|
file: f ? { id: f.id, fileName: f.fileName, mimeType: f.mimeType, bucket: f.bucket, objectKey: f.objectKey, createdAt: f.createdAt } : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const required = reqStatuses.filter((r) => r.isRequired)
|
||||||
|
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
|
||||||
|
const hasRequired = required.length > 0
|
||||||
|
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
|
||||||
|
const deadline = round.windowCloseAt ?? null
|
||||||
|
return {
|
||||||
|
roundId: round.id,
|
||||||
|
roundName: round.name,
|
||||||
|
deadline,
|
||||||
|
deadlinePassed: deadline ? new Date() > deadline : false,
|
||||||
|
requirements: reqStatuses,
|
||||||
|
allRequiredUploaded,
|
||||||
|
hasRequired,
|
||||||
|
allUploaded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the reminder notification payload for one finalist team lead. */
|
||||||
|
async function remindTeam(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
args: { projectId: string; projectTitle: string; deadline: Date | null; missing: string[]; leadUserId: string },
|
||||||
|
) {
|
||||||
|
await createNotification({
|
||||||
|
userId: args.leadUserId,
|
||||||
|
type: NotificationTypes.GRAND_FINAL_DOCS_REMINDER,
|
||||||
|
title: 'Upload your Grand Final documents',
|
||||||
|
message: args.missing.length
|
||||||
|
? `Still needed for "${args.projectTitle}": ${args.missing.join(', ')}.`
|
||||||
|
: `Please upload the final documents for "${args.projectTitle}".`,
|
||||||
|
linkUrl: `/applicant/documents`, // relative → client-side nav in-app; email renderer absolutizes
|
||||||
|
metadata: {
|
||||||
|
projectId: args.projectId,
|
||||||
|
projectTitle: args.projectTitle,
|
||||||
|
deadline: args.deadline?.toISOString(),
|
||||||
|
missing: args.missing,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual admin reminder blast. Targets `projectIds` if given, else all finalist
|
||||||
|
* teams (enrolled in the active LIVE_FINAL round) with missing required docs.
|
||||||
|
*/
|
||||||
|
export async function sendManualFinalDocReminders(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
opts: { programId: string; projectIds?: string[]; actorId: string },
|
||||||
|
): Promise<{ sent: number }> {
|
||||||
|
const round = await getOpenFinaleRound(prisma, opts.programId)
|
||||||
|
if (!round || !finalistUploadsEnabled(round.configJson)) return { sent: 0 }
|
||||||
|
|
||||||
|
const states = await prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: round.id, ...(opts.projectIds ? { projectId: { in: opts.projectIds } } : {}) },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let sent = 0
|
||||||
|
for (const { projectId } of states) {
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, projectId)
|
||||||
|
if (!status) continue
|
||||||
|
const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name)
|
||||||
|
// When projectIds explicitly provided, send regardless; else only if missing docs.
|
||||||
|
if (!opts.projectIds && missing.length === 0) continue
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { userId: true } } },
|
||||||
|
})
|
||||||
|
const leadUserId = project?.teamMembers[0]?.userId
|
||||||
|
if (!project || !leadUserId) continue
|
||||||
|
|
||||||
|
await remindTeam(prisma, {
|
||||||
|
projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId,
|
||||||
|
})
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
return { sent }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron: remind finalist teams (enrolled in an active LIVE_FINAL round) with
|
||||||
|
* missing required documents, once, when the deadline is within the configured
|
||||||
|
* window. Stamps FinalistConfirmation.finalDocsReminderSentAt.
|
||||||
|
*/
|
||||||
|
export async function sendDueFinalDocReminders(prisma: PrismaClient): Promise<{ remindersSent: number }> {
|
||||||
|
const now = new Date()
|
||||||
|
const rounds = await prisma.round.findMany({
|
||||||
|
where: { roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
||||||
|
select: { id: true, windowCloseAt: true, configJson: true, competition: { select: { programId: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
let remindersSent = 0
|
||||||
|
for (const round of rounds) {
|
||||||
|
if (!round.windowCloseAt) continue
|
||||||
|
// Only chase teams to upload when the admin has enabled revised uploads.
|
||||||
|
if (!finalistUploadsEnabled(round.configJson)) continue
|
||||||
|
const cfg = (round.configJson ?? {}) as { finalDocsReminderHoursBeforeDeadline?: number }
|
||||||
|
const windowMs = (cfg.finalDocsReminderHoursBeforeDeadline ?? 48) * 3_600_000
|
||||||
|
const isDue = round.windowCloseAt.getTime() <= now.getTime() + windowMs && round.windowCloseAt.getTime() > now.getTime()
|
||||||
|
if (!isDue) continue
|
||||||
|
|
||||||
|
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
|
||||||
|
for (const { projectId } of states) {
|
||||||
|
const confirmation = await prisma.finalistConfirmation.findFirst({
|
||||||
|
where: { projectId, finalDocsReminderSentAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!confirmation) continue
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, projectId)
|
||||||
|
if (!status) continue
|
||||||
|
const missing = status.requirements.filter((r) => r.isRequired && !r.uploaded).map((r) => r.name)
|
||||||
|
if (missing.length === 0) continue
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { title: true, teamMembers: { where: { role: 'LEAD' }, take: 1, select: { userId: true } } },
|
||||||
|
})
|
||||||
|
const leadUserId = project?.teamMembers[0]?.userId
|
||||||
|
if (!project || !leadUserId) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remindTeam(prisma, { projectId, projectTitle: project.title, deadline: status.deadline, missing, leadUserId })
|
||||||
|
await prisma.finalistConfirmation.update({ where: { id: confirmation.id }, data: { finalDocsReminderSentAt: new Date() } })
|
||||||
|
remindersSent++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[final-docs] reminder failed for', projectId, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { remindersSent }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewFile = {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
url: string
|
||||||
|
docLabel: string // requirement name if known, else a humanized file type
|
||||||
|
roundLabel: string // which round this was submitted in
|
||||||
|
roundSort: number
|
||||||
|
isFinaleUpload: boolean // uploaded directly to the LIVE_FINAL round (a revised "final")
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
export type ReviewTeam = { projectId: string; teamName: string; category: string | null; files: ReviewFile[] }
|
||||||
|
export type ReviewPayload = { round: { id: string; name: string; deadline: Date | null }; totalCount: number; teams: ReviewTeam[] }
|
||||||
|
|
||||||
|
function humanizeFileType(t: string | null | undefined): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'EXEC_SUMMARY': return 'Executive Summary'
|
||||||
|
case 'BUSINESS_PLAN': return 'Business Plan'
|
||||||
|
case 'PRESENTATION': return 'Presentation'
|
||||||
|
case 'VIDEO_PITCH': return 'Video Pitch'
|
||||||
|
case 'VIDEO': return 'Video'
|
||||||
|
case 'SUPPORTING_DOC': return 'Supporting Document'
|
||||||
|
default: return 'Document'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only review payload for finale judges: every finalist team enrolled in
|
||||||
|
* the program's LIVE_FINAL round, with ALL of their submitted files across every
|
||||||
|
* round (pitch deck, executive summary, business plan, videos, plus any revised
|
||||||
|
* finals uploads). Each file carries a server-generated GET presigned URL (1h)
|
||||||
|
* so finale judges — who are not assignment-gated through file.getDownloadUrl —
|
||||||
|
* can open the documents directly. This is NOT gated on the upload toggle:
|
||||||
|
* judges can always review the teams' existing submissions.
|
||||||
|
*/
|
||||||
|
export async function listFinalistDocumentsForReview(prisma: PrismaClient, programId: string): Promise<ReviewPayload> {
|
||||||
|
const round = await getOpenFinaleRound(prisma, programId)
|
||||||
|
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
|
||||||
|
|
||||||
|
// Admin curation: which prior-round documents judges may see (null = all).
|
||||||
|
const visibleIds = reviewVisibleRequirementIds(round.configJson)
|
||||||
|
|
||||||
|
const states = await prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: round.id },
|
||||||
|
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
|
||||||
|
})
|
||||||
|
const projectIds = states.map((s) => s.project.id)
|
||||||
|
|
||||||
|
// Every file these teams have submitted, in any round.
|
||||||
|
const allFiles = await prisma.projectFile.findMany({
|
||||||
|
where: { projectId: { in: projectIds } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true, projectId: true, fileName: true, mimeType: true, fileType: true, requirementId: true,
|
||||||
|
bucket: true, objectKey: true, createdAt: true, roundId: true,
|
||||||
|
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolve round names for files attached directly to a round (no requirement).
|
||||||
|
const directRoundIds = [...new Set(allFiles.filter((f) => f.roundId && !f.requirement).map((f) => f.roundId!))]
|
||||||
|
const directRounds = directRoundIds.length
|
||||||
|
? await prisma.round.findMany({ where: { id: { in: directRoundIds } }, select: { id: true, name: true, sortOrder: true } })
|
||||||
|
: []
|
||||||
|
const roundById = new Map(directRounds.map((r) => [r.id, r]))
|
||||||
|
|
||||||
|
const filesByProject = new Map<string, ReviewFile[]>()
|
||||||
|
for (const f of allFiles) {
|
||||||
|
const isFinaleUpload = f.roundId === round.id
|
||||||
|
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
|
||||||
|
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
|
||||||
|
const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null)
|
||||||
|
const rf: ReviewFile = {
|
||||||
|
id: f.id,
|
||||||
|
fileName: f.fileName,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
url: await getPresignedUrl(f.bucket, f.objectKey, 'GET', 3600),
|
||||||
|
docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType),
|
||||||
|
roundLabel: r?.name ?? '—',
|
||||||
|
roundSort: r?.sortOrder ?? -1,
|
||||||
|
isFinaleUpload,
|
||||||
|
createdAt: f.createdAt,
|
||||||
|
}
|
||||||
|
const list = filesByProject.get(f.projectId)
|
||||||
|
if (list) list.push(rf)
|
||||||
|
else filesByProject.set(f.projectId, [rf])
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams: ReviewTeam[] = states.map(({ project }) => ({
|
||||||
|
projectId: project.id,
|
||||||
|
teamName: project.teamName || project.title,
|
||||||
|
category: project.competitionCategory,
|
||||||
|
files: (filesByProject.get(project.id) ?? []).sort(
|
||||||
|
(a, b) => b.roundSort - a.roundSort || a.docLabel.localeCompare(b.docLabel),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
teams.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.teamName.localeCompare(b.teamName))
|
||||||
|
return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewDocSlot = {
|
||||||
|
requirementId: string
|
||||||
|
name: string
|
||||||
|
roundName: string
|
||||||
|
roundSort: number
|
||||||
|
fileCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct prior-round document slots (FileRequirements) that the finalist
|
||||||
|
* teams have files for — the options offered in the admin "documents shown to
|
||||||
|
* judges" picker. Excludes the finale round's own slots (those uploads are
|
||||||
|
* always visible to judges) and files without a requirement.
|
||||||
|
*/
|
||||||
|
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
|
||||||
|
const round = await getOpenFinaleRound(prisma, programId)
|
||||||
|
if (!round) return []
|
||||||
|
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
|
||||||
|
const files = await prisma.projectFile.findMany({
|
||||||
|
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
|
||||||
|
select: {
|
||||||
|
requirementId: true,
|
||||||
|
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const slots = new Map<string, ReviewDocSlot>()
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.requirementId || !f.requirement) continue
|
||||||
|
const existing = slots.get(f.requirementId)
|
||||||
|
if (existing) existing.fileCount++
|
||||||
|
else slots.set(f.requirementId, {
|
||||||
|
requirementId: f.requirementId,
|
||||||
|
name: f.requirement.name.trim(),
|
||||||
|
roundName: f.requirement.round.name,
|
||||||
|
roundSort: f.requirement.round.sortOrder,
|
||||||
|
fileCount: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
|
||||||
|
export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise<boolean> {
|
||||||
|
if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true
|
||||||
|
const round = await prisma.round.findFirst({
|
||||||
|
where: { competition: { programId }, roundType: 'LIVE_FINAL', status: { in: OPEN_FINALE_STATUS }, finalizedAt: null },
|
||||||
|
orderBy: { sortOrder: 'desc' },
|
||||||
|
select: { juryGroupId: true },
|
||||||
|
})
|
||||||
|
if (!round?.juryGroupId) return false
|
||||||
|
const member = await prisma.juryGroupMember.findFirst({ where: { juryGroupId: round.juryGroupId, userId }, select: { id: true } })
|
||||||
|
return !!member
|
||||||
|
}
|
||||||
@@ -105,6 +105,8 @@ export const NotificationTypes = {
|
|||||||
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
|
FINALIST_WITHDRAWN: 'FINALIST_WITHDRAWN',
|
||||||
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
|
TRAVEL_CONFIRMED: 'TRAVEL_CONFIRMED',
|
||||||
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
|
VISA_STATUS_UPDATE: 'VISA_STATUS_UPDATE',
|
||||||
|
GRAND_FINAL_DOCS_REMINDER: 'GRAND_FINAL_DOCS_REMINDER',
|
||||||
|
GRAND_FINAL_DOCS_SUBMITTED: 'GRAND_FINAL_DOCS_SUBMITTED',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
|
||||||
|
|||||||
61
src/server/services/lunch-external-invite.ts
Normal file
61
src/server/services/lunch-external-invite.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||||
|
import { signExternalLunchToken } from '@/lib/external-lunch-token'
|
||||||
|
import { getBaseUrl, sendExternalDishInviteEmail } from '@/lib/email'
|
||||||
|
|
||||||
|
type PrismaLike = PrismaClient | Prisma.TransactionClient
|
||||||
|
|
||||||
|
export type InvitableExternal = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteLunchEvent = {
|
||||||
|
eventAt: Date | null
|
||||||
|
venue: string | null
|
||||||
|
notes: string | null
|
||||||
|
changeCutoffHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public, tokenized dish-pick URL for an external attendee.
|
||||||
|
* The token is a stateless HMAC signature (see external-lunch-token) carrying the
|
||||||
|
* externalId; `exp` is generous (event day + 1, or 30 days when no event date)
|
||||||
|
* so the link outlives the change deadline, which is enforced separately at write.
|
||||||
|
*/
|
||||||
|
export function buildExternalPickUrl(externalId: string, eventAt: Date | null): string {
|
||||||
|
const exp = eventAt
|
||||||
|
? Math.floor(eventAt.getTime() / 1000) + 86_400
|
||||||
|
: Math.floor(Date.now() / 1000) + 30 * 86_400
|
||||||
|
const token = signExternalLunchToken({ externalId, exp })
|
||||||
|
return `${getBaseUrl()}/lunch/pick/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send (or resend) the dish-selection invite to one external attendee and stamp
|
||||||
|
* `inviteSentAt`. Throws if the external has no email or the email send fails;
|
||||||
|
* callers that must not fail (e.g. createExternal) wrap this in try/catch.
|
||||||
|
*/
|
||||||
|
export async function sendExternalDishInvite(
|
||||||
|
prisma: PrismaLike,
|
||||||
|
external: InvitableExternal,
|
||||||
|
event: InviteLunchEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!external.email) throw new Error('External attendee has no email address')
|
||||||
|
const changeDeadline = event.eventAt
|
||||||
|
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
|
||||||
|
: null
|
||||||
|
await sendExternalDishInviteEmail({
|
||||||
|
to: external.email,
|
||||||
|
name: external.name,
|
||||||
|
eventAt: event.eventAt,
|
||||||
|
venue: event.venue,
|
||||||
|
notes: event.notes,
|
||||||
|
changeDeadline,
|
||||||
|
pickUrl: buildExternalPickUrl(external.id, event.eventAt),
|
||||||
|
})
|
||||||
|
await prisma.externalAttendee.update({
|
||||||
|
where: { id: external.id },
|
||||||
|
data: { inviteSentAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -30,3 +30,21 @@ export async function selectUnpickedAttendees(
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
include: { user: { select: { name: true, email: true } } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return external attendees for a LunchEvent that can still be chased for a dish
|
||||||
|
* pick: they have an email on file and have not yet been assigned a dish.
|
||||||
|
* Externals with no email (un-emailable) or an existing dish are excluded.
|
||||||
|
*/
|
||||||
|
export async function selectUnpickedExternals(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
event: { id: string },
|
||||||
|
) {
|
||||||
|
return prisma.externalAttendee.findMany({
|
||||||
|
where: {
|
||||||
|
lunchEventId: event.id,
|
||||||
|
dishId: null,
|
||||||
|
AND: [{ email: { not: null } }, { email: { not: '' } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
277
tests/unit/audience-window.test.ts
Normal file
277
tests/unit/audience-window.test.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Grand-finale audience favorite-vote windows:
|
||||||
|
* - admin opens a per-category (or overall) window with a duration
|
||||||
|
* - votes are gated server-side: phase OPEN, before closesAt, category match
|
||||||
|
* - one vote per voter token per window (re-vote updates), max 3 voters per IP
|
||||||
|
* - no cron required: the closesAt check at vote/read time is the source of truth
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { liveVotingRouter } from '@/server/routers/live-voting'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let round: any
|
||||||
|
let session: any
|
||||||
|
let startup1: any
|
||||||
|
let startup2: any
|
||||||
|
let concept1: any
|
||||||
|
let admin: any
|
||||||
|
let adminCaller: ReturnType<typeof createCaller>
|
||||||
|
let publicCaller: ReturnType<typeof createCaller>
|
||||||
|
let tokenA: string
|
||||||
|
let tokenB: string
|
||||||
|
|
||||||
|
async function makeVoter() {
|
||||||
|
const res = await publicCaller.registerAudienceVoter({ sessionId: session.id })
|
||||||
|
return res.token as string
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
round = await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
})
|
||||||
|
startup1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
startup2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
concept1 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
|
||||||
|
await prisma.round.update({
|
||||||
|
where: { id: round.id },
|
||||||
|
data: { configJson: { projectOrder: [concept1.id, startup1.id, startup2.id] } },
|
||||||
|
})
|
||||||
|
session = await prisma.liveVotingSession.create({
|
||||||
|
data: { roundId: round.id, allowAudienceVotes: true },
|
||||||
|
})
|
||||||
|
admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
adminCaller = createCaller(liveVotingRouter, admin)
|
||||||
|
publicCaller = createCaller(liveVotingRouter, admin) // public procedures ignore the session user
|
||||||
|
tokenA = await makeVoter()
|
||||||
|
tokenB = await makeVoter()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [admin.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('window lifecycle', () => {
|
||||||
|
it('opens a category window with a closing time', async () => {
|
||||||
|
const s = await adminCaller.openAudienceWindow({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:STARTUP',
|
||||||
|
durationMinutes: 5,
|
||||||
|
})
|
||||||
|
expect(s.audiencePhase).toBe('OPEN')
|
||||||
|
expect(s.audienceWindowKey).toBe('CATEGORY:STARTUP')
|
||||||
|
const msLeft = new Date(s.audienceWindowClosesAt!).getTime() - Date.now()
|
||||||
|
expect(msLeft).toBeGreaterThan(4 * 60_000)
|
||||||
|
expect(msLeft).toBeLessThanOrEqual(5 * 60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects opening a second window while one is open', async () => {
|
||||||
|
await expect(
|
||||||
|
adminCaller.openAudienceWindow({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
|
||||||
|
durationMinutes: 5,
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('casting favorite votes', () => {
|
||||||
|
it('accepts a vote for an in-category project and re-vote updates in place', async () => {
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenA,
|
||||||
|
projectId: startup1.id,
|
||||||
|
})
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenA,
|
||||||
|
projectId: startup2.id,
|
||||||
|
})
|
||||||
|
const rows = await prisma.audienceFavoriteVote.findMany({
|
||||||
|
where: { sessionId: session.id, windowKey: 'CATEGORY:STARTUP' },
|
||||||
|
})
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].projectId).toBe(startup2.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a vote for a project outside the open category', async () => {
|
||||||
|
await expect(
|
||||||
|
publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenB,
|
||||||
|
projectId: concept1.id,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/category/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid token', async () => {
|
||||||
|
await expect(
|
||||||
|
publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: 'bogus',
|
||||||
|
projectId: startup1.id,
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects votes after closesAt even without an explicit close (no cron)', async () => {
|
||||||
|
await prisma.liveVotingSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { audienceWindowClosesAt: new Date(Date.now() - 1000) },
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenB,
|
||||||
|
projectId: startup1.id,
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
// getAudienceWindow also reports closed
|
||||||
|
const win = await publicCaller.getAudienceWindow({ sessionId: session.id })
|
||||||
|
expect(win.open).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close + re-open works and votes flow again in the new category', async () => {
|
||||||
|
await adminCaller.closeAudienceWindow({ sessionId: session.id })
|
||||||
|
await expect(
|
||||||
|
publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenB,
|
||||||
|
projectId: startup1.id,
|
||||||
|
})
|
||||||
|
).rejects.toThrow()
|
||||||
|
|
||||||
|
await adminCaller.openAudienceWindow({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
|
||||||
|
durationMinutes: 5,
|
||||||
|
})
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenB,
|
||||||
|
projectId: concept1.id,
|
||||||
|
})
|
||||||
|
const rows = await prisma.audienceFavoriteVote.findMany({
|
||||||
|
where: { sessionId: session.id, windowKey: 'CATEGORY:BUSINESS_CONCEPT' },
|
||||||
|
})
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('overall favorite window', () => {
|
||||||
|
it('requires the admin toggle before opening', async () => {
|
||||||
|
await adminCaller.closeAudienceWindow({ sessionId: session.id })
|
||||||
|
await expect(
|
||||||
|
adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 })
|
||||||
|
).rejects.toThrow()
|
||||||
|
|
||||||
|
await adminCaller.updateSessionConfig({ sessionId: session.id, allowOverallFavorite: true })
|
||||||
|
const s = await adminCaller.openAudienceWindow({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'OVERALL',
|
||||||
|
durationMinutes: 5,
|
||||||
|
})
|
||||||
|
expect(s.audienceWindowKey).toBe('OVERALL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts any ordered project in OVERALL mode', async () => {
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenA,
|
||||||
|
projectId: concept1.id,
|
||||||
|
})
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenB,
|
||||||
|
projectId: startup1.id,
|
||||||
|
})
|
||||||
|
const rows = await prisma.audienceFavoriteVote.findMany({
|
||||||
|
where: { sessionId: session.id, windowKey: 'OVERALL' },
|
||||||
|
})
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('IP cap', () => {
|
||||||
|
it('rejects a 4th distinct voter from the same IP in one window', async () => {
|
||||||
|
// tokenA + tokenB already voted in OVERALL from ctx ip 127.0.0.1, but their
|
||||||
|
// stored ipAddress comes from ctx — normalize the rows to be explicit:
|
||||||
|
await prisma.audienceFavoriteVote.updateMany({
|
||||||
|
where: { sessionId: session.id, windowKey: 'OVERALL' },
|
||||||
|
data: { ipAddress: '127.0.0.1' },
|
||||||
|
})
|
||||||
|
const tokenC = await makeVoter()
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenC,
|
||||||
|
projectId: startup2.id,
|
||||||
|
})
|
||||||
|
const tokenD = await makeVoter()
|
||||||
|
await expect(
|
||||||
|
publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenD,
|
||||||
|
projectId: startup1.id,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/limit/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an existing voter on a capped IP can still change their vote', async () => {
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: tokenA,
|
||||||
|
projectId: startup2.id,
|
||||||
|
})
|
||||||
|
const row = await prisma.audienceFavoriteVote.findFirst({
|
||||||
|
where: { sessionId: session.id, windowKey: 'OVERALL', audienceVoter: { token: tokenA } },
|
||||||
|
})
|
||||||
|
expect(row?.projectId).toBe(startup2.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reads', () => {
|
||||||
|
it('getAudienceWindow lists eligible projects in order and the caller’s vote', async () => {
|
||||||
|
const win = await publicCaller.getAudienceWindow({ sessionId: session.id, token: tokenA })
|
||||||
|
expect(win.open).toBe(true)
|
||||||
|
expect(win.windowKey).toBe('OVERALL')
|
||||||
|
expect(win.projects.map((p: any) => p.id)).toEqual([concept1.id, startup1.id, startup2.id])
|
||||||
|
expect(win.myVoteProjectId).toBe(startup2.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getFavoriteTallies aggregates per window per project', async () => {
|
||||||
|
const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id })
|
||||||
|
const overall = tallies.windows.find((w: any) => w.windowKey === 'OVERALL')
|
||||||
|
expect(overall.totalVotes).toBe(3)
|
||||||
|
const startup2Count = overall.projects.find((p: any) => p.projectId === startup2.id)?.count
|
||||||
|
expect(startup2Count).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getAudienceContextByRound resolves the session publicly', async () => {
|
||||||
|
const ctx = await publicCaller.getAudienceContextByRound({ roundId: round.id })
|
||||||
|
expect(ctx?.sessionId).toBe(session.id)
|
||||||
|
expect(ctx?.allowAudienceVotes).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-admins cannot open windows or read tallies', async () => {
|
||||||
|
const juror = await createTestUser('JURY_MEMBER')
|
||||||
|
const jurorCaller = createCaller(liveVotingRouter, juror)
|
||||||
|
await expect(
|
||||||
|
jurorCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 })
|
||||||
|
).rejects.toThrow()
|
||||||
|
await expect(jurorCaller.getFavoriteTallies({ sessionId: session.id })).rejects.toThrow()
|
||||||
|
await prisma.user.delete({ where: { id: juror.id } })
|
||||||
|
})
|
||||||
|
})
|
||||||
45
tests/unit/auth-public-paths.test.ts
Normal file
45
tests/unit/auth-public-paths.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* The external lunch dish-pick page is reached by attendees with NO account, via
|
||||||
|
* a signed token link. It MUST be in the middleware public-path allowlist, or the
|
||||||
|
* auth middleware redirects them to /login (a dead end for accountless users).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { authConfig } from '@/lib/auth.config'
|
||||||
|
|
||||||
|
function authorized(pathname: string, auth: unknown) {
|
||||||
|
const fn = authConfig.callbacks!.authorized!
|
||||||
|
return fn({
|
||||||
|
auth: auth as never,
|
||||||
|
request: { nextUrl: new URL(`http://localhost${pathname}`) } as never,
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('middleware public paths', () => {
|
||||||
|
it('allows the external lunch pick page without a session', () => {
|
||||||
|
expect(authorized('/lunch/pick/some.signed.token', null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks a protected page without a session', () => {
|
||||||
|
expect(authorized('/admin/logistics', null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Grand finale: audience QR voting, public scoreboard, and the big-screen
|
||||||
|
// ceremony view are all reached by attendees with NO account.
|
||||||
|
it('allows audience voting pages without a session', () => {
|
||||||
|
expect(authorized('/vote/competition/round123', null)).toBe(true)
|
||||||
|
expect(authorized('/vote/session456', null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows the public live scoreboard without a session', () => {
|
||||||
|
expect(authorized('/live-scores/session456', null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows the big-screen ceremony view without a session', () => {
|
||||||
|
expect(authorized('/live/ceremony/round123', null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps jury and admin live surfaces private', () => {
|
||||||
|
expect(authorized('/jury/competitions/round123/live', null)).toBe(false)
|
||||||
|
expect(authorized('/admin', null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
39
tests/unit/ceremony-rate-limit.test.ts
Normal file
39
tests/unit/ceremony-rate-limit.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Ceremony traffic classifier — these requests bypass the 100/min-per-IP tRPC
|
||||||
|
* budget (whole venues share one IP). A single non-ceremony procedure in a
|
||||||
|
* batch must fall back to the strict bucket.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { isCeremonyTraffic } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
describe('isCeremonyTraffic', () => {
|
||||||
|
it('accepts single ceremony procedures', () => {
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/liveVoting.getCeremonyState')).toBe(true)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/liveVoting.getAudienceWindow')).toBe(true)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/liveVoting.castFavoriteVote')).toBe(true)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/live.getCursor')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts batched requests of only ceremony procedures', () => {
|
||||||
|
expect(
|
||||||
|
isCeremonyTraffic('/api/trpc/live.getCursor,liveVoting.getSessionForVotingByRound')
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects any batch containing a non-ceremony procedure', () => {
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/live.getCursor,user.me')).toBe(false)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/liveVoting.getResults')).toBe(false)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/deliberation.submitVote')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects admin/state-changing live procedures', () => {
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/live.startPresentation')).toBe(false)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/liveVoting.openAudienceWindow')).toBe(false)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/liveVoting.revealNext')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty and malformed paths', () => {
|
||||||
|
expect(isCeremonyTraffic('/api/trpc/')).toBe(false)
|
||||||
|
expect(isCeremonyTraffic('/api/trpc')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
113
tests/unit/deliberation-jury-wiring.test.ts
Normal file
113
tests/unit/deliberation-jury-wiring.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Deliberation jury wiring — regression for two launch blockers:
|
||||||
|
* 1. submitVote compared the JuryGroupMember id against User.id (FORBIDDEN for
|
||||||
|
* every legitimate juror). The server now resolves the caller's participant
|
||||||
|
* row itself; the client never sends an identity.
|
||||||
|
* 2. getSession had no project list before finalize (the ranking form rendered
|
||||||
|
* empty). It now includes the round's projects filtered by session category.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
createTestProjectRoundState,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { deliberationRouter } from '@/server/routers/deliberation'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let competition: any
|
||||||
|
let delibRound: any
|
||||||
|
let startup1: any
|
||||||
|
let startup2: any
|
||||||
|
let concept1: any
|
||||||
|
let juror: any
|
||||||
|
let outsiderJuror: any
|
||||||
|
let admin: any
|
||||||
|
let sessionId: string
|
||||||
|
let adminCaller: ReturnType<typeof createCaller>
|
||||||
|
let jurorCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
competition = await createTestCompetition(program.id)
|
||||||
|
delibRound = await createTestRound(competition.id, {
|
||||||
|
roundType: 'DELIBERATION',
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
})
|
||||||
|
startup1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
startup2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
concept1 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
|
||||||
|
await createTestProjectRoundState(startup1.id, delibRound.id)
|
||||||
|
await createTestProjectRoundState(startup2.id, delibRound.id)
|
||||||
|
await createTestProjectRoundState(concept1.id, delibRound.id)
|
||||||
|
|
||||||
|
const juryGroup = await prisma.juryGroup.create({
|
||||||
|
data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') },
|
||||||
|
})
|
||||||
|
juror = await createTestUser('JURY_MEMBER')
|
||||||
|
outsiderJuror = await createTestUser('JURY_MEMBER')
|
||||||
|
admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
const member = await prisma.juryGroupMember.create({
|
||||||
|
data: { juryGroupId: juryGroup.id, userId: juror.id, role: 'MEMBER' },
|
||||||
|
})
|
||||||
|
|
||||||
|
adminCaller = createCaller(deliberationRouter, admin)
|
||||||
|
jurorCaller = createCaller(deliberationRouter, juror)
|
||||||
|
|
||||||
|
const session = await adminCaller.createSession({
|
||||||
|
competitionId: competition.id,
|
||||||
|
roundId: delibRound.id,
|
||||||
|
category: 'STARTUP',
|
||||||
|
mode: 'FULL_RANKING',
|
||||||
|
tieBreakMethod: 'TIE_ADMIN_DECIDES',
|
||||||
|
participantUserIds: [member.id], // JuryGroupMember IDs
|
||||||
|
})
|
||||||
|
sessionId = session.id
|
||||||
|
await adminCaller.openVoting({ sessionId })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [juror.id, outsiderJuror.id, admin.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSession projects', () => {
|
||||||
|
it('exposes the category projects before any results exist', async () => {
|
||||||
|
const session = await jurorCaller.getSession({ sessionId })
|
||||||
|
const ids = (session.projects ?? []).map((p: any) => p.id).sort()
|
||||||
|
expect(ids).toEqual([startup1.id, startup2.id].sort())
|
||||||
|
// off-category project excluded
|
||||||
|
expect(ids).not.toContain(concept1.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitVote identity resolution', () => {
|
||||||
|
it('lets a participant juror vote without sending any identity', async () => {
|
||||||
|
await jurorCaller.submitVote({ sessionId, projectId: startup1.id, rank: 1 })
|
||||||
|
await jurorCaller.submitVote({ sessionId, projectId: startup2.id, rank: 2 })
|
||||||
|
|
||||||
|
const session = await jurorCaller.getSession({ sessionId })
|
||||||
|
const myVotes = (session.votes ?? []).filter(
|
||||||
|
(v: any) => v.juryMember?.user?.id === juror.id
|
||||||
|
)
|
||||||
|
expect(myVotes).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a juror who is not a participant', async () => {
|
||||||
|
const outsiderCaller = createCaller(deliberationRouter, outsiderJuror)
|
||||||
|
await expect(
|
||||||
|
outsiderCaller.submitVote({ sessionId, projectId: startup1.id, rank: 1 })
|
||||||
|
).rejects.toThrow(/participant/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregates the resolved votes (sanity end-to-end)', async () => {
|
||||||
|
const agg = await adminCaller.aggregate({ sessionId })
|
||||||
|
expect(agg.rankings.length).toBeGreaterThan(0)
|
||||||
|
expect(agg.rankings[0].projectId).toBe(startup1.id) // rank 1 → top Borda points
|
||||||
|
})
|
||||||
|
})
|
||||||
36
tests/unit/external-lunch-token.test.ts
Normal file
36
tests/unit/external-lunch-token.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
import {
|
||||||
|
signExternalLunchToken,
|
||||||
|
verifyExternalLunchToken,
|
||||||
|
} from '../../src/lib/external-lunch-token'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXTAUTH_SECRET = 'test-secret-for-external-lunch-tokens'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('external lunch token', () => {
|
||||||
|
it('round-trips a payload', () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 86400
|
||||||
|
const token = signExternalLunchToken({ externalId: 'cmx_test', exp })
|
||||||
|
const verified = verifyExternalLunchToken(token)
|
||||||
|
expect(verified.externalId).toBe('cmx_test')
|
||||||
|
expect(verified.exp).toBe(exp)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects tampered tokens', () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 86400
|
||||||
|
const token = signExternalLunchToken({ externalId: 'cmx_test', exp })
|
||||||
|
const tampered = token.slice(0, -2) + 'xx'
|
||||||
|
expect(() => verifyExternalLunchToken(tampered)).toThrow(/signature/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects expired tokens', () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) - 1
|
||||||
|
const token = signExternalLunchToken({ externalId: 'cmx_test', exp })
|
||||||
|
expect(() => verifyExternalLunchToken(token)).toThrow(/expired/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed tokens', () => {
|
||||||
|
expect(() => verifyExternalLunchToken('not-a-token')).toThrow(/malformed/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
171
tests/unit/final-documents-curation.test.ts
Normal file
171
tests/unit/final-documents-curation.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, afterAll, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/minio', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/minio')>()
|
||||||
|
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { prisma } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
createTestProjectRoundState,
|
||||||
|
createTestUser,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { listFinalistDocumentsForReview, listReviewVisibilityOptions } from '@/server/services/final-documents'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of programIds) await cleanupTestData(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One finalist team with 4 files:
|
||||||
|
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
|
||||||
|
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
|
||||||
|
* - loose.pdf (prior SUBMISSION round, NO requirement)
|
||||||
|
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
|
||||||
|
*/
|
||||||
|
async function setupCuration() {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
programIds.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
|
||||||
|
const reqBP = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
|
||||||
|
})
|
||||||
|
const reqDeck = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
|
||||||
|
})
|
||||||
|
const finale = await createTestRound(comp.id, {
|
||||||
|
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
|
||||||
|
windowCloseAt: new Date(Date.now() + 86_400_000),
|
||||||
|
configJson: { allowFinalistRevisedUploads: true },
|
||||||
|
})
|
||||||
|
const reqFinal = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
|
||||||
|
})
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, finale.id)
|
||||||
|
|
||||||
|
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
|
||||||
|
prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
id: uid('file'), projectId: project.id, roundId, requirementId,
|
||||||
|
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
|
||||||
|
bucket: 'b', objectKey: uid('key'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
|
||||||
|
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
|
||||||
|
await mkFile(priorRound.id, null, 'loose.pdf')
|
||||||
|
await mkFile(finale.id, reqFinal.id, 'final.mp4')
|
||||||
|
|
||||||
|
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSelection(roundId: string, ids: string[] | null) {
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
|
||||||
|
const cfg = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
if (ids === null) delete cfg.reviewVisibleRequirementIds
|
||||||
|
else cfg.reviewVisibleRequirementIds = ids
|
||||||
|
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('listFinalistDocumentsForReview curation', () => {
|
||||||
|
it('no selection key → all files visible (current behavior)', async () => {
|
||||||
|
const { program } = await setupCuration()
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
expect(result.teams).toHaveLength(1)
|
||||||
|
expect(result.teams[0].files).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selection → only matching prior files, finale uploads always visible', async () => {
|
||||||
|
const { program, finale, reqBP } = await setupCuration()
|
||||||
|
await setSelection(finale.id, [reqBP.id])
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
const names = result.teams[0].files.map((f) => f.fileName).sort()
|
||||||
|
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty selection → only finale uploads visible', async () => {
|
||||||
|
const { program, finale } = await setupCuration()
|
||||||
|
await setSelection(finale.id, [])
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prior file without a requirement is excluded under any selection', async () => {
|
||||||
|
const { program, finale, reqBP, reqDeck } = await setupCuration()
|
||||||
|
await setSelection(finale.id, [reqBP.id, reqDeck.id])
|
||||||
|
const result = await listFinalistDocumentsForReview(prisma, program.id)
|
||||||
|
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listReviewVisibilityOptions', () => {
|
||||||
|
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
|
||||||
|
const { program, reqBP, reqDeck } = await setupCuration()
|
||||||
|
const options = await listReviewVisibilityOptions(prisma, program.id)
|
||||||
|
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
|
||||||
|
const bp = options.find((o) => o.requirementId === reqBP.id)!
|
||||||
|
expect(bp.name).toBe('Business Plan')
|
||||||
|
expect(bp.fileCount).toBe(1)
|
||||||
|
expect(bp.roundName).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns [] when there is no open finale round', async () => {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
programIds.push(program.id)
|
||||||
|
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finalist review-doc settings procedures', () => {
|
||||||
|
const userIds: string[] = []
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of programIds) await cleanupTestData(id, userIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips a selection and preserves sibling configJson keys', async () => {
|
||||||
|
const { program, finale, reqBP } = await setupCuration()
|
||||||
|
const admin = await createTestUser('PROGRAM_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const { finalistRouter } = await import('@/server/routers/finalist')
|
||||||
|
const { createCaller } = await import('../setup')
|
||||||
|
const caller = createCaller(finalistRouter, admin)
|
||||||
|
|
||||||
|
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||||
|
expect(initial.selectedIds).toBeNull()
|
||||||
|
expect(initial.options.length).toBe(2)
|
||||||
|
|
||||||
|
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
|
||||||
|
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||||
|
expect(curated.selectedIds).toEqual([reqBP.id])
|
||||||
|
|
||||||
|
// sibling key from setupCuration must survive
|
||||||
|
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
|
||||||
|
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
|
||||||
|
|
||||||
|
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
|
||||||
|
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
|
||||||
|
expect(cleared.selectedIds).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-LIVE_FINAL round', async () => {
|
||||||
|
const { program, priorRound } = await setupCuration()
|
||||||
|
const admin = await createTestUser('PROGRAM_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
const { finalistRouter } = await import('@/server/routers/finalist')
|
||||||
|
const { createCaller } = await import('../setup')
|
||||||
|
const caller = createCaller(finalistRouter, admin)
|
||||||
|
await expect(
|
||||||
|
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
|
||||||
|
).rejects.toThrow()
|
||||||
|
void program
|
||||||
|
})
|
||||||
|
})
|
||||||
361
tests/unit/final-documents.test.ts
Normal file
361
tests/unit/final-documents.test.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { prisma } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
createTestProjectRoundState,
|
||||||
|
createTestUser,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import {
|
||||||
|
getFinalDocumentStatusForProject,
|
||||||
|
sendManualFinalDocReminders,
|
||||||
|
sendDueFinalDocReminders,
|
||||||
|
} from '@/server/services/final-documents'
|
||||||
|
import * as applicantRouter from '@/server/routers/applicant'
|
||||||
|
import * as finalistRouter from '@/server/routers/finalist'
|
||||||
|
import * as mentorRouter from '@/server/routers/mentor'
|
||||||
|
import { createCaller } from '../setup'
|
||||||
|
import { BUCKET_NAME, generateObjectKey } from '@/lib/minio'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
|
||||||
|
async function makeFinaleProgram(
|
||||||
|
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
programIds.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
status: opts.roundStatus ?? 'ROUND_ACTIVE',
|
||||||
|
sortOrder: 6,
|
||||||
|
windowCloseAt: opts.closeAt ?? new Date(Date.now() + 86_400_000),
|
||||||
|
configJson: { allowFinalistRevisedUploads: opts.uploadsEnabled ?? true },
|
||||||
|
})
|
||||||
|
if (opts.skipRequirements) {
|
||||||
|
return { program, comp, round, reqPlan: undefined, reqVideo: undefined }
|
||||||
|
}
|
||||||
|
const reqPlan = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: !opts.optionalRequirements, sortOrder: 1 },
|
||||||
|
})
|
||||||
|
const reqVideo = await prisma.fileRequirement.create({
|
||||||
|
data: { id: uid('req'), roundId: round.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: !opts.optionalRequirements, sortOrder: 2 },
|
||||||
|
})
|
||||||
|
return { program, comp, round, reqPlan, reqVideo }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getFinalDocumentStatusForProject', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of programIds) await cleanupTestData(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the project is not enrolled in the active LIVE_FINAL round', async () => {
|
||||||
|
const { program } = await makeFinaleProgram()
|
||||||
|
const orphan = await createTestProject(program.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, orphan.id)
|
||||||
|
expect(status).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns per-requirement status with none uploaded', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram()
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status).not.toBeNull()
|
||||||
|
expect(status!.requirements).toHaveLength(2)
|
||||||
|
expect(status!.requirements.every((r) => !r.uploaded)).toBe(true)
|
||||||
|
expect(status!.allRequiredUploaded).toBe(false)
|
||||||
|
expect(status!.deadline?.toISOString()).toBe(round.windowCloseAt!.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a requirement uploaded and flips allRequiredUploaded when all present', async () => {
|
||||||
|
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram()
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
for (const [req, type, mime] of [
|
||||||
|
[reqPlan!, 'BUSINESS_PLAN', 'application/pdf'],
|
||||||
|
[reqVideo!, 'VIDEO', 'video/mp4'],
|
||||||
|
] as const) {
|
||||||
|
await prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
|
||||||
|
fileType: type as any, fileName: `f-${req.id}`, mimeType: mime, size: 10,
|
||||||
|
bucket: 'b', objectKey: uid('key'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status!.requirements.every((r) => r.uploaded)).toBe(true)
|
||||||
|
expect(status!.allRequiredUploaded).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when the LIVE_FINAL round is DRAFT (pre-event — documents open before the round opens)', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ roundStatus: 'ROUND_DRAFT' })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status).not.toBeNull()
|
||||||
|
expect(status!.roundId).toBe(round.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the LIVE_FINAL round is CLOSED', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ roundStatus: 'ROUND_CLOSED' })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the admin has NOT enabled revised uploads (toggle off)', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ uploadsEnabled: false })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports allRequiredUploaded false when the round has no required requirements', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status).not.toBeNull()
|
||||||
|
expect(status!.requirements).toHaveLength(0)
|
||||||
|
expect(status!.allRequiredUploaded).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
|
||||||
|
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
|
||||||
|
const before = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(before!.hasRequired).toBe(false)
|
||||||
|
expect(before!.allUploaded).toBe(false)
|
||||||
|
expect(before!.allRequiredUploaded).toBe(false)
|
||||||
|
|
||||||
|
for (const req of [reqPlan!, reqVideo!]) {
|
||||||
|
await prisma.projectFile.create({
|
||||||
|
data: {
|
||||||
|
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
|
||||||
|
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
|
||||||
|
bucket: 'b', objectKey: uid('key'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const after = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(after!.hasRequired).toBe(false)
|
||||||
|
expect(after!.allUploaded).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
|
||||||
|
const { program, round, reqPlan } = await makeFinaleProgram()
|
||||||
|
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status!.hasRequired).toBe(true) // reqVideo still required
|
||||||
|
expect(status!.allUploaded).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
|
||||||
|
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const status = await getFinalDocumentStatusForProject(prisma, project.id)
|
||||||
|
expect(status!.hasRequired).toBe(false)
|
||||||
|
expect(status!.allUploaded).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applicant.getFinalDocumentStatus', () => {
|
||||||
|
const localPrograms: string[] = []
|
||||||
|
const localUsers: string[] = []
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of localPrograms) await cleanupTestData(id, localUsers)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the status for the caller\'s enrolled finalist project', async () => {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
|
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const user = await createTestUser('APPLICANT')
|
||||||
|
localUsers.push(user.id)
|
||||||
|
await prisma.teamMember.create({ data: { projectId: project.id, userId: user.id, role: 'LEAD' } })
|
||||||
|
|
||||||
|
const caller = createCaller(applicantRouter.applicantRouter, user)
|
||||||
|
const status = await caller.getFinalDocumentStatus()
|
||||||
|
expect(status?.roundId).toBe(round.id)
|
||||||
|
expect(status?.requirements).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the caller has no project', async () => {
|
||||||
|
const user = await createTestUser('APPLICANT')
|
||||||
|
localUsers.push(user.id)
|
||||||
|
const caller = createCaller(applicantRouter.applicantRouter, user)
|
||||||
|
expect(await caller.getFinalDocumentStatus()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendManualFinalDocReminders', () => {
|
||||||
|
const localPrograms: string[] = []
|
||||||
|
const localUsers: string[] = []
|
||||||
|
afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) })
|
||||||
|
|
||||||
|
it('sends a reminder only to finalist teams with missing required docs', async () => {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
|
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const lead = await createTestUser('APPLICANT')
|
||||||
|
localUsers.push(lead.id)
|
||||||
|
await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' } })
|
||||||
|
|
||||||
|
const result = await sendManualFinalDocReminders(prisma, { programId: program.id, actorId: lead.id })
|
||||||
|
expect(result.sent).toBe(1)
|
||||||
|
const notif = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'GRAND_FINAL_DOCS_REMINDER' } })
|
||||||
|
expect(notif).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendDueFinalDocReminders', () => {
|
||||||
|
const localPrograms: string[] = []
|
||||||
|
const localUsers: string[] = []
|
||||||
|
afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) })
|
||||||
|
|
||||||
|
it('reminds once when within the window and stamps finalDocsReminderSentAt', async () => {
|
||||||
|
const program = await createTestProgram(); localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, {
|
||||||
|
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
|
||||||
|
windowCloseAt: new Date(Date.now() + 3_600_000), // 1h out → within 48h window
|
||||||
|
configJson: { finalDocsReminderHoursBeforeDeadline: 48, allowFinalistRevisedUploads: true },
|
||||||
|
})
|
||||||
|
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const lead = await createTestUser('APPLICANT'); localUsers.push(lead.id)
|
||||||
|
await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' } })
|
||||||
|
await prisma.finalistConfirmation.create({ data: { id: uid('fc'), projectId: project.id, status: 'CONFIRMED', category: 'STARTUP', deadline: new Date(Date.now() + 3_600_000), token: uid('tok') } })
|
||||||
|
|
||||||
|
const first = await sendDueFinalDocReminders(prisma)
|
||||||
|
expect(first.remindersSent).toBe(1)
|
||||||
|
const second = await sendDueFinalDocReminders(prisma)
|
||||||
|
expect(second.remindersSent).toBe(0) // idempotent: already stamped
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finalist.listReviewDocuments', () => {
|
||||||
|
const localPrograms: string[] = []
|
||||||
|
const localUsers: string[] = []
|
||||||
|
afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) })
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const program = await createTestProgram()
|
||||||
|
localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const jg = await prisma.juryGroup.create({ data: { id: uid('jg'), competitionId: comp.id, name: 'Finals Jury', slug: uid('jg') } })
|
||||||
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
|
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: jg.id } })
|
||||||
|
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
||||||
|
const project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
return { program, comp, jg, round, project }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('admin sees all finalist teams', async () => {
|
||||||
|
const { program } = await setup()
|
||||||
|
const admin = await createTestUser('PROGRAM_ADMIN'); localUsers.push(admin.id)
|
||||||
|
const caller = createCaller(finalistRouter.finalistRouter, admin)
|
||||||
|
const result = await caller.listReviewDocuments({ programId: program.id })
|
||||||
|
expect(result.teams).toHaveLength(1)
|
||||||
|
expect(result.totalCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a finals jury-group member is allowed', async () => {
|
||||||
|
const { program, jg } = await setup()
|
||||||
|
const juror = await createTestUser('JURY_MEMBER'); localUsers.push(juror.id)
|
||||||
|
await prisma.juryGroupMember.create({ data: { juryGroupId: jg.id, userId: juror.id, role: 'MEMBER' } })
|
||||||
|
const caller = createCaller(finalistRouter.finalistRouter, juror)
|
||||||
|
const result = await caller.listReviewDocuments({ programId: program.id })
|
||||||
|
expect(result.teams).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a non-finals jury member is forbidden', async () => {
|
||||||
|
const { program } = await setup()
|
||||||
|
const juror = await createTestUser('JURY_MEMBER'); localUsers.push(juror.id)
|
||||||
|
const caller = createCaller(finalistRouter.finalistRouter, juror)
|
||||||
|
await expect(caller.listReviewDocuments({ programId: program.id })).rejects.toThrow(TRPCError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mentor.getProjectFinalDocuments', () => {
|
||||||
|
const localPrograms: string[] = []
|
||||||
|
const localUsers: string[] = []
|
||||||
|
afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) })
|
||||||
|
|
||||||
|
it('returns status for a project the mentor is assigned to', async () => {
|
||||||
|
const program = await createTestProgram(); localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6, windowCloseAt: new Date(Date.now() + 86_400_000), configJson: { allowFinalistRevisedUploads: true } })
|
||||||
|
await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const mentor = await createTestUser('MENTOR'); localUsers.push(mentor.id)
|
||||||
|
await prisma.mentorAssignment.create({ data: { projectId: project.id, mentorId: mentor.id } })
|
||||||
|
|
||||||
|
const caller = createCaller(mentorRouter.mentorRouter, mentor)
|
||||||
|
const status = await caller.getProjectFinalDocuments({ projectId: project.id })
|
||||||
|
expect(status?.roundId).toBe(round.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forbids a mentor not assigned to the project', async () => {
|
||||||
|
const program = await createTestProgram(); localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6 })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const mentor = await createTestUser('MENTOR'); localUsers.push(mentor.id)
|
||||||
|
const caller = createCaller(mentorRouter.mentorRouter, mentor)
|
||||||
|
await expect(caller.getProjectFinalDocuments({ projectId: project.id })).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveFileMetadata → GRAND_FINAL_DOCS_SUBMITTED', () => {
|
||||||
|
const localPrograms: string[] = []
|
||||||
|
const localUsers: string[] = []
|
||||||
|
afterAll(async () => { for (const id of localPrograms) await cleanupTestData(id, localUsers) })
|
||||||
|
|
||||||
|
it('notifies the mentor when a finalist uploads a LIVE_FINAL document', async () => {
|
||||||
|
const program = await createTestProgram(); localPrograms.push(program.id)
|
||||||
|
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
||||||
|
const round = await createTestRound(comp.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6 })
|
||||||
|
const req = await prisma.fileRequirement.create({ data: { id: uid('req'), roundId: round.id, name: 'Executive Summary', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 } })
|
||||||
|
const project = await createTestProject(program.id)
|
||||||
|
await createTestProjectRoundState(project.id, round.id)
|
||||||
|
const lead = await createTestUser('APPLICANT'); localUsers.push(lead.id)
|
||||||
|
await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' } })
|
||||||
|
const mentor = await createTestUser('MENTOR'); localUsers.push(mentor.id)
|
||||||
|
await prisma.mentorAssignment.create({ data: { projectId: project.id, mentorId: mentor.id } })
|
||||||
|
|
||||||
|
const caller = createCaller(applicantRouter.applicantRouter, lead)
|
||||||
|
await caller.saveFileMetadata({
|
||||||
|
projectId: project.id, fileName: 'exec.pdf', mimeType: 'application/pdf', size: 100,
|
||||||
|
fileType: 'EXEC_SUMMARY', bucket: BUCKET_NAME, objectKey: generateObjectKey(project.title, 'exec.pdf'), roundId: round.id, requirementId: req.id,
|
||||||
|
})
|
||||||
|
const notif = await prisma.inAppNotification.findFirst({ where: { userId: mentor.id, type: 'GRAND_FINAL_DOCS_SUBMITTED' } })
|
||||||
|
expect(notif).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
246
tests/unit/live-phase.test.ts
Normal file
246
tests/unit/live-phase.test.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Grand-finale per-project phase machine on LiveProgressCursor:
|
||||||
|
* ON_DECK → PRESENTING → QA → SCORING, with server-stamped timers,
|
||||||
|
* pause/resume accumulator math, an overtime timing log, big-screen
|
||||||
|
* override slides, and persisted juror notes.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
} from '../helpers'
|
||||||
|
import { liveRouter } from '@/server/routers/live'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let round: any
|
||||||
|
let p1: any
|
||||||
|
let p2: any
|
||||||
|
let admin: any
|
||||||
|
let juror: any
|
||||||
|
let adminCaller: ReturnType<typeof createCaller>
|
||||||
|
let jurorCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
round = await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
configJson: { presentationDurationMinutes: 2, qaDurationMinutes: 1 },
|
||||||
|
})
|
||||||
|
p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
p2 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
|
||||||
|
admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
juror = await createTestUser('JURY_MEMBER')
|
||||||
|
adminCaller = createCaller(liveRouter, admin)
|
||||||
|
jurorCaller = createCaller(liveRouter, juror)
|
||||||
|
|
||||||
|
await adminCaller.start({ roundId: round.id, projectOrder: [p1.id, p2.id] })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [admin.id, juror.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('phase transitions', () => {
|
||||||
|
it('sendToScreens puts a project ON_DECK with no timer', async () => {
|
||||||
|
const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
|
||||||
|
expect(cursor.projectPhase).toBe('ON_DECK')
|
||||||
|
expect(cursor.activeProjectId).toBe(p1.id)
|
||||||
|
expect(cursor.phaseStartedAt).toBeNull()
|
||||||
|
expect(cursor.phaseDurationSeconds).toBeNull()
|
||||||
|
expect(cursor.overrideSlide).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects sendToScreens for a project outside the order', async () => {
|
||||||
|
await expect(
|
||||||
|
adminCaller.sendToScreens({ roundId: round.id, projectId: 'nope' })
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startPresentation stamps the timer with config default duration', async () => {
|
||||||
|
const cursor = await adminCaller.startPresentation({ roundId: round.id })
|
||||||
|
expect(cursor.projectPhase).toBe('PRESENTING')
|
||||||
|
expect(cursor.phaseStartedAt).not.toBeNull()
|
||||||
|
expect(cursor.phaseDurationSeconds).toBe(120) // presentationDurationMinutes: 2
|
||||||
|
expect(cursor.phasePausedAccumMs).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pause/resume folds pause time into the accumulator', async () => {
|
||||||
|
const paused = await adminCaller.pausePhase({ roundId: round.id })
|
||||||
|
expect(paused.phasePausedAt).not.toBeNull()
|
||||||
|
|
||||||
|
// pausing twice errors
|
||||||
|
await expect(adminCaller.pausePhase({ roundId: round.id })).rejects.toThrow()
|
||||||
|
|
||||||
|
// backdate the pause so the accumulator visibly grows
|
||||||
|
await prisma.liveProgressCursor.update({
|
||||||
|
where: { roundId: round.id },
|
||||||
|
data: { phasePausedAt: new Date(Date.now() - 5_000) },
|
||||||
|
})
|
||||||
|
const resumed = await adminCaller.resumePhase({ roundId: round.id })
|
||||||
|
expect(resumed.phasePausedAt).toBeNull()
|
||||||
|
expect(resumed.phasePausedAccumMs).toBeGreaterThanOrEqual(5_000)
|
||||||
|
|
||||||
|
// resuming while not paused errors
|
||||||
|
await expect(adminCaller.resumePhase({ roundId: round.id })).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startQA logs the presentation with overtime and starts the QA timer', async () => {
|
||||||
|
// Backdate the presentation start so it overran its 120s budget
|
||||||
|
await prisma.liveProgressCursor.update({
|
||||||
|
where: { roundId: round.id },
|
||||||
|
data: { phaseStartedAt: new Date(Date.now() - 200_000), phasePausedAccumMs: 0 },
|
||||||
|
})
|
||||||
|
const cursor = await adminCaller.startQA({ roundId: round.id, durationSeconds: 30 })
|
||||||
|
expect(cursor.projectPhase).toBe('QA')
|
||||||
|
expect(cursor.phaseDurationSeconds).toBe(30)
|
||||||
|
|
||||||
|
const log = cursor.timingLogJson as Array<any>
|
||||||
|
expect(log).toHaveLength(1)
|
||||||
|
expect(log[0].projectId).toBe(p1.id)
|
||||||
|
expect(log[0].phase).toBe('PRESENTING')
|
||||||
|
expect(log[0].configuredSeconds).toBe(120)
|
||||||
|
expect(log[0].overranSeconds).toBeGreaterThanOrEqual(79) // ~200s elapsed vs 120s budget
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openScoring logs the QA phase and clears the timer', async () => {
|
||||||
|
const cursor = await adminCaller.openScoring({ roundId: round.id })
|
||||||
|
expect(cursor.projectPhase).toBe('SCORING')
|
||||||
|
expect(cursor.phaseStartedAt).toBeNull()
|
||||||
|
const log = cursor.timingLogJson as Array<any>
|
||||||
|
expect(log).toHaveLength(2)
|
||||||
|
expect(log[1].phase).toBe('QA')
|
||||||
|
expect(log[1].configuredSeconds).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sending the next project keeps the timing log', async () => {
|
||||||
|
const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id })
|
||||||
|
expect(cursor.activeProjectId).toBe(p2.id)
|
||||||
|
expect(cursor.projectPhase).toBe('ON_DECK')
|
||||||
|
expect((cursor.timingLogJson as Array<any>).length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setOverrideSlide sets and clears the big-screen override', async () => {
|
||||||
|
const set = await adminCaller.setOverrideSlide({ roundId: round.id, slide: 'break' })
|
||||||
|
expect(set.overrideSlide).toBe('break')
|
||||||
|
const cleared = await adminCaller.setOverrideSlide({ roundId: round.id, slide: null })
|
||||||
|
expect(cleared.overrideSlide).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getCursor exposes phase fields and ordered projects with categories', async () => {
|
||||||
|
const cursor = await jurorCaller.getCursor({ roundId: round.id })
|
||||||
|
expect(cursor?.projectPhase).toBe('ON_DECK')
|
||||||
|
expect(cursor?.orderedProjects?.map((p: any) => p.id)).toEqual([p1.id, p2.id])
|
||||||
|
expect(cursor?.orderedProjects?.[0]?.competitionCategory).toBe('STARTUP')
|
||||||
|
expect(cursor?.activeProject?.competitionCategory).toBe('BUSINESS_CONCEPT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('phase mutations are admin-only', async () => {
|
||||||
|
await expect(
|
||||||
|
jurorCaller.startPresentation({ roundId: round.id })
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('voting session sync', () => {
|
||||||
|
it('sendToScreens points the voting session at the project and activates it', async () => {
|
||||||
|
const session = await prisma.liveVotingSession.create({
|
||||||
|
data: { roundId: round.id, status: 'NOT_STARTED' },
|
||||||
|
})
|
||||||
|
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
|
||||||
|
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
|
where: { id: session.id },
|
||||||
|
})
|
||||||
|
expect(updated.currentProjectId).toBe(p1.id)
|
||||||
|
expect(updated.status).toBe('IN_PROGRESS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startPresentation re-syncs a session that drifted out of band', async () => {
|
||||||
|
await prisma.liveVotingSession.update({
|
||||||
|
where: { roundId: round.id },
|
||||||
|
data: { status: 'NOT_STARTED', currentProjectId: null },
|
||||||
|
})
|
||||||
|
await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 60 })
|
||||||
|
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
|
||||||
|
where: { roundId: round.id },
|
||||||
|
})
|
||||||
|
expect(updated.currentProjectId).toBe(p1.id)
|
||||||
|
expect(updated.status).toBe('IN_PROGRESS')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('per-project timing overrides', () => {
|
||||||
|
it('setProjectTiming stores per-project durations in round config', async () => {
|
||||||
|
await adminCaller.setProjectTiming({
|
||||||
|
roundId: round.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
presentationSeconds: 480,
|
||||||
|
qaSeconds: 90,
|
||||||
|
})
|
||||||
|
const r = await prisma.round.findUniqueOrThrow({ where: { id: round.id } })
|
||||||
|
const overrides = (r.configJson as any).projectTimingOverrides
|
||||||
|
expect(overrides[p1.id]).toEqual({ presentationSeconds: 480, qaSeconds: 90 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('startPresentation/startQA use the project override over the config default', async () => {
|
||||||
|
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
|
||||||
|
const pres = await adminCaller.startPresentation({ roundId: round.id })
|
||||||
|
expect(pres.phaseDurationSeconds).toBe(480) // override, not the 120s config default
|
||||||
|
const qa = await adminCaller.startQA({ roundId: round.id })
|
||||||
|
expect(qa.phaseDurationSeconds).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an explicit durationSeconds input still wins over the project override', async () => {
|
||||||
|
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
|
||||||
|
const pres = await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 33 })
|
||||||
|
expect(pres.phaseDurationSeconds).toBe(33)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects without an override keep the config default', async () => {
|
||||||
|
await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id })
|
||||||
|
const pres = await adminCaller.startPresentation({ roundId: round.id })
|
||||||
|
expect(pres.phaseDurationSeconds).toBe(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearing an override falls back to defaults', async () => {
|
||||||
|
await adminCaller.setProjectTiming({
|
||||||
|
roundId: round.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
presentationSeconds: null,
|
||||||
|
qaSeconds: null,
|
||||||
|
})
|
||||||
|
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
|
||||||
|
const pres = await adminCaller.startPresentation({ roundId: round.id })
|
||||||
|
expect(pres.phaseDurationSeconds).toBe(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getCursor exposes the overrides for the admin UI', async () => {
|
||||||
|
await adminCaller.setProjectTiming({ roundId: round.id, projectId: p2.id, qaSeconds: 240 })
|
||||||
|
const cursor = await adminCaller.getCursor({ roundId: round.id })
|
||||||
|
expect(cursor?.projectTimingOverrides?.[p2.id]?.qaSeconds).toBe(240)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('juror notes', () => {
|
||||||
|
it('saveNote upserts one note per (round, project, juror)', async () => {
|
||||||
|
await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'first draft' })
|
||||||
|
await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'revised' })
|
||||||
|
await jurorCaller.saveNote({ roundId: round.id, projectId: p2.id, content: 'other project' })
|
||||||
|
|
||||||
|
const notes = await jurorCaller.getMyNotes({ roundId: round.id })
|
||||||
|
expect(notes).toHaveLength(2)
|
||||||
|
const n1 = notes.find((n: any) => n.projectId === p1.id)
|
||||||
|
expect(n1?.content).toBe('revised')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getMyNotes only returns the caller’s notes', async () => {
|
||||||
|
const adminNotes = await adminCaller.getMyNotes({ roundId: round.id })
|
||||||
|
expect(adminNotes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
203
tests/unit/live-results-tally.test.ts
Normal file
203
tests/unit/live-results-tally.test.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Tally audit — these numbers go on a projector. Pins:
|
||||||
|
* - criteria-mode weighted score computation on vote submit (hand-computed)
|
||||||
|
* - getResults jury aggregation + ordering
|
||||||
|
* - tie detection
|
||||||
|
* - audience favorite tallies stay separate from jury scores (separate awards)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { liveVotingRouter } from '@/server/routers/live-voting'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let round: any
|
||||||
|
let session: any
|
||||||
|
let p1: any
|
||||||
|
let p2: any
|
||||||
|
let jurorA: any
|
||||||
|
let jurorB: any
|
||||||
|
let admin: any
|
||||||
|
let callerA: ReturnType<typeof createCaller>
|
||||||
|
let callerB: ReturnType<typeof createCaller>
|
||||||
|
let adminCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
const CRITERIA = [
|
||||||
|
{ id: 'impact', label: 'Impact', scale: 10, weight: 0.6 },
|
||||||
|
{ id: 'feasibility', label: 'Feasibility', scale: 5, weight: 0.4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
const juryGroup = await prisma.juryGroup.create({
|
||||||
|
data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') },
|
||||||
|
})
|
||||||
|
round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' })
|
||||||
|
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: juryGroup.id } })
|
||||||
|
p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
p2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
await prisma.round.update({
|
||||||
|
where: { id: round.id },
|
||||||
|
data: {
|
||||||
|
configJson: {
|
||||||
|
projectOrder: [p1.id, p2.id],
|
||||||
|
presentationDurationMinutes: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
jurorA = await createTestUser('JURY_MEMBER')
|
||||||
|
jurorB = await createTestUser('JURY_MEMBER')
|
||||||
|
admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
for (const j of [jurorA, jurorB]) {
|
||||||
|
await prisma.juryGroupMember.create({
|
||||||
|
data: { juryGroupId: juryGroup.id, userId: j.id, role: 'MEMBER' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
session = await prisma.liveVotingSession.create({
|
||||||
|
data: {
|
||||||
|
roundId: round.id,
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
currentProjectId: p1.id,
|
||||||
|
votingMode: 'criteria',
|
||||||
|
criteriaJson: CRITERIA,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
callerA = createCaller(liveVotingRouter, jurorA)
|
||||||
|
callerB = createCaller(liveVotingRouter, jurorB)
|
||||||
|
adminCaller = createCaller(liveVotingRouter, admin)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [jurorA.id, jurorB.id, admin.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('criteria-mode weighted score', () => {
|
||||||
|
it('computes the documented weighted normalization on submit', async () => {
|
||||||
|
// impact 8/10 → 8.0 normalized ·0.6 = 4.8 ; feasibility 4/5 → 8.0 ·0.4 = 3.2
|
||||||
|
// weighted sum = 8.0 → stored score 8
|
||||||
|
const vote = await callerA.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
score: 1, // ignored in criteria mode — recomputed server-side
|
||||||
|
criterionScores: { impact: 8, feasibility: 4 },
|
||||||
|
})
|
||||||
|
expect(vote.score).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an out-of-scale criterion score', async () => {
|
||||||
|
await expect(
|
||||||
|
callerA.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 11, feasibility: 4 },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/between 1 and/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a missing criterion', async () => {
|
||||||
|
await expect(
|
||||||
|
callerA.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 8 },
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Missing score/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getResults aggregation', () => {
|
||||||
|
it('averages jury votes per project and orders by weighted total', async () => {
|
||||||
|
// jurorB scores p1: impact 4/10 → 2.4, feasibility 2/5 → 1.6 → 4
|
||||||
|
await callerB.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 4, feasibility: 2 },
|
||||||
|
})
|
||||||
|
// both jurors score p2 (revision path: p2 is in the order)
|
||||||
|
// jurorA: impact 10/10 → 6.0, feasibility 5/5 → 4.0 → 10
|
||||||
|
await callerA.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p2.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 10, feasibility: 5 },
|
||||||
|
})
|
||||||
|
// jurorB: impact 6/10 → 3.6, feasibility 3/5 → 2.4 → 6
|
||||||
|
await callerB.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p2.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 6, feasibility: 3 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await adminCaller.getResults({ sessionId: session.id })
|
||||||
|
// p1: (8+4)/2 = 6 ; p2: (10+6)/2 = 8 → p2 first
|
||||||
|
expect(res.results[0].project?.id).toBe(p2.id)
|
||||||
|
expect(res.results[0].juryAverage).toBe(8)
|
||||||
|
expect(res.results[0].juryVoteCount).toBe(2)
|
||||||
|
expect(res.results[1].project?.id).toBe(p1.id)
|
||||||
|
expect(res.results[1].juryAverage).toBe(6)
|
||||||
|
// audience weight defaults to 0 → weighted total equals jury average
|
||||||
|
expect(res.results[0].weightedTotal).toBe(8)
|
||||||
|
expect(res.weights.audience).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects ties', async () => {
|
||||||
|
// pull p1 up to match p2's average: change jurorB's p1 vote to 10 → (8+10)/2 = 9? no.
|
||||||
|
// make both averages equal: set jurorA p1 → 10 (impact 10, feas 5) → p1 avg (10+4)/2 = 7
|
||||||
|
// and jurorA p2 → 8 (impact 8, feas 4) → p2 avg (8+6)/2 = 7
|
||||||
|
await callerA.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p1.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 10, feasibility: 5 },
|
||||||
|
})
|
||||||
|
await callerA.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: p2.id,
|
||||||
|
score: 1,
|
||||||
|
criterionScores: { impact: 8, feasibility: 4 },
|
||||||
|
})
|
||||||
|
const res = await adminCaller.getResults({ sessionId: session.id })
|
||||||
|
expect(res.results[0].weightedTotal).toBe(res.results[1].weightedTotal)
|
||||||
|
expect(res.ties.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('audience favorites stay separate', () => {
|
||||||
|
it('favorite tallies do not touch jury weighted totals', async () => {
|
||||||
|
await prisma.liveVotingSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { allowAudienceVotes: true },
|
||||||
|
})
|
||||||
|
await adminCaller.openAudienceWindow({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:STARTUP',
|
||||||
|
durationMinutes: 5,
|
||||||
|
})
|
||||||
|
const reg = await adminCaller.registerAudienceVoter({ sessionId: session.id })
|
||||||
|
await adminCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: reg.token,
|
||||||
|
projectId: p1.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id })
|
||||||
|
expect(tallies.windows[0].totalVotes).toBe(1)
|
||||||
|
|
||||||
|
const res = await adminCaller.getResults({ sessionId: session.id })
|
||||||
|
// favorite votes are NOT LiveVotes — jury aggregation unchanged
|
||||||
|
expect(res.results.every((r: any) => r.audienceVoteCount === 0)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
91
tests/unit/live-timer.test.ts
Normal file
91
tests/unit/live-timer.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Server-stamped phase timer math: every screen (admin, jury, big screen)
|
||||||
|
* derives the same countdown from cursor timestamps — no client-local clocks.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { elapsedMs, remainingSeconds, formatClock, parseClock } from '@/lib/live-timer'
|
||||||
|
|
||||||
|
const t0 = new Date('2026-06-11T10:00:00Z')
|
||||||
|
const at = (s: number) => new Date(t0.getTime() + s * 1000)
|
||||||
|
|
||||||
|
describe('live-timer', () => {
|
||||||
|
it('elapsedMs counts from phaseStartedAt', () => {
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 0 },
|
||||||
|
at(90)
|
||||||
|
)
|
||||||
|
).toBe(90_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('elapsedMs freezes while paused and subtracts accumulated pause', () => {
|
||||||
|
// paused at +60s, asked at +90s → frozen at 60s
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: at(60), phasePausedAccumMs: 0 },
|
||||||
|
at(90)
|
||||||
|
)
|
||||||
|
).toBe(60_000)
|
||||||
|
// resumed with 30s pause accumulated, asked at +120s → 90s elapsed
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 30_000 },
|
||||||
|
at(120)
|
||||||
|
)
|
||||||
|
).toBe(90_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('elapsedMs accepts ISO strings (serialized cursor)', () => {
|
||||||
|
expect(
|
||||||
|
elapsedMs(
|
||||||
|
{
|
||||||
|
phaseStartedAt: t0.toISOString(),
|
||||||
|
phaseDurationSeconds: 300,
|
||||||
|
phasePausedAt: null,
|
||||||
|
phasePausedAccumMs: 0,
|
||||||
|
},
|
||||||
|
at(45)
|
||||||
|
)
|
||||||
|
).toBe(45_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remainingSeconds goes negative on overtime', () => {
|
||||||
|
expect(
|
||||||
|
remainingSeconds(
|
||||||
|
{ phaseStartedAt: t0, phaseDurationSeconds: 60, phasePausedAt: null, phasePausedAccumMs: 0 },
|
||||||
|
at(75)
|
||||||
|
)
|
||||||
|
).toBe(-15)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remainingSeconds is null without a running timer', () => {
|
||||||
|
expect(
|
||||||
|
remainingSeconds(
|
||||||
|
{ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 },
|
||||||
|
t0
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatClock renders m:ss and overtime as +m:ss', () => {
|
||||||
|
expect(formatClock(305)).toBe('5:05')
|
||||||
|
expect(formatClock(0)).toBe('0:00')
|
||||||
|
expect(formatClock(-83)).toBe('+1:23')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parseClock accepts m:ss, mm:ss and plain minutes', () => {
|
||||||
|
expect(parseClock('7:30')).toBe(450)
|
||||||
|
expect(parseClock('12:05')).toBe(725)
|
||||||
|
expect(parseClock('0:45')).toBe(45)
|
||||||
|
expect(parseClock('7')).toBe(420) // plain minutes
|
||||||
|
expect(parseClock(' 3:00 ')).toBe(180) // tolerant of whitespace
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parseClock rejects garbage', () => {
|
||||||
|
expect(parseClock('')).toBeNull()
|
||||||
|
expect(parseClock('abc')).toBeNull()
|
||||||
|
expect(parseClock('5:75')).toBeNull() // seconds must be 0-59
|
||||||
|
expect(parseClock('-2:00')).toBeNull()
|
||||||
|
expect(parseClock('1:2:3')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
170
tests/unit/live-vote-comment.test.ts
Normal file
170
tests/unit/live-vote-comment.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Grand-finale juror voting extras:
|
||||||
|
* - optional overall comment stored with the LiveVote (and updatable)
|
||||||
|
* - getSessionForVotingByRound: the jury page only knows the roundId
|
||||||
|
* - getMyFinaleInputs: a juror's own finale votes + ceremony notes,
|
||||||
|
* resurfaced during deliberation
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
uid,
|
||||||
|
} from '../helpers'
|
||||||
|
import { liveVotingRouter } from '@/server/routers/live-voting'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let round: any
|
||||||
|
let emptyRound: any
|
||||||
|
let session: any
|
||||||
|
let project: any
|
||||||
|
let juror: any
|
||||||
|
let jurorCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
const juryGroup = await prisma.juryGroup.create({
|
||||||
|
data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') },
|
||||||
|
})
|
||||||
|
round = await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
})
|
||||||
|
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: juryGroup.id } })
|
||||||
|
emptyRound = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', sortOrder: 1 })
|
||||||
|
project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
juror = await createTestUser('JURY_MEMBER')
|
||||||
|
await prisma.juryGroupMember.create({
|
||||||
|
data: { juryGroupId: juryGroup.id, userId: juror.id, role: 'MEMBER' },
|
||||||
|
})
|
||||||
|
session = await prisma.liveVotingSession.create({
|
||||||
|
data: {
|
||||||
|
roundId: round.id,
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
currentProjectId: project.id,
|
||||||
|
votingMode: 'simple',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
jurorCaller = createCaller(liveVotingRouter, juror)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [juror.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('vote comments', () => {
|
||||||
|
it('persists an optional comment with the vote', async () => {
|
||||||
|
const vote = await jurorCaller.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: project.id,
|
||||||
|
score: 8,
|
||||||
|
comment: 'Strong pitch, weak unit economics',
|
||||||
|
})
|
||||||
|
expect(vote.comment).toBe('Strong pitch, weak unit economics')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-voting updates the comment and keeps it when omitted', async () => {
|
||||||
|
const updated = await jurorCaller.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: project.id,
|
||||||
|
score: 9,
|
||||||
|
comment: 'Revised after Q&A',
|
||||||
|
})
|
||||||
|
expect(updated.score).toBe(9)
|
||||||
|
expect(updated.comment).toBe('Revised after Q&A')
|
||||||
|
|
||||||
|
const again = await jurorCaller.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: project.id,
|
||||||
|
score: 7,
|
||||||
|
})
|
||||||
|
expect(again.comment).toBe('Revised after Q&A') // omitted comment is not erased
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deliberation-time revision', () => {
|
||||||
|
it('allows voting for an ordered project that is not currently presenting', async () => {
|
||||||
|
const otherProject = await createTestProject(program.id, {
|
||||||
|
competitionCategory: 'BUSINESS_CONCEPT',
|
||||||
|
})
|
||||||
|
await prisma.round.update({
|
||||||
|
where: { id: round.id },
|
||||||
|
data: { configJson: { projectOrder: [project.id, otherProject.id] } },
|
||||||
|
})
|
||||||
|
// otherProject is NOT currentProjectId — revision path must accept it
|
||||||
|
const vote = await jurorCaller.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: otherProject.id,
|
||||||
|
score: 6,
|
||||||
|
comment: 'revised during deliberation',
|
||||||
|
})
|
||||||
|
expect(vote.projectId).toBe(otherProject.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still rejects projects outside the finale order', async () => {
|
||||||
|
const stranger = await createTestProject(program.id)
|
||||||
|
await expect(
|
||||||
|
jurorCaller.vote({ sessionId: session.id, projectId: stranger.id, score: 5 })
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows revision while the session is PAUSED', async () => {
|
||||||
|
await prisma.liveVotingSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { status: 'PAUSED' },
|
||||||
|
})
|
||||||
|
const vote = await jurorCaller.vote({
|
||||||
|
sessionId: session.id,
|
||||||
|
projectId: project.id,
|
||||||
|
score: 10,
|
||||||
|
})
|
||||||
|
expect(vote.score).toBe(10)
|
||||||
|
await prisma.liveVotingSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { status: 'IN_PROGRESS' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSessionForVotingByRound', () => {
|
||||||
|
it('resolves the session from a roundId', async () => {
|
||||||
|
const data = await jurorCaller.getSessionForVotingByRound({ roundId: round.id })
|
||||||
|
expect(data?.session.id).toBe(session.id)
|
||||||
|
expect(data?.currentProject?.id).toBe(project.id)
|
||||||
|
expect(data?.userVote?.score).toBe(10) // last revision in the PAUSED test
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the round has no session (creates nothing)', async () => {
|
||||||
|
const data = await jurorCaller.getSessionForVotingByRound({ roundId: emptyRound.id })
|
||||||
|
expect(data).toBeNull()
|
||||||
|
const count = await prisma.liveVotingSession.count({ where: { roundId: emptyRound.id } })
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMyFinaleInputs', () => {
|
||||||
|
it('returns the caller’s votes and notes for the round', async () => {
|
||||||
|
await prisma.liveNote.create({
|
||||||
|
data: { roundId: round.id, projectId: project.id, userId: juror.id, content: 'ceremony note' },
|
||||||
|
})
|
||||||
|
const inputs = await jurorCaller.getMyFinaleInputs({ roundId: round.id })
|
||||||
|
expect(inputs.session?.id).toBe(session.id)
|
||||||
|
expect(inputs.votes).toHaveLength(2) // original + deliberation-time revision
|
||||||
|
const mainVote = inputs.votes.find((v: any) => v.projectId === project.id)
|
||||||
|
expect(mainVote?.comment).toBe('Revised after Q&A')
|
||||||
|
expect(inputs.notes).toHaveLength(1)
|
||||||
|
expect(inputs.notes[0].content).toBe('ceremony note')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty-safe for a round without a session', async () => {
|
||||||
|
const inputs = await jurorCaller.getMyFinaleInputs({ roundId: emptyRound.id })
|
||||||
|
expect(inputs.session).toBeNull()
|
||||||
|
expect(inputs.votes).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,7 @@ vi.mock('@/lib/email', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
sendLunchReminderEmail: vi.fn(async () => undefined),
|
sendLunchReminderEmail: vi.fn(async () => undefined),
|
||||||
sendLunchRecapEmail: vi.fn(async () => undefined),
|
sendLunchRecapEmail: vi.fn(async () => undefined),
|
||||||
|
sendExternalDishInviteEmail: vi.fn(async () => undefined),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ afterAll(async () => {
|
|||||||
where: { confirmation: { project: { programId } } },
|
where: { confirmation: { project: { programId } } },
|
||||||
})
|
})
|
||||||
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||||
await cleanupTestData(programId, [])
|
await cleanupTestData(programId, [])
|
||||||
@@ -58,6 +60,7 @@ describe('GET /api/cron/lunch-reminders', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
||||||
|
if (!process.env.NEXTAUTH_SECRET) process.env.NEXTAUTH_SECRET = 'test-secret-nextauth'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects without CRON_SECRET', async () => {
|
it('rejects without CRON_SECRET', async () => {
|
||||||
@@ -119,6 +122,33 @@ describe('GET /api/cron/lunch-reminders', () => {
|
|||||||
expect(row?.reminderSentAt).not.toBeNull()
|
expect(row?.reminderSentAt).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends dish invites to unpicked external attendees inside the window', async () => {
|
||||||
|
const program = await createTestProgram({ name: `cron-rmd-ext-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const eventAt = new Date(Date.now() + 25 * 3_600_000)
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
enabled: true,
|
||||||
|
eventAt,
|
||||||
|
venue: 'La Terrasse',
|
||||||
|
changeCutoffHours: 24,
|
||||||
|
reminderHoursBeforeDeadline: 4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// emailed + no dish → invited; no-email → skipped
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Emailed Guest', email: `ext-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'No Email Guest' },
|
||||||
|
})
|
||||||
|
const res = await callCron('lunch-reminders')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('is idempotent — re-running with reminderSentAt set does not resend', async () => {
|
it('is idempotent — re-running with reminderSentAt set does not resend', async () => {
|
||||||
const program = await createTestProgram({ name: `cron-rmd-idem-${uid()}` })
|
const program = await createTestProgram({ name: `cron-rmd-idem-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
|
|||||||
200
tests/unit/lunch-external-pick.test.ts
Normal file
200
tests/unit/lunch-external-pick.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
|
||||||
|
|
||||||
|
vi.mock('@/lib/email', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
sendExternalDishInviteEmail: vi.fn(async () => undefined),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { lunchRouter } from '@/server/routers/lunch'
|
||||||
|
import { signExternalLunchToken } from '@/lib/external-lunch-token'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXTAUTH_SECRET = 'test-secret-for-external-lunch-tokens'
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const programId of programIds) {
|
||||||
|
await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||||
|
await cleanupTestData(programId, [])
|
||||||
|
}
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function newAdminCaller() {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
return createCaller(lunchRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicCaller() {
|
||||||
|
return lunchRouter.createCaller({
|
||||||
|
session: null,
|
||||||
|
prisma,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'vitest',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupEvent(opts: { eventAt?: Date | null; changeCutoffHours?: number } = {}) {
|
||||||
|
const program = await createTestProgram({ name: `xpick-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
enabled: true,
|
||||||
|
eventAt: opts.eventAt ?? new Date(Date.now() + 30 * 86_400_000),
|
||||||
|
venue: 'La Terrasse',
|
||||||
|
changeCutoffHours: opts.changeCutoffHours ?? 48,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const dish = await prisma.dish.create({
|
||||||
|
data: { lunchEventId: event.id, name: `Risotto-${uid()}`, dietaryTags: ['VEGETARIAN'] },
|
||||||
|
})
|
||||||
|
return { program, event, dish }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lunch.createExternal auto-invite', () => {
|
||||||
|
it('sends a dish invite and stamps inviteSentAt when an email is present', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await caller.createExternal({
|
||||||
|
lunchEventId: event.id,
|
||||||
|
name: 'Marine',
|
||||||
|
email: `marine-${uid()}@example.org`,
|
||||||
|
})
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1)
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.inviteSentAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send or stamp when no email is present', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'No Email' })
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).not.toHaveBeenCalled()
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.inviteSentAt).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.getExternalByToken', () => {
|
||||||
|
it('returns the external, event, and dishes for a valid token', async () => {
|
||||||
|
const { event, dish } = await setupEvent()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
const res = await publicCaller().getExternalByToken({ token })
|
||||||
|
expect(res.external.id).toBe(ext.id)
|
||||||
|
expect(res.external.name).toBe('Guest')
|
||||||
|
expect(res.event.venue).toBe('La Terrasse')
|
||||||
|
expect(res.dishes.map((d) => d.id)).toContain(dish.id)
|
||||||
|
expect(res.changeDeadline).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a tampered token', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
publicCaller().getExternalByToken({ token: token.slice(0, -2) + 'xx' }),
|
||||||
|
).rejects.toThrow(/signature/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.setExternalPick', () => {
|
||||||
|
it('sets the dish before the deadline', async () => {
|
||||||
|
const { event, dish } = await setupEvent()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
await publicCaller().setExternalPick({
|
||||||
|
token,
|
||||||
|
dishId: dish.id,
|
||||||
|
allergens: ['GLUTEN'],
|
||||||
|
allergenOther: 'wine',
|
||||||
|
})
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.dishId).toBe(dish.id)
|
||||||
|
expect(row?.allergens).toEqual(['GLUTEN'])
|
||||||
|
expect(row?.allergenOther).toBe('wine')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a pick after the change deadline', async () => {
|
||||||
|
// eventAt 1h out, 24h cutoff → deadline is in the past
|
||||||
|
const { event, dish } = await setupEvent({
|
||||||
|
eventAt: new Date(Date.now() + 3_600_000),
|
||||||
|
changeCutoffHours: 24,
|
||||||
|
})
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
publicCaller().setExternalPick({
|
||||||
|
token,
|
||||||
|
dishId: dish.id,
|
||||||
|
allergens: [],
|
||||||
|
allergenOther: null,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/deadline/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.sendExternalInvite', () => {
|
||||||
|
it('sends and stamps inviteSentAt for an emailed external', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
await caller.sendExternalInvite({ externalId: ext.id })
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1)
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.inviteSentAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the external has no email', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'No Email' },
|
||||||
|
})
|
||||||
|
await expect(caller.sendExternalInvite({ externalId: ext.id })).rejects.toThrow(/email/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
tests/unit/lunch-external-reminder-filter.test.ts
Normal file
63
tests/unit/lunch-external-reminder-filter.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* selectUnpickedExternals returns external attendees that:
|
||||||
|
* - belong to the given LunchEvent
|
||||||
|
* - have an email on file
|
||||||
|
* - have NOT picked a dish (dishId is null)
|
||||||
|
*
|
||||||
|
* Externals without an email (can't be reminded) and externals who already have
|
||||||
|
* a dish must be excluded.
|
||||||
|
*/
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../setup'
|
||||||
|
import { createTestProgram, cleanupTestData, uid } from '../helpers'
|
||||||
|
import { selectUnpickedExternals } from '@/server/services/lunch-reminders'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const programId of programIds) {
|
||||||
|
await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||||
|
await cleanupTestData(programId, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectUnpickedExternals', () => {
|
||||||
|
it('returns only emailed externals with no dish', async () => {
|
||||||
|
const program = await createTestProgram({ name: `xfilter-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: { programId: program.id, enabled: true },
|
||||||
|
})
|
||||||
|
const dish = await prisma.dish.create({
|
||||||
|
data: { lunchEventId: event.id, name: `dish-${uid()}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
// emailed + no dish → INCLUDED
|
||||||
|
const x1 = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Has email, no dish', email: `a-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
// emailed + has dish → excluded
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: {
|
||||||
|
lunchEventId: event.id,
|
||||||
|
name: 'Has email, has dish',
|
||||||
|
email: `b-${uid()}@x.test`,
|
||||||
|
dishId: dish.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// no email + no dish → excluded (can't email them)
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'No email', email: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await selectUnpickedExternals(prisma, { id: event.id })
|
||||||
|
|
||||||
|
const ids = result.map((x) => x.id)
|
||||||
|
expect(ids).toContain(x1.id)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].email).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
160
tests/unit/reveal.test.ts
Normal file
160
tests/unit/reveal.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Results reveal controller: admin composes steps privately (DRAFT), arms the
|
||||||
|
* big screen (ARMED), then steps through one reveal at a time (REVEALING →
|
||||||
|
* DONE). The public ceremony-state endpoint must NEVER leak un-revealed steps.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestData,
|
||||||
|
} from '../helpers'
|
||||||
|
import { liveVotingRouter } from '@/server/routers/live-voting'
|
||||||
|
import { liveRouter } from '@/server/routers/live'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let round: any
|
||||||
|
let session: any
|
||||||
|
let project: any
|
||||||
|
let admin: any
|
||||||
|
let adminCaller: ReturnType<typeof createCaller>
|
||||||
|
let publicCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ kind: 'category-intro' as const, category: 'STARTUP' as const, title: 'Startups' },
|
||||||
|
{ kind: 'place' as const, category: 'STARTUP' as const, place: 3, title: 'Team Gamma', subtitle: '3rd place — Startups' },
|
||||||
|
{ kind: 'place' as const, category: 'STARTUP' as const, place: 2, title: 'Team Beta', subtitle: '2nd place — Startups' },
|
||||||
|
{ kind: 'place' as const, category: 'STARTUP' as const, place: 1, title: 'Team Alpha', subtitle: 'Winner — Startups' },
|
||||||
|
{ kind: 'thanks' as const, title: 'Thank you' },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' })
|
||||||
|
project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
|
||||||
|
await prisma.round.update({
|
||||||
|
where: { id: round.id },
|
||||||
|
data: { configJson: { projectOrder: [project.id] } },
|
||||||
|
})
|
||||||
|
session = await prisma.liveVotingSession.create({ data: { roundId: round.id } })
|
||||||
|
admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
adminCaller = createCaller(liveVotingRouter, admin)
|
||||||
|
publicCaller = createCaller(liveVotingRouter, admin)
|
||||||
|
// a cursor so getCeremonyState has phase data
|
||||||
|
const liveCaller = createCaller(liveRouter, admin)
|
||||||
|
await liveCaller.start({ roundId: round.id, projectOrder: [project.id] })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [admin.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reveal lifecycle', () => {
|
||||||
|
it('saveReveal upserts a DRAFT', async () => {
|
||||||
|
const r1 = await adminCaller.saveReveal({ sessionId: session.id, steps: steps.slice(0, 2) })
|
||||||
|
expect(r1.status).toBe('DRAFT')
|
||||||
|
const r2 = await adminCaller.saveReveal({ sessionId: session.id, steps })
|
||||||
|
expect((r2.stepsJson as any[]).length).toBe(5)
|
||||||
|
expect(r2.currentStepIndex).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('armReveal requires steps and moves DRAFT → ARMED', async () => {
|
||||||
|
const r = await adminCaller.armReveal({ sessionId: session.id })
|
||||||
|
expect(r.status).toBe('ARMED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revealNext steps through and lands on DONE at the last step', async () => {
|
||||||
|
let r = await adminCaller.revealNext({ sessionId: session.id })
|
||||||
|
expect(r.status).toBe('REVEALING')
|
||||||
|
expect(r.currentStepIndex).toBe(0)
|
||||||
|
|
||||||
|
r = await adminCaller.revealNext({ sessionId: session.id })
|
||||||
|
expect(r.currentStepIndex).toBe(1)
|
||||||
|
|
||||||
|
r = await adminCaller.revealNext({ sessionId: session.id })
|
||||||
|
r = await adminCaller.revealNext({ sessionId: session.id })
|
||||||
|
r = await adminCaller.revealNext({ sessionId: session.id })
|
||||||
|
expect(r.currentStepIndex).toBe(4)
|
||||||
|
expect(r.status).toBe('DONE')
|
||||||
|
|
||||||
|
// advancing past the end stays clamped at the final step
|
||||||
|
r = await adminCaller.revealNext({ sessionId: session.id })
|
||||||
|
expect(r.currentStepIndex).toBe(4)
|
||||||
|
expect(r.status).toBe('DONE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('public ceremony state only exposes revealed steps', async () => {
|
||||||
|
// wind back mid-reveal
|
||||||
|
await prisma.revealState.update({
|
||||||
|
where: { sessionId: session.id },
|
||||||
|
data: { status: 'REVEALING', currentStepIndex: 1 },
|
||||||
|
})
|
||||||
|
const state = await publicCaller.getCeremonyState({ roundId: round.id })
|
||||||
|
expect(state.reveal?.status).toBe('REVEALING')
|
||||||
|
expect(state.reveal?.steps).toHaveLength(2) // steps 0 and 1 only
|
||||||
|
expect(state.reveal?.steps?.some((s: any) => s.title === 'Team Alpha')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('public ceremony state shows no steps when ARMED and no reveal when DRAFT', async () => {
|
||||||
|
await prisma.revealState.update({
|
||||||
|
where: { sessionId: session.id },
|
||||||
|
data: { status: 'ARMED', currentStepIndex: -1 },
|
||||||
|
})
|
||||||
|
let state = await publicCaller.getCeremonyState({ roundId: round.id })
|
||||||
|
expect(state.reveal?.status).toBe('ARMED')
|
||||||
|
expect(state.reveal?.steps).toHaveLength(0)
|
||||||
|
|
||||||
|
await adminCaller.resetReveal({ sessionId: session.id })
|
||||||
|
state = await publicCaller.getCeremonyState({ roundId: round.id })
|
||||||
|
expect(state.reveal).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveal mutations are admin-only', async () => {
|
||||||
|
const juror = await createTestUser('JURY_MEMBER')
|
||||||
|
const jurorCaller = createCaller(liveVotingRouter, juror)
|
||||||
|
await expect(jurorCaller.armReveal({ sessionId: session.id })).rejects.toThrow()
|
||||||
|
await expect(jurorCaller.revealNext({ sessionId: session.id })).rejects.toThrow()
|
||||||
|
await prisma.user.delete({ where: { id: juror.id } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ceremony state composition', () => {
|
||||||
|
it('exposes phase, active project, audience window and never any scores', async () => {
|
||||||
|
const state = await publicCaller.getCeremonyState({ roundId: round.id })
|
||||||
|
expect(state.phase?.projectPhase).toBe('ON_DECK')
|
||||||
|
expect(state.activeProject?.title).toBe(project.title)
|
||||||
|
expect(state.audience.open).toBe(false)
|
||||||
|
expect(JSON.stringify(state)).not.toMatch(/score/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the live vote count while a window is open', async () => {
|
||||||
|
await adminCaller.updateSessionConfig({ sessionId: session.id, allowAudienceVotes: true })
|
||||||
|
await adminCaller.openAudienceWindow({
|
||||||
|
sessionId: session.id,
|
||||||
|
windowKey: 'CATEGORY:STARTUP',
|
||||||
|
durationMinutes: 5,
|
||||||
|
})
|
||||||
|
const reg = await publicCaller.registerAudienceVoter({ sessionId: session.id })
|
||||||
|
await publicCaller.castFavoriteVote({
|
||||||
|
sessionId: session.id,
|
||||||
|
token: reg.token,
|
||||||
|
projectId: project.id,
|
||||||
|
})
|
||||||
|
const state = await publicCaller.getCeremonyState({ roundId: round.id })
|
||||||
|
expect(state.audience.open).toBe(true)
|
||||||
|
expect(state.audience.windowKey).toBe('CATEGORY:STARTUP')
|
||||||
|
expect(state.audience.voteCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the override slide', async () => {
|
||||||
|
const liveCaller = createCaller(liveRouter, admin)
|
||||||
|
await liveCaller.setOverrideSlide({ roundId: round.id, slide: 'break' })
|
||||||
|
const state = await publicCaller.getCeremonyState({ roundId: round.id })
|
||||||
|
expect(state.overrideSlide).toBe('break')
|
||||||
|
})
|
||||||
|
})
|
||||||
58
tests/unit/round-config-merge.test.ts
Normal file
58
tests/unit/round-config-merge.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Regression: saving the round Config form must NOT wipe operational keys that
|
||||||
|
* live outside the form schema (ceremony projectOrder, per-project timing
|
||||||
|
* overrides, finals-docs upload toggle). Found live: a Config save mid-test
|
||||||
|
* destroyed the running ceremony's run order.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
createTestProgram,
|
||||||
|
createTestCompetition,
|
||||||
|
createTestRound,
|
||||||
|
cleanupTestData,
|
||||||
|
} from '../helpers'
|
||||||
|
import { roundRouter } from '@/server/routers/round'
|
||||||
|
|
||||||
|
let program: any
|
||||||
|
let round: any
|
||||||
|
let admin: any
|
||||||
|
let adminCaller: ReturnType<typeof createCaller>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
program = await createTestProgram()
|
||||||
|
const competition = await createTestCompetition(program.id)
|
||||||
|
round = await createTestRound(competition.id, {
|
||||||
|
roundType: 'LIVE_FINAL',
|
||||||
|
status: 'ROUND_ACTIVE',
|
||||||
|
configJson: {
|
||||||
|
presentationDurationMinutes: 5,
|
||||||
|
projectOrder: ['p-one', 'p-two'],
|
||||||
|
projectTimingOverrides: { 'p-one': { presentationSeconds: 480 } },
|
||||||
|
allowFinalistRevisedUploads: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
adminCaller = createCaller(roundRouter, admin)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData(program.id, [admin.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round.update configJson merge', () => {
|
||||||
|
it('updates form fields without wiping operational keys', async () => {
|
||||||
|
await adminCaller.update({
|
||||||
|
id: round.id,
|
||||||
|
configJson: { presentationDurationMinutes: 10, qaDurationMinutes: 3 },
|
||||||
|
})
|
||||||
|
const updated = await prisma.round.findUniqueOrThrow({ where: { id: round.id } })
|
||||||
|
const cfg = updated.configJson as Record<string, unknown>
|
||||||
|
expect(cfg.presentationDurationMinutes).toBe(10) // form field applied
|
||||||
|
expect(cfg.qaDurationMinutes).toBe(3)
|
||||||
|
expect(cfg.projectOrder).toEqual(['p-one', 'p-two']) // ceremony state survives
|
||||||
|
expect((cfg.projectTimingOverrides as any)['p-one'].presentationSeconds).toBe(480)
|
||||||
|
expect(cfg.allowFinalistRevisedUploads).toBe(true) // finals-docs toggle survives
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user