| import { z } from "zod"; |
| import { eq, and, desc, sql, inArray, ilike } from "drizzle-orm"; |
| import { router, protectedProcedure } from "../index"; |
| import { db } from "@pram/db"; |
| import { |
| testAttempt, |
| sectionResult, |
| answer, |
| testPackage, |
| packageSection, |
| sectionQuestion, |
| question, |
| examType, |
| } from "@pram/db"; |
| import { paginationSchema, paginateDefaults } from "../lib/pagination"; |
| import { assertOwnership } from "../lib/ownership"; |
| import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors"; |
|
|
| function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string, isCaseSensitive = false): boolean { |
| const ua = userAnswer.trim(); |
| const ca = correctAnswer.trim(); |
|
|
| if (isCaseSensitive) { |
| return ua === ca; |
| } |
|
|
| switch (format) { |
| case "true_false_not_given": |
| case "author_view": |
| return ua.toUpperCase() === ca.toUpperCase(); |
| case "fill_blank": |
| return ua.toLowerCase() === ca.toLowerCase(); |
| case "multiple_choice": |
| case "synonym": |
| case "grammar_in_context": |
| case "sentence_completion": |
| case "summary_completion": |
| case "cloze": |
| case "reference": |
| case "kanji_reading": |
| case "particle_choice": |
| case "article_case": |
| case "character_reading": |
| case "sentence_arrangement": |
| case "matching_headings": |
| case "matching_information": |
| case "matching_pairs": |
| case "error_recognition": |
| case "text_insertion": |
| return ua.toUpperCase() === ca.toUpperCase(); |
| default: |
| return ua === ca; |
| } |
| } |
|
|
| |
| |
| function calculatePartialCredit(format: string, userAnswer: string, correctAnswer: string): number | null { |
| if (format !== "sentence_arrangement" && format !== "matching_pairs" && format !== "cloze") { |
| return null; |
| } |
|
|
| try { |
| const ua = userAnswer.trim(); |
| const ca = correctAnswer.trim(); |
| if (!ua || !ca) return null; |
|
|
| if (format === "sentence_arrangement") { |
| |
| const userParts = ua.split(",").map((s) => s.trim()); |
| const correctParts = ca.split(",").map((s) => s.trim()); |
| if (userParts.length !== correctParts.length) return 0; |
| const correctCount = userParts.filter((p, i) => p === correctParts[i]).length; |
| return Math.round((correctCount / correctParts.length) * 100); |
| } |
|
|
| if (format === "matching_pairs") { |
| |
| const parseMapping = (s: string): Map<string, string> => { |
| const map = new Map(); |
| s.split(",").forEach((pair) => { |
| const [k, v] = pair.split(":").map((x) => x.trim()); |
| if (k && v) map.set(k, v); |
| }); |
| return map; |
| }; |
| const userMap = parseMapping(ua); |
| const correctMap = parseMapping(ca); |
| let correctCount = 0; |
| for (const [key, value] of correctMap) { |
| if (userMap.get(key) === value) correctCount++; |
| } |
| return correctMap.size > 0 ? Math.round((correctCount / correctMap.size) * 100) : null; |
| } |
|
|
| if (format === "cloze") { |
| |
| return calculatePartialCredit("sentence_arrangement", ua, ca); |
| } |
| } catch { |
| return null; |
| } |
| return null; |
| } |
|
|
| |
| export function validateWordCount(answer: string, minWords = 1, maxWords = 500): { isValid: boolean; wordCount: number } { |
| const trimmed = answer.trim(); |
| if (!trimmed) return { isValid: false, wordCount: 0 }; |
| const words = trimmed.split(/\s+/).filter(Boolean); |
| return { isValid: words.length >= minWords && words.length <= maxWords, wordCount: words.length }; |
| } |
|
|
| |
| const rateLimitMap = new Map<string, number>(); |
| let rateLimitCleanup: ReturnType<typeof setInterval> | null = null; |
|
|
| function startRateLimitCleanup() { |
| if (rateLimitCleanup) return; |
| rateLimitCleanup = setInterval(() => { |
| const cutoff = Date.now() - 60_000; |
| for (const [key, time] of rateLimitMap) { |
| if (time < cutoff) rateLimitMap.delete(key); |
| } |
| }, 60_000); |
| } |
|
|
| function checkRateLimit(key: string, windowMs: number) { |
| startRateLimitCleanup(); |
| const now = Date.now(); |
| const last = rateLimitMap.get(key) ?? 0; |
| if (now - last < windowMs) { |
| throwBadRequest("Terlalu banyak permintaan. Coba lagi nanti."); |
| } |
| rateLimitMap.set(key, now); |
| } |
|
|
| export const attemptRouter = router({ |
| start: protectedProcedure |
| .input(z.object({ packageId: z.string().uuid() })) |
| .mutation(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
| checkRateLimit(`start:${userId}`, 3000); |
|
|
| |
| const [activeAttempt] = await db |
| .select({ id: testAttempt.id }) |
| .from(testAttempt) |
| .where( |
| and( |
| eq(testAttempt.userId, userId), |
| eq(testAttempt.packageId, input.packageId), |
| eq(testAttempt.status, "in_progress"), |
| ), |
| ) |
| .limit(1); |
| if (activeAttempt) { |
| throwBadRequest("Kamu masih punya latihan yang sedang berjalan untuk paket ini"); |
| } |
|
|
| |
| const [recentAttempt] = await db |
| .select({ finishedAt: testAttempt.finishedAt }) |
| .from(testAttempt) |
| .where( |
| and( |
| eq(testAttempt.userId, userId), |
| eq(testAttempt.packageId, input.packageId), |
| eq(testAttempt.status, "completed"), |
| ), |
| ) |
| .orderBy(desc(testAttempt.finishedAt)) |
| .limit(1); |
| if (recentAttempt?.finishedAt) { |
| const elapsed = Date.now() - new Date(recentAttempt.finishedAt).getTime(); |
| if (elapsed < 10_000) { |
| throwBadRequest("Tunggu 10 detik sebelum memulai latihan ulang"); |
| } |
| } |
|
|
| const [pkg] = await db |
| .select() |
| .from(testPackage) |
| .where(eq(testPackage.id, input.packageId)) |
| .limit(1); |
|
|
| if (!pkg?.isPublic) { |
| assertOwnership(pkg, userId, "Package"); |
| } |
|
|
| const sections = await db |
| .select({ |
| id: packageSection.id, |
| sectionTypeId: packageSection.sectionTypeId, |
| }) |
| .from(packageSection) |
| .where(eq(packageSection.packageId, input.packageId)) |
| .orderBy(packageSection.orderIndex); |
|
|
| const [attempt] = await db |
| .insert(testAttempt) |
| .values({ |
| userId, |
| packageId: input.packageId, |
| status: "in_progress", |
| }) |
| .returning(); |
|
|
| if (!attempt) throwBadRequest("Failed to create attempt"); |
|
|
| for (const section of sections) { |
| await db.insert(sectionResult).values({ |
| attemptId: attempt.id, |
| sectionTypeId: section.sectionTypeId, |
| }); |
| } |
|
|
| await db |
| .update(testPackage) |
| .set({ usageCount: sql`${testPackage.usageCount} + 1` }) |
| .where(eq(testPackage.id, input.packageId)); |
|
|
| return { attemptId: attempt.id }; |
| }), |
|
|
| getById: protectedProcedure |
| .input(z.object({ id: z.string().uuid() })) |
| .query(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
|
|
| const [attempt] = await db |
| .select() |
| .from(testAttempt) |
| .where(eq(testAttempt.id, input.id)) |
| .limit(1); |
|
|
| if (!attempt) return null; |
| if (attempt.userId !== userId) throwForbidden(); |
|
|
| const isInProgress = attempt.status === "in_progress"; |
|
|
| const dbSections = await db |
| .select() |
| .from(sectionResult) |
| .where(eq(sectionResult.attemptId, input.id)) |
| .orderBy(sectionResult.createdAt); |
|
|
| const sectionResultIds = dbSections.map((s) => s.id); |
|
|
| let answers: any[] = []; |
| if (sectionResultIds.length > 0) { |
| answers = await db |
| .select({ |
| id: answer.id, |
| sectionResultId: answer.sectionResultId, |
| questionId: answer.questionId, |
| userAnswer: answer.userAnswer, |
| isCorrect: answer.isCorrect, |
| partialScore: answer.partialScore, |
| timeSpentSec: answer.timeSpentSec, |
| createdAt: answer.createdAt, |
| question: { |
| id: question.id, |
| format: question.format, |
| passageText: question.passageText, |
| questionText: question.questionText, |
| options: question.options, |
| correctAnswer: question.correctAnswer, |
| explanation: question.explanation, |
| difficulty: question.difficulty, |
| skillTags: question.skillTags, |
| }, |
| }) |
| .from(answer) |
| .innerJoin(question, eq(answer.questionId, question.id)) |
| .where(inArray(answer.sectionResultId, sectionResultIds)); |
| } |
|
|
| const pkgSections = await db |
| .select({ |
| id: packageSection.id, |
| sectionTypeId: packageSection.sectionTypeId, |
| title: packageSection.title, |
| orderIndex: packageSection.orderIndex, |
| }) |
| .from(packageSection) |
| .where(eq(packageSection.packageId, attempt.packageId!)) |
| .orderBy(packageSection.orderIndex); |
|
|
| const psIds = pkgSections.map((s) => s.id); |
| let sectionQuestions: any[] = []; |
| if (psIds.length > 0) { |
| const sqs = await db |
| .select({ |
| sectionId: sectionQuestion.sectionId, |
| questionId: sectionQuestion.questionId, |
| orderIndex: sectionQuestion.orderIndex, |
| }) |
| .from(sectionQuestion) |
| .where(inArray(sectionQuestion.sectionId, psIds)); |
|
|
| if (sqs.length > 0) { |
| const qIds = sqs.map((sq) => sq.questionId); |
| const qs = await db |
| .select() |
| .from(question) |
| .where(inArray(question.id, qIds)); |
|
|
| const qMap = new Map(qs.map((q) => [q.id, q])); |
| sectionQuestions = sqs.map((sq) => ({ |
| ...sq, |
| question: qMap.get(sq.questionId), |
| })); |
| } |
| } |
|
|
| |
| if (isInProgress) { |
| const sanitizeQuestion = (q: any) => { |
| if (!q) return q; |
| const { correctAnswer, explanation, ...rest } = q; |
| return rest; |
| }; |
|
|
| const sanitizeAnswer = (a: any) => { |
| if (!a) return a; |
| const { isCorrect, partialScore, ...rest } = a; |
| return rest; |
| }; |
|
|
| answers = answers.map((a) => ({ |
| ...sanitizeAnswer(a), |
| question: sanitizeQuestion(a.question), |
| })); |
|
|
| sectionQuestions = sectionQuestions.map((sq) => ({ |
| ...sq, |
| question: sanitizeQuestion(sq.question), |
| })); |
| } |
|
|
| |
| const sectionsWithData = pkgSections.map((pkgSec, idx) => { |
| const secResult = dbSections[idx]; |
| const secAnswers = answers.filter((a) => a.sectionResultId === secResult?.id); |
| const secQuestions = sectionQuestions |
| .filter((sq) => sq.sectionId === pkgSec.id) |
| .sort((a, b) => a.orderIndex - b.orderIndex) |
| .map((sq) => sq.question); |
|
|
| return { |
| ...pkgSec, |
| sectionResultId: secResult?.id, |
| score: secResult?.score, |
| maxScore: secResult?.maxScore, |
| timeSpentSec: secResult?.timeSpentSec, |
| answers: secAnswers, |
| questions: secQuestions, |
| }; |
| }); |
|
|
| return { |
| ...attempt, |
| sections: sectionsWithData, |
| }; |
| }), |
|
|
| submitAnswer: protectedProcedure |
| .input( |
| z.object({ |
| attemptId: z.string().uuid(), |
| sectionResultId: z.string().uuid(), |
| questionId: z.string().uuid(), |
| userAnswer: z.string(), |
| timeSpentSec: z.number().optional(), |
| }), |
| ) |
| .mutation(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
| checkRateLimit(`submit:${userId}`, 500); |
|
|
| const [attempt] = await db |
| .select() |
| .from(testAttempt) |
| .where(eq(testAttempt.id, input.attemptId)) |
| .limit(1); |
|
|
| if (!attempt) throwNotFound("Attempt"); |
| if (attempt.userId !== userId) throwForbidden(); |
| if (attempt.status !== "in_progress") throwBadRequest("Attempt already finished"); |
|
|
| const [q] = await db |
| .select() |
| .from(question) |
| .where(eq(question.id, input.questionId)) |
| .limit(1); |
|
|
| if (!q) throwNotFound("Question"); |
|
|
| const [existing] = await db |
| .select() |
| .from(answer) |
| .where( |
| and( |
| eq(answer.sectionResultId, input.sectionResultId), |
| eq(answer.questionId, input.questionId), |
| ), |
| ) |
| .limit(1); |
|
|
| if (existing) { |
| await db |
| .update(answer) |
| .set({ |
| userAnswer: input.userAnswer, |
| |
| timeSpentSec: input.timeSpentSec, |
| }) |
| .where(eq(answer.id, existing.id)); |
| } else { |
| await db.insert(answer).values({ |
| sectionResultId: input.sectionResultId, |
| questionId: input.questionId, |
| userAnswer: input.userAnswer, |
| |
| timeSpentSec: input.timeSpentSec, |
| }); |
| } |
|
|
| return { success: true }; |
| }), |
|
|
| finish: protectedProcedure |
| .input(z.object({ attemptId: z.string().uuid() })) |
| .mutation(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
| checkRateLimit(`finish:${userId}`, 3000); |
|
|
| const [attempt] = await db |
| .select() |
| .from(testAttempt) |
| .where(eq(testAttempt.id, input.attemptId)) |
| .limit(1); |
|
|
| if (!attempt) throwNotFound("Attempt"); |
| if (attempt.userId !== userId) throwForbidden(); |
| if (attempt.status !== "in_progress") throwBadRequest("Attempt already finished"); |
|
|
| |
| if (attempt.startedAt) { |
| const elapsedSec = Math.round((Date.now() - new Date(attempt.startedAt).getTime()) / 1000); |
| if (elapsedSec < 5) { |
| throwBadRequest("Latihan terlalu cepat. Harap kerjakan soal dengan benar."); |
| } |
| } |
|
|
| const dbSections = await db |
| .select() |
| .from(sectionResult) |
| .where(eq(sectionResult.attemptId, input.attemptId)) |
| .orderBy(sectionResult.createdAt); |
|
|
| const pkgSections = await db |
| .select({ id: packageSection.id }) |
| .from(packageSection) |
| .where(eq(packageSection.packageId, attempt.packageId!)) |
| .orderBy(packageSection.orderIndex); |
|
|
| |
| const psIds = pkgSections.map((s) => s.id); |
| let questionCounts = new Map<string, number>(); |
| if (psIds.length > 0) { |
| const counts = await db |
| .select({ |
| sectionId: sectionQuestion.sectionId, |
| count: sql<number>`count(*)::int`, |
| }) |
| .from(sectionQuestion) |
| .where(inArray(sectionQuestion.sectionId, psIds)) |
| .groupBy(sectionQuestion.sectionId); |
|
|
| for (const c of counts) { |
| questionCounts.set(c.sectionId, Number(c.count)); |
| } |
| } |
|
|
| let totalScore = 0; |
| const allQuestionIds = new Set<string>(); |
|
|
| for (let i = 0; i < dbSections.length; i++) { |
| const secResult = dbSections[i]; |
| const pkgSec = pkgSections[i]; |
| if (!secResult || !pkgSec) continue; |
|
|
| const secAnswers = await db |
| .select({ |
| answerId: answer.id, |
| questionId: answer.questionId, |
| userAnswer: answer.userAnswer, |
| partialScore: answer.partialScore, |
| timeSpentSec: answer.timeSpentSec, |
| questionFormat: question.format, |
| questionCorrectAnswer: question.correctAnswer, |
| questionIsCaseSensitive: question.isCaseSensitive, |
| }) |
| .from(answer) |
| .innerJoin(question, eq(answer.questionId, question.id)) |
| .where(eq(answer.sectionResultId, secResult.id)); |
|
|
| for (const a of secAnswers) { |
| allQuestionIds.add(a.questionId); |
| } |
|
|
| |
| let sectionScore = 0; |
| for (const a of secAnswers) { |
| if (!a.userAnswer) continue; |
| const isCaseSensitive = a.questionIsCaseSensitive ?? false; |
| const isCorrect = normalizeAnswer(a.questionFormat, a.userAnswer, a.questionCorrectAnswer, isCaseSensitive); |
| const partialScore = calculatePartialCredit(a.questionFormat, a.userAnswer, a.questionCorrectAnswer); |
| const effectiveScore = isCorrect ? 1 : (partialScore != null ? partialScore / 100 : 0); |
| sectionScore += effectiveScore; |
|
|
| |
| await db |
| .update(answer) |
| .set({ isCorrect, partialScore }) |
| .where(eq(answer.id, a.answerId)); |
| } |
|
|
| const sectionMax = Number(questionCounts.get(pkgSec.id) ?? secAnswers.length); |
| const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0); |
|
|
| await db |
| .update(sectionResult) |
| .set({ |
| score: Math.round(sectionScore), |
| maxScore: sectionMax, |
| timeSpentSec: sectionTimeSpent, |
| }) |
| .where(eq(sectionResult.id, secResult.id)); |
|
|
| totalScore += sectionScore; |
| } |
|
|
| |
| if (allQuestionIds.size > 0) { |
| await db |
| .update(question) |
| .set({ usageCount: sql`${question.usageCount} + 1` }) |
| .where(inArray(question.id, Array.from(allQuestionIds))); |
| } |
|
|
| |
| let totalQuestions = 0; |
| for (const count of questionCounts.values()) { |
| totalQuestions += Number(count); |
| } |
|
|
| await db |
| .update(testAttempt) |
| .set({ |
| status: "completed", |
| finishedAt: new Date(), |
| totalScore, |
| maxScore: totalQuestions, |
| }) |
| .where(eq(testAttempt.id, input.attemptId)); |
|
|
| return { |
| totalScore, |
| maxScore: totalQuestions, |
| percentage: totalQuestions > 0 ? Math.round((totalScore / totalQuestions) * 100) : 0, |
| }; |
| }), |
|
|
| getActiveAttempt: protectedProcedure |
| .input(z.object({ packageId: z.string().uuid() })) |
| .query(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
|
|
| const [attempt] = await db |
| .select({ |
| id: testAttempt.id, |
| status: testAttempt.status, |
| startedAt: testAttempt.startedAt, |
| }) |
| .from(testAttempt) |
| .where( |
| and( |
| eq(testAttempt.userId, userId), |
| eq(testAttempt.packageId, input.packageId), |
| eq(testAttempt.status, "in_progress"), |
| ), |
| ) |
| .orderBy(desc(testAttempt.createdAt)) |
| .limit(1); |
|
|
| return attempt ?? null; |
| }), |
|
|
| abandon: protectedProcedure |
| .input(z.object({ attemptId: z.string().uuid() })) |
| .mutation(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
|
|
| const [attempt] = await db |
| .select() |
| .from(testAttempt) |
| .where(eq(testAttempt.id, input.attemptId)) |
| .limit(1); |
|
|
| if (!attempt) throwNotFound("Attempt"); |
| if (attempt.userId !== userId) throwForbidden(); |
| if (attempt.status !== "in_progress") throwBadRequest("Attempt not in progress"); |
|
|
| await db |
| .update(testAttempt) |
| .set({ status: "abandoned" }) |
| .where(eq(testAttempt.id, input.attemptId)); |
|
|
| return { success: true }; |
| }), |
|
|
| myAttempts: protectedProcedure |
| .input( |
| z |
| .object({ |
| packageId: z.string().uuid().optional(), |
| examTypeId: z.string().optional(), |
| search: z.string().optional(), |
| ...paginationSchema.shape, |
| }) |
| .optional(), |
| ) |
| .query(async ({ ctx, input }) => { |
| const userId = ctx.session.user.id; |
| const { limit, offset } = paginateDefaults(input); |
| const conditions = [eq(testAttempt.userId, userId)]; |
|
|
| if (input?.packageId) { |
| conditions.push(eq(testAttempt.packageId, input.packageId)); |
| } |
| if (input?.examTypeId) { |
| conditions.push(eq(testPackage.examTypeId, input.examTypeId)); |
| } |
| if (input?.search) { |
| conditions.push(ilike(testPackage.title, `%${input.search}%`)); |
| } |
|
|
| const where = and(...conditions); |
|
|
| const rows = await db |
| .select({ |
| id: testAttempt.id, |
| packageId: testAttempt.packageId, |
| comboId: testAttempt.comboId, |
| startedAt: testAttempt.startedAt, |
| finishedAt: testAttempt.finishedAt, |
| totalScore: testAttempt.totalScore, |
| maxScore: testAttempt.maxScore, |
| status: testAttempt.status, |
| createdAt: testAttempt.createdAt, |
| packageTitle: testPackage.title, |
| packageExamTypeId: testPackage.examTypeId, |
| examTypeName: examType.name, |
| }) |
| .from(testAttempt) |
| .leftJoin(testPackage, eq(testAttempt.packageId, testPackage.id)) |
| .leftJoin(examType, eq(testPackage.examTypeId, examType.id)) |
| .where(where) |
| .orderBy(desc(testAttempt.createdAt)) |
| .limit(limit) |
| .offset(offset); |
|
|
| const [countResult] = await db |
| .select({ count: sql<number>`count(*)` }) |
| .from(testAttempt) |
| .leftJoin(testPackage, eq(testAttempt.packageId, testPackage.id)) |
| .where(where); |
| const totalCount = Number(countResult?.count ?? 0); |
|
|
| return { attempts: rows, total: totalCount }; |
| }), |
| }); |
|
|