diff --git a/app/types.ts b/app/types.ts index f3551b3..75666e4 100644 --- a/app/types.ts +++ b/app/types.ts @@ -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', diff --git a/docs/api.md b/docs/api.md index b82d9fe..37eb9b0 100755 --- a/docs/api.md +++ b/docs/api.md @@ -45,8 +45,8 @@ Verify authentication token (admin only). `Authorization: Bearer ` 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:** @@ -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 ` + +**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 ` + +**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. @@ -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 diff --git a/server/api/admin/presenter/current-state.get.ts b/server/api/admin/presenter/current-state.get.ts new file mode 100644 index 0000000..0d3a1aa --- /dev/null +++ b/server/api/admin/presenter/current-state.get.ts @@ -0,0 +1,7 @@ +import type { PresenterCurrentState } from '~/types' + +export default defineEventHandler(async (event): Promise => { + await verifyAdmin(event) + + return await getPresenterCurrentState() +}) diff --git a/server/api/admin/presenter/overview.get.ts b/server/api/admin/presenter/overview.get.ts new file mode 100644 index 0000000..91baf03 --- /dev/null +++ b/server/api/admin/presenter/overview.get.ts @@ -0,0 +1,7 @@ +import type { PresenterQuestionsOverview } from '~/types' + +export default defineEventHandler(async (event): Promise => { + await verifyAdmin(event) + + return await getPresenterQuestionsOverview() +}) diff --git a/server/api/questions/create.post.ts b/server/api/questions/create.post.ts index 87eee5c..b8e423b 100755 --- a/server/api/questions/create.post.ts +++ b/server/api/questions/create.post.ts @@ -88,6 +88,20 @@ export default defineEventHandler(async (event) => { } }) + const normalizedAnswerOptionLabels = new Set() + 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, diff --git a/server/utils/presenter.ts b/server/utils/presenter.ts new file mode 100644 index 0000000..7801bac --- /dev/null +++ b/server/utils/presenter.ts @@ -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 { + 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 { + 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), + })), + }, + } +} diff --git a/server/utils/quiz-results.ts b/server/utils/quiz-results.ts new file mode 100644 index 0000000..9156622 --- /dev/null +++ b/server/utils/quiz-results.ts @@ -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() + + 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 + } + } + + return results +} diff --git a/server/utils/storage.ts b/server/utils/storage.ts index c8ade27..a7f3606 100644 --- a/server/utils/storage.ts +++ b/server/utils/storage.ts @@ -26,6 +26,7 @@ import { answers, questions, } from '../database/schema' +import { buildQuestionOptionResults } from './quiz-results' import { getPeers } from './websocket' let storageInitialized = false @@ -313,25 +314,9 @@ export async function getResultsForQuestion( const answerList = allAnswers || await getAnswersForQuestion(question.id) - const results: Record = {} - 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, }