Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,45 @@ export interface Results {
totalConnections: number
}

export interface PresenterQuestionOverviewItem {
id: string
key: string
question_text: LocalizedString
}

export interface PresenterQuestionsOverview {
totalQuestions: number
questions: PresenterQuestionOverviewItem[]
}

export interface PresenterCurrentStateAnswerOption {
text: LocalizedString
emoji?: string
count: number
percent: number
}

export interface PresenterCurrentQuestion {
id: string
key: string
index: number
totalQuestions: number
question_text: LocalizedString
note?: LocalizedString
is_active: boolean
is_locked: boolean
createdAt: string
answer_options: PresenterCurrentStateAnswerOption[]
}

export interface PresenterCurrentState {
hasActiveQuestion: boolean
totalUsers: number
receivedAnswers: number
receivedAnswersPercent: number
currentQuestion: PresenterCurrentQuestion | null
}

export enum WebSocketChannel {
DEFAULT = 'default',
RESULTS = 'results',
Expand Down
95 changes: 93 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Verify authentication token (admin only).

`Authorization: Bearer <token>` accepts two admin auth modes:

- A valid admin JWT only when a client already has that token. `POST /api/auth/login` does not return the JWT in the response body; it sets the `admin_token` HTTP-only cookie for browser admin sessions.
- The exact static token configured in `NUXT_ADMIN_TOKEN` for software-to-software admin access. External API clients should use this static token path.
- A JWT created by `POST /api/auth/login`
- The exact static token configured in `NUXT_ADMIN_TOKEN` for software-to-software admin access

**Response:**

Expand Down Expand Up @@ -77,6 +77,95 @@ Load the authenticated Drizzle Studio shell used by `/admin/database`.

Load Drizzle Studio static assets through the authenticated proxy.

### GET `/api/admin/presenter/overview`

Get a high-level quiz overview for presenter software (admin only).

This endpoint is meant for low-frequency use, such as a one-time fetch from presenter slides when a session starts.

**Headers:**

- Cookie: `admin_token`
- Or `Authorization: Bearer <token>`

**Response:**

```json
{
"totalQuestions": 3,
"questions": [
{
"id": "string",
"key": "string",
"question_text": {
"en": "string"
}
}
]
}
```

### GET `/api/admin/presenter/current-state`

Get detailed presenter state for the active question (admin only).

This endpoint is meant for polling, such as once per second from presenter slides.

`totalUsers` means active WebSocket connections at request time.

**Headers:**

- Cookie: `admin_token`
- Or `Authorization: Bearer <token>`

**Response:**

```json
{
"hasActiveQuestion": true,
"totalUsers": 42,
"receivedAnswers": 15,
"receivedAnswersPercent": 36,
"currentQuestion": {
"id": "string",
"key": "string",
"index": 2,
"totalQuestions": 5,
"question_text": {
"en": "string"
},
"note": {
"en": "string"
},
"is_active": true,
"is_locked": false,
"createdAt": "2026-05-03T12:00:00.000Z",
"answer_options": [
{
"text": {
"en": "string"
},
"emoji": "🔥",
"count": 10,
"percent": 67
}
]
}
}
```

**Response (no active question):**

```json
{
"hasActiveQuestion": false,
"totalUsers": 42,
"receivedAnswers": 0,
"receivedAnswersPercent": 0,
"currentQuestion": null
}
```

### POST `/`

Internal admin-only Drizzle Studio RPC compatibility endpoint used by the embedded frame. Treat this as internal transport, not a public integration API.
Expand Down Expand Up @@ -118,6 +207,8 @@ Get the currently active question (public). Returns a simplified version without

Create new question (admin only).

English `answer_options[].text.en` values must be unique. Matching is case-insensitive.

**Request:**

```json
Expand Down
7 changes: 7 additions & 0 deletions server/api/admin/presenter/current-state.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { PresenterCurrentState } from '~/types'

export default defineEventHandler(async (event): Promise<PresenterCurrentState> => {
await verifyAdmin(event)

return await getPresenterCurrentState()
})
7 changes: 7 additions & 0 deletions server/api/admin/presenter/overview.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { PresenterQuestionsOverview } from '~/types'

export default defineEventHandler(async (event): Promise<PresenterQuestionsOverview> => {
await verifyAdmin(event)

return await getPresenterQuestionsOverview()
})
14 changes: 14 additions & 0 deletions server/api/questions/create.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ export default defineEventHandler(async (event) => {
}
})

const normalizedAnswerOptionLabels = new Set<string>()
for (const option of answer_options) {
const normalizedEnglishLabel = option.text.en.toLowerCase()

if (normalizedAnswerOptionLabels.has(normalizedEnglishLabel)) {
throw createError({
statusCode: 400,
statusMessage: 'Answer option English text values must be unique',
})
}

normalizedAnswerOptionLabels.add(normalizedEnglishLabel)
}

if (answer_options.length < 2) {
throw createError({
statusCode: 400,
Expand Down
87 changes: 87 additions & 0 deletions server/utils/presenter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type {
PresenterCurrentState,
PresenterQuestionsOverview,
Question,
} from '~/types'

function getPercent(part: number, total: number): number {
return total > 0 ? Math.round((part / total) * 100) : 0
}

function findActiveQuestion(questionList: Question[]): Question | undefined {
for (let index = questionList.length - 1; index >= 0; index -= 1) {
const question = questionList[index]

if (question?.is_active) {
return question
}
}

return undefined
}

/** Returns stable high-level quiz metadata for presenter integrations. */
export async function getPresenterQuestionsOverview(): Promise<PresenterQuestionsOverview> {
const questionList = await getQuestions()

return {
totalQuestions: questionList.length,
questions: questionList.map(question => ({
id: question.id,
key: question.key,
question_text: question.question_text,
})),
}
}

/** Returns polling-friendly presenter state for the active question. */
export async function getPresenterCurrentState(): Promise<PresenterCurrentState> {
const [
questionList,
peers,
] = await Promise.all([
getQuestions(),
getPeers(),
])
const totalUsers = peers.length
const currentQuestion = findActiveQuestion(questionList)

if (!currentQuestion) {
return {
hasActiveQuestion: false,
totalUsers,
receivedAnswers: 0,
receivedAnswersPercent: 0,
currentQuestion: null,
}
}

const answerList = await getAnswersForQuestion(currentQuestion.id)
const receivedAnswers = answerList.length
const results = buildQuestionOptionResults(currentQuestion, answerList)
const currentQuestionIndex = questionList.findIndex(question => question.id === currentQuestion.id) + 1

return {
hasActiveQuestion: true,
totalUsers,
receivedAnswers,
receivedAnswersPercent: getPercent(receivedAnswers, totalUsers),
currentQuestion: {
id: currentQuestion.id,
key: currentQuestion.key,
index: currentQuestionIndex,
totalQuestions: questionList.length,
question_text: currentQuestion.question_text,
note: currentQuestion.note,
is_active: currentQuestion.is_active ?? false,
is_locked: currentQuestion.is_locked,
createdAt: currentQuestion.createdAt,
answer_options: currentQuestion.answer_options.map(option => ({
text: option.text,
emoji: option.emoji,
count: results[option.text.en]?.count ?? 0,
percent: getPercent(results[option.text.en]?.count ?? 0, receivedAnswers),
})),
},
}
}
37 changes: 37 additions & 0 deletions server/utils/quiz-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {
Answer,
Question,
Results,
} from '~/types'

/** Builds per-option vote counts keyed by the English option label. */
export function buildQuestionOptionResults(question: Question, answerList: Answer[]): Results['results'] {
const results = Object.create(null) as Results['results']
const normalizedOptionLabels = new Set<string>()

for (const option of question.answer_options) {
const resultKey = option.text.en
const normalizedResultKey = resultKey.toLowerCase()

if (normalizedOptionLabels.has(normalizedResultKey)) {
throw new Error(`Duplicate answer option label is not supported: "${resultKey}"`)
}

normalizedOptionLabels.add(normalizedResultKey)

results[resultKey] = {
count: 0,
emoji: option.emoji,
}
}

for (const answer of answerList) {
const selectedAnswer = answer.selected_answer.en

if (Object.prototype.hasOwnProperty.call(results, selectedAnswer)) {
results[selectedAnswer]!.count += 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return results
}
19 changes: 2 additions & 17 deletions server/utils/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
answers,
questions,
} from '../database/schema'
import { buildQuestionOptionResults } from './quiz-results'
import { getPeers } from './websocket'

let storageInitialized = false
Expand Down Expand Up @@ -313,25 +314,9 @@ export async function getResultsForQuestion(

const answerList = allAnswers || await getAnswersForQuestion(question.id)

const results: Record<string, { count: number, emoji?: string }> = {}
for (const option of question.answer_options) {
results[option.text.en] = {
count: 0,
emoji: option.emoji,
}
}

for (const answer of answerList) {
const selectedAnswer = answer.selected_answer.en

if (Object.prototype.hasOwnProperty.call(results, selectedAnswer)) {
results[selectedAnswer]!.count += 1
}
}

return {
question,
results,
results: buildQuestionOptionResults(question, answerList),
totalVotes: answerList.length,
totalConnections: (await getPeers()).length,
}
Expand Down