forked from wangziqi/gongxue-base
feat: add mock exam reports
This commit is contained in:
@@ -3,8 +3,11 @@ import {
|
||||
createPracticeSessionRoute,
|
||||
favoriteQuestionsRoute,
|
||||
favoriteWordsRoute,
|
||||
practiceReportsRoute,
|
||||
practiceSessionReportRoute,
|
||||
resolveWrongQuestionRoute,
|
||||
submitAnswerRoute,
|
||||
submitPracticeSessionRoute,
|
||||
toggleFavoriteQuestionRoute,
|
||||
toggleFavoriteWordRoute,
|
||||
updateWordProgressRoute,
|
||||
@@ -15,6 +18,9 @@ import {
|
||||
|
||||
export const learningRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
||||
['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute],
|
||||
['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute],
|
||||
['GET', '/api/learning/practice-reports', practiceReportsRoute],
|
||||
['POST', '/api/learning/answers', submitAnswerRoute],
|
||||
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
||||
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
@@ -35,6 +36,115 @@ interface PracticeBlueprintRow {
|
||||
rules: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PracticeSessionReportSessionRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
mode: string;
|
||||
blueprintId: string | null;
|
||||
collectionId: string | null;
|
||||
entryId: string | null;
|
||||
contentNodeId: string | null;
|
||||
questionIds: unknown;
|
||||
questionCount: number;
|
||||
durationMinutes: number | null;
|
||||
totalScore: string | number | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PracticeSessionReportRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
practiceSessionId: string;
|
||||
blueprintId: string | null;
|
||||
collectionId: string | null;
|
||||
mode: string;
|
||||
totalQuestions: number;
|
||||
answeredCount: number;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
unansweredCount: number;
|
||||
score: string | number;
|
||||
totalScore: string | number;
|
||||
accuracy: string | number;
|
||||
durationSeconds: number;
|
||||
startedAt: string | null;
|
||||
submittedAt: string;
|
||||
sectionStats: unknown;
|
||||
questionResults: unknown;
|
||||
wrongQuestionIds: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PracticeSessionReportAnswerRow {
|
||||
questionId: string;
|
||||
selectedOptions: unknown;
|
||||
answerText: string | null;
|
||||
isCorrect: boolean | null;
|
||||
answeredAt: string;
|
||||
}
|
||||
|
||||
interface PracticeSessionReportQuestionRow {
|
||||
questionId: string;
|
||||
type: string;
|
||||
typeLabel: string | null;
|
||||
collectionId: string | null;
|
||||
sectionKey: string | null;
|
||||
sortOrder: number | null;
|
||||
itemScore: string | number | null;
|
||||
content: string | null;
|
||||
explanation: string | null;
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: unknown;
|
||||
answerText: string | null;
|
||||
}
|
||||
|
||||
interface PracticeReportQuestionResult {
|
||||
questionId: string;
|
||||
questionType: string;
|
||||
sectionKey: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
answered: boolean;
|
||||
isCorrect: boolean | null;
|
||||
selectedOptions: string[];
|
||||
answerText: string | null;
|
||||
answeredAt: string | null;
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: number[];
|
||||
correctAnswerText: string | null;
|
||||
explanation: string | null;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
interface SectionDefinition {
|
||||
key: string;
|
||||
title: string | null;
|
||||
questionType: string | null;
|
||||
scoreEach: number | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface SectionStat {
|
||||
key: string;
|
||||
title: string | null;
|
||||
questionType: string | null;
|
||||
questionCount: number;
|
||||
answeredCount: number;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
unansweredCount: number;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
accuracy: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface PracticeAssembly {
|
||||
mode: string;
|
||||
targetType: string | null;
|
||||
@@ -101,12 +211,77 @@ function jsonArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function positiveInt(value: unknown, fallback: number, max = 500) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.min(Math.trunc(parsed), max);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function round2(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function round4(value: number) {
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
function idArrayFromJson(value: unknown): string[] {
|
||||
return normalizeStringArray(value);
|
||||
}
|
||||
|
||||
function sectionDefinitionsFrom(metadata: Record<string, unknown>): SectionDefinition[] {
|
||||
const assembly = jsonObject(metadata.assembly);
|
||||
return jsonArray(assembly.sections).map((section, index) => {
|
||||
const object = jsonObject(section);
|
||||
const key = String(object.key || object.sectionKey || object.questionType || object.type || `section_${index + 1}`);
|
||||
const title = typeof object.title === 'string'
|
||||
? object.title
|
||||
: typeof object.name === 'string'
|
||||
? object.name
|
||||
: null;
|
||||
const questionType = typeof object.questionType === 'string'
|
||||
? object.questionType
|
||||
: typeof object.type === 'string'
|
||||
? object.type
|
||||
: null;
|
||||
const scoreEach = object.scoreEach === undefined && object.score === undefined ? null : finiteNumber(object.scoreEach ?? object.score, 0);
|
||||
return { key, title, questionType, scoreEach, sortOrder: index };
|
||||
});
|
||||
}
|
||||
|
||||
function sectionForQuestion(row: PracticeSessionReportQuestionRow, sections: SectionDefinition[]) {
|
||||
const direct = row.sectionKey ? sections.find(section => section.key === row.sectionKey) : undefined;
|
||||
if (direct) return direct;
|
||||
const byType = sections.find(section => section.questionType && section.questionType === row.type);
|
||||
if (byType) return byType;
|
||||
return {
|
||||
key: row.sectionKey || row.type || 'default',
|
||||
title: row.typeLabel,
|
||||
questionType: row.type || null,
|
||||
scoreEach: null,
|
||||
sortOrder: sections.length,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuestionScore(input: {
|
||||
row: PracticeSessionReportQuestionRow;
|
||||
section: SectionDefinition;
|
||||
fallbackScore: number;
|
||||
}) {
|
||||
if (input.row.itemScore !== null && input.row.itemScore !== undefined) return finiteNumber(input.row.itemScore, input.fallbackScore);
|
||||
if (input.section.scoreEach !== null && Number.isFinite(input.section.scoreEach)) return input.section.scoreEach;
|
||||
return input.fallbackScore;
|
||||
}
|
||||
|
||||
function modeFrom(value: string) {
|
||||
if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value;
|
||||
return 'chapter';
|
||||
@@ -524,6 +699,417 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
return { item: result };
|
||||
}
|
||||
|
||||
function formatReport(row: PracticeSessionReportRow) {
|
||||
return {
|
||||
...row,
|
||||
totalQuestions: Number(row.totalQuestions),
|
||||
answeredCount: Number(row.answeredCount),
|
||||
correctCount: Number(row.correctCount),
|
||||
wrongCount: Number(row.wrongCount),
|
||||
unansweredCount: Number(row.unansweredCount),
|
||||
score: finiteNumber(row.score),
|
||||
totalScore: finiteNumber(row.totalScore),
|
||||
accuracy: finiteNumber(row.accuracy),
|
||||
durationSeconds: Number(row.durationSeconds || 0),
|
||||
sectionStats: jsonArray(row.sectionStats),
|
||||
questionResults: jsonArray(row.questionResults),
|
||||
wrongQuestionIds: normalizeStringArray(row.wrongQuestionIds),
|
||||
metadata: jsonObject(row.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPracticeReportBySession(
|
||||
client: { query: pg.PoolClient['query'] },
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
practiceSessionId: string,
|
||||
) {
|
||||
const result = await client.query<PracticeSessionReportRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", user_id as "userId",
|
||||
practice_session_id as "practiceSessionId",
|
||||
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
|
||||
total_questions as "totalQuestions", answered_count as "answeredCount",
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
unanswered_count as "unansweredCount",
|
||||
score, total_score as "totalScore", accuracy,
|
||||
duration_seconds as "durationSeconds",
|
||||
started_at as "startedAt", submitted_at as "submittedAt",
|
||||
section_stats as "sectionStats", question_results as "questionResults",
|
||||
wrong_question_ids as "wrongQuestionIds", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.practice_session_reports
|
||||
where tenant_id = $1 and user_id = $2 and practice_session_id = $3
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, practiceSessionId],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function buildPracticeSessionReport(
|
||||
client: pg.PoolClient,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
practiceSessionId: string,
|
||||
) {
|
||||
const existing = await fetchPracticeReportBySession(client, tenantId, userId, practiceSessionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const sessionResult = await client.query<PracticeSessionReportSessionRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", user_id as "userId", mode,
|
||||
blueprint_id as "blueprintId", collection_id as "collectionId",
|
||||
entry_id as "entryId", content_node_id as "contentNodeId",
|
||||
question_ids as "questionIds", question_count as "questionCount",
|
||||
duration_minutes as "durationMinutes", total_score as "totalScore",
|
||||
started_at as "startedAt", finished_at as "finishedAt", metadata
|
||||
from public.practice_sessions
|
||||
where tenant_id = $1 and user_id = $2 and id = $3
|
||||
for update
|
||||
`,
|
||||
[tenantId, userId, practiceSessionId],
|
||||
);
|
||||
const session = sessionResult.rows[0];
|
||||
if (!session) {
|
||||
throw new HttpError(404, 'Practice session not found', 'PRACTICE_SESSION_NOT_FOUND');
|
||||
}
|
||||
|
||||
const existingAfterLock = await fetchPracticeReportBySession(client, tenantId, userId, practiceSessionId);
|
||||
if (existingAfterLock) return existingAfterLock;
|
||||
|
||||
const questionIds = idArrayFromJson(session.questionIds);
|
||||
if (questionIds.length === 0) {
|
||||
throw new HttpError(409, 'Practice session has no question snapshot', 'PRACTICE_SESSION_EMPTY');
|
||||
}
|
||||
|
||||
const sectionDefinitions = sectionDefinitionsFrom(session.metadata);
|
||||
const fallbackQuestionScore = session.totalScore
|
||||
? finiteNumber(session.totalScore, questionIds.length) / Math.max(1, questionIds.length)
|
||||
: 1;
|
||||
|
||||
const questionRows = await client.query<PracticeSessionReportQuestionRow>(
|
||||
`
|
||||
select q.id as "questionId", q.type, q.type_label as "typeLabel",
|
||||
ci.collection_id as "collectionId", ci.section_key as "sectionKey",
|
||||
ci.sort_order as "sortOrder", ci.score as "itemScore",
|
||||
v.content, v.explanation,
|
||||
v.correct_option_index as "correctOptionIndex",
|
||||
v.correct_option_indices as "correctOptionIndices",
|
||||
v.answer_text as "answerText"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_collection_items ci
|
||||
on ci.tenant_id = q.tenant_id
|
||||
and ci.question_id = q.id
|
||||
and ci.collection_id = $3::uuid
|
||||
where q.tenant_id = $1 and q.id = any($2::uuid[])
|
||||
`,
|
||||
[tenantId, questionIds, session.collectionId],
|
||||
);
|
||||
const questionById = new Map(questionRows.rows.map(row => [row.questionId, row]));
|
||||
|
||||
const answerRows = await client.query<PracticeSessionReportAnswerRow>(
|
||||
`
|
||||
select distinct on (question_id)
|
||||
question_id as "questionId", selected_options as "selectedOptions",
|
||||
answer_text as "answerText", is_correct as "isCorrect",
|
||||
answered_at as "answeredAt"
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and practice_session_id = $3
|
||||
and question_id = any($4::uuid[])
|
||||
order by question_id, answered_at desc
|
||||
`,
|
||||
[tenantId, userId, practiceSessionId, questionIds],
|
||||
);
|
||||
const answerByQuestionId = new Map(answerRows.rows.map(row => [row.questionId, row]));
|
||||
|
||||
const sectionStatsByKey = new Map<string, SectionStat>();
|
||||
const questionResults: PracticeReportQuestionResult[] = [];
|
||||
const wrongQuestionIds: string[] = [];
|
||||
|
||||
for (const questionId of questionIds) {
|
||||
const question = questionById.get(questionId);
|
||||
if (!question) continue;
|
||||
const answer = answerByQuestionId.get(questionId) || null;
|
||||
const section = sectionForQuestion(question, sectionDefinitions);
|
||||
const questionScore = round2(resolveQuestionScore({ row: question, section, fallbackScore: fallbackQuestionScore }));
|
||||
const answered = !!answer;
|
||||
const isCorrect = answer?.isCorrect ?? null;
|
||||
const earnedScore = isCorrect === true ? questionScore : 0;
|
||||
const sectionStat = sectionStatsByKey.get(section.key) || {
|
||||
key: section.key,
|
||||
title: section.title,
|
||||
questionType: section.questionType,
|
||||
questionCount: 0,
|
||||
answeredCount: 0,
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
unansweredCount: 0,
|
||||
score: 0,
|
||||
totalScore: 0,
|
||||
accuracy: 0,
|
||||
sortOrder: section.sortOrder,
|
||||
};
|
||||
|
||||
sectionStat.questionCount += 1;
|
||||
sectionStat.totalScore = round2(sectionStat.totalScore + questionScore);
|
||||
if (answered) sectionStat.answeredCount += 1;
|
||||
if (isCorrect === true) {
|
||||
sectionStat.correctCount += 1;
|
||||
sectionStat.score = round2(sectionStat.score + earnedScore);
|
||||
} else if (answered) {
|
||||
sectionStat.wrongCount += 1;
|
||||
wrongQuestionIds.push(questionId);
|
||||
} else {
|
||||
sectionStat.unansweredCount += 1;
|
||||
}
|
||||
sectionStatsByKey.set(section.key, sectionStat);
|
||||
|
||||
questionResults.push({
|
||||
questionId,
|
||||
questionType: question.type,
|
||||
sectionKey: section.key,
|
||||
score: earnedScore,
|
||||
totalScore: questionScore,
|
||||
answered,
|
||||
isCorrect,
|
||||
selectedOptions: normalizeStringArray(answer?.selectedOptions),
|
||||
answerText: answer?.answerText || null,
|
||||
answeredAt: answer?.answeredAt || null,
|
||||
correctOptionIndex: question.correctOptionIndex,
|
||||
correctOptionIndices: normalizeNumberArray(question.correctOptionIndices),
|
||||
correctAnswerText: question.answerText,
|
||||
explanation: question.explanation,
|
||||
content: question.content,
|
||||
});
|
||||
}
|
||||
|
||||
const sectionStats = [...sectionStatsByKey.values()]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder || left.key.localeCompare(right.key))
|
||||
.map(section => ({
|
||||
...section,
|
||||
unansweredCount: Math.max(0, section.questionCount - section.answeredCount),
|
||||
accuracy: round4(section.questionCount ? section.correctCount / section.questionCount : 0),
|
||||
score: round2(section.score),
|
||||
totalScore: round2(section.totalScore),
|
||||
}));
|
||||
|
||||
const totalQuestions = questionResults.length;
|
||||
const answeredCount = questionResults.filter(item => item.answered).length;
|
||||
const correctCount = questionResults.filter(item => item.isCorrect === true).length;
|
||||
const wrongCount = questionResults.filter(item => item.answered && item.isCorrect !== true).length;
|
||||
const unansweredCount = Math.max(0, totalQuestions - answeredCount);
|
||||
const score = round2(questionResults.reduce((sum, item) => sum + item.score, 0));
|
||||
const computedTotalScore = round2(questionResults.reduce((sum, item) => sum + item.totalScore, 0));
|
||||
const configuredTotalScore = session.totalScore ? round2(finiteNumber(session.totalScore, computedTotalScore)) : computedTotalScore;
|
||||
const submittedAt = new Date();
|
||||
const startedAt = new Date(session.startedAt);
|
||||
const durationSeconds = Number.isFinite(startedAt.getTime())
|
||||
? Math.max(0, Math.trunc((submittedAt.getTime() - startedAt.getTime()) / 1000))
|
||||
: 0;
|
||||
|
||||
const reportResult = await client.query<PracticeSessionReportRow>(
|
||||
`
|
||||
insert into public.practice_session_reports (
|
||||
tenant_id, user_id, practice_session_id, blueprint_id, collection_id, mode,
|
||||
total_questions, answered_count, correct_count, wrong_count, unanswered_count,
|
||||
score, total_score, accuracy, duration_seconds, started_at, submitted_at,
|
||||
section_stats, question_results, wrong_question_ids, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4::uuid, $5::uuid, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18::jsonb, $19::jsonb, $20::jsonb, $21::jsonb
|
||||
)
|
||||
returning id, tenant_id as "tenantId", user_id as "userId",
|
||||
practice_session_id as "practiceSessionId",
|
||||
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
|
||||
total_questions as "totalQuestions", answered_count as "answeredCount",
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
unanswered_count as "unansweredCount",
|
||||
score, total_score as "totalScore", accuracy,
|
||||
duration_seconds as "durationSeconds",
|
||||
started_at as "startedAt", submitted_at as "submittedAt",
|
||||
section_stats as "sectionStats", question_results as "questionResults",
|
||||
wrong_question_ids as "wrongQuestionIds", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
practiceSessionId,
|
||||
session.blueprintId,
|
||||
session.collectionId,
|
||||
session.mode,
|
||||
totalQuestions,
|
||||
answeredCount,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
unansweredCount,
|
||||
score,
|
||||
configuredTotalScore,
|
||||
round4(totalQuestions ? correctCount / totalQuestions : 0),
|
||||
durationSeconds,
|
||||
session.startedAt,
|
||||
submittedAt.toISOString(),
|
||||
JSON.stringify(sectionStats),
|
||||
JSON.stringify(questionResults),
|
||||
JSON.stringify(wrongQuestionIds),
|
||||
JSON.stringify({
|
||||
scoringVersion: 1,
|
||||
computedTotalScore,
|
||||
configuredTotalScore,
|
||||
entryId: session.entryId,
|
||||
contentNodeId: session.contentNodeId,
|
||||
}),
|
||||
],
|
||||
);
|
||||
const report = reportResult.rows[0];
|
||||
|
||||
for (const section of sectionStats) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.practice_session_report_sections (
|
||||
tenant_id, report_id, practice_session_id, section_key, section_name, question_type,
|
||||
question_count, answered_count, correct_count, wrong_count, unanswered_count,
|
||||
score, total_score, accuracy, sort_order, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, '{}'::jsonb)
|
||||
on conflict (tenant_id, report_id, section_key)
|
||||
do update set section_name = excluded.section_name,
|
||||
question_type = excluded.question_type,
|
||||
question_count = excluded.question_count,
|
||||
answered_count = excluded.answered_count,
|
||||
correct_count = excluded.correct_count,
|
||||
wrong_count = excluded.wrong_count,
|
||||
unanswered_count = excluded.unanswered_count,
|
||||
score = excluded.score,
|
||||
total_score = excluded.total_score,
|
||||
accuracy = excluded.accuracy,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
report.id,
|
||||
practiceSessionId,
|
||||
section.key,
|
||||
section.title,
|
||||
section.questionType,
|
||||
section.questionCount,
|
||||
section.answeredCount,
|
||||
section.correctCount,
|
||||
section.wrongCount,
|
||||
section.unansweredCount,
|
||||
section.score,
|
||||
section.totalScore,
|
||||
section.accuracy,
|
||||
section.sortOrder,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.practice_sessions
|
||||
set finished_at = coalesce(finished_at, $4::timestamptz),
|
||||
metadata = metadata || jsonb_build_object(
|
||||
'submittedReportId', $5::uuid,
|
||||
'score', $6::numeric,
|
||||
'accuracy', $7::numeric,
|
||||
'submittedAt', $4::timestamptz
|
||||
)
|
||||
where tenant_id = $1 and user_id = $2 and id = $3
|
||||
`,
|
||||
[tenantId, userId, practiceSessionId, submittedAt.toISOString(), report.id, score, round4(totalQuestions ? correctCount / totalQuestions : 0)],
|
||||
);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function submitPracticeSessionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const practiceSessionId = requiredString(body, 'practiceSessionId');
|
||||
|
||||
const report = await transaction(async client => buildPracticeSessionReport(client, tenantId, userId, practiceSessionId));
|
||||
return { item: formatReport(report) };
|
||||
}
|
||||
|
||||
export async function practiceSessionReportRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const practiceSessionId = stringParam(ctx, 'practiceSessionId');
|
||||
if (!practiceSessionId) {
|
||||
throw new HttpError(400, 'practiceSessionId is required', 'PRACTICE_SESSION_ID_REQUIRED');
|
||||
}
|
||||
|
||||
const report = await queryOne<PracticeSessionReportRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", user_id as "userId",
|
||||
practice_session_id as "practiceSessionId",
|
||||
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
|
||||
total_questions as "totalQuestions", answered_count as "answeredCount",
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
unanswered_count as "unansweredCount",
|
||||
score, total_score as "totalScore", accuracy,
|
||||
duration_seconds as "durationSeconds",
|
||||
started_at as "startedAt", submitted_at as "submittedAt",
|
||||
section_stats as "sectionStats", question_results as "questionResults",
|
||||
wrong_question_ids as "wrongQuestionIds", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.practice_session_reports
|
||||
where tenant_id = $1 and user_id = $2 and practice_session_id = $3
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, practiceSessionId],
|
||||
);
|
||||
if (!report) {
|
||||
throw new HttpError(404, 'Practice session report not found', 'PRACTICE_REPORT_NOT_FOUND');
|
||||
}
|
||||
|
||||
return { item: formatReport(report) };
|
||||
}
|
||||
|
||||
export async function practiceReportsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const blueprintId = stringParam(ctx, 'blueprintId');
|
||||
const mode = stringParam(ctx, 'mode');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query<PracticeSessionReportRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId", user_id as "userId",
|
||||
practice_session_id as "practiceSessionId",
|
||||
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
|
||||
total_questions as "totalQuestions", answered_count as "answeredCount",
|
||||
correct_count as "correctCount", wrong_count as "wrongCount",
|
||||
unanswered_count as "unansweredCount",
|
||||
score, total_score as "totalScore", accuracy,
|
||||
duration_seconds as "durationSeconds",
|
||||
started_at as "startedAt", submitted_at as "submittedAt",
|
||||
section_stats as "sectionStats", question_results as "questionResults",
|
||||
wrong_question_ids as "wrongQuestionIds", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.practice_session_reports
|
||||
where tenant_id = $1 and user_id = $2
|
||||
and ($3::uuid is null or blueprint_id = $3::uuid)
|
||||
and ($4::text = '' or mode = $4::text)
|
||||
order by submitted_at desc
|
||||
limit $5
|
||||
`,
|
||||
[tenantId, userId, blueprintId || null, mode, limit],
|
||||
);
|
||||
|
||||
return { items: items.map(formatReport) };
|
||||
}
|
||||
|
||||
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
Reference in New Issue
Block a user