import { z } from "zod"; import { eq, and, desc, sql, like, or, inArray } from "drizzle-orm"; import { router, protectedProcedure, publicProcedure } from "../index"; import { db } from "@pram/db"; import { question, examType, sectionType, user } from "@pram/db"; import { paginationSchema, paginateDefaults } from "../lib/pagination"; import { buildVisibilityCondition } from "../lib/visibility"; import { assertOwnership } from "../lib/ownership"; import { throwNotFound } from "../lib/errors"; const questionListSelect = { id: question.id, examTypeId: question.examTypeId, sectionTypeId: question.sectionTypeId, format: question.format, passageText: question.passageText, questionText: question.questionText, options: question.options, correctAnswer: question.correctAnswer, explanation: question.explanation, difficulty: question.difficulty, skillTags: question.skillTags, source: question.source, aiModel: question.aiModel, creatorUserId: question.creatorUserId, isPublic: question.isPublic, usageCount: question.usageCount, avgRating: question.avgRating, createdAt: question.createdAt, updatedAt: question.updatedAt, creatorName: user.name, examTypeName: examType.name, sectionTypeName: sectionType.name, }; const questionDetailSelect = { ...questionListSelect, }; function buildSearchCondition(term: string) { const pattern = `%${term}%`; return or( like(question.passageText, pattern), like(question.questionText, pattern), like(question.explanation, pattern), ); } function stripAnswer(row: T) { const { correctAnswer, explanation, ...rest } = row; return rest as Omit; } export const questionRouter = router({ list: publicProcedure .input( z .object({ examTypeId: z.string().optional(), sectionTypeId: z.string().optional(), format: z.string().optional(), difficulty: z.number().optional(), isPublic: z.boolean().optional(), creatorUserId: z.string().optional(), search: z.string().optional(), skillTags: z.array(z.string()).optional(), ...paginationSchema?.shape, }) .optional(), ) .query(async ({ ctx, input }) => { const userId = ctx.session?.user.id; const conditions = []; if (input?.examTypeId) conditions.push(eq(question.examTypeId, input.examTypeId)); if (input?.sectionTypeId) conditions.push(eq(question.sectionTypeId, input.sectionTypeId)); if (input?.format) conditions.push(eq(question.format, input.format)); if (input?.difficulty) conditions.push(eq(question.difficulty, input.difficulty)); if (input?.skillTags?.length) { conditions.push(sql`${question.skillTags} && ${input.skillTags}`); } if (input?.creatorUserId) { conditions.push(eq(question.creatorUserId, input.creatorUserId)); } if (input?.isPublic !== undefined) { conditions.push(eq(question.isPublic, input.isPublic)); } else if (!input?.creatorUserId) { const vis = buildVisibilityCondition(question, userId); if (vis) conditions.push(vis); } if (input?.search) { const searchCond = buildSearchCondition(input.search); if (searchCond) conditions.push(searchCond); } const where = conditions.length > 0 ? and(...conditions) : undefined; const { limit, offset } = paginateDefaults(input); const rows = await db .select(questionListSelect) .from(question) .leftJoin(user, eq(question.creatorUserId, user.id)) .leftJoin(examType, eq(question.examTypeId, examType.id)) .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id)) .where(where) .orderBy(desc(question.createdAt)) .limit(limit) .offset(offset); const [countResult] = await db .select({ count: sql`count(*)` }) .from(question) .where(where); return { questions: rows.map(stripAnswer), total: Number(countResult?.count ?? 0) }; }), myQuestions: protectedProcedure .input( z .object({ search: z.string().optional(), ...paginationSchema?.shape, }) .optional(), ) .query(async ({ ctx, input }) => { const userId = ctx.session.user.id; const conditions = [eq(question.creatorUserId, userId)]; if (input?.search) { const searchCond = buildSearchCondition(input.search); if (searchCond) conditions.push(searchCond); } const where = and(...conditions); const { limit, offset } = paginateDefaults(input); const rows = await db .select({ id: question.id, examTypeId: question.examTypeId, sectionTypeId: question.sectionTypeId, format: question.format, passageText: question.passageText, questionText: question.questionText, options: question.options, correctAnswer: question.correctAnswer, explanation: question.explanation, difficulty: question.difficulty, skillTags: question.skillTags, source: question.source, isPublic: question.isPublic, usageCount: question.usageCount, avgRating: question.avgRating, createdAt: question.createdAt, updatedAt: question.updatedAt, examTypeName: examType.name, sectionTypeName: sectionType.name, }) .from(question) .leftJoin(examType, eq(question.examTypeId, examType.id)) .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id)) .where(where) .orderBy(desc(question.createdAt)) .limit(limit) .offset(offset); const [countResult] = await db .select({ count: sql`count(*)` }) .from(question) .where(where); return { questions: rows, total: Number(countResult?.count ?? 0) }; }), getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const userId = ctx.session?.user.id; const [row] = await db .select(questionDetailSelect) .from(question) .leftJoin(user, eq(question.creatorUserId, user.id)) .leftJoin(examType, eq(question.examTypeId, examType.id)) .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id)) .where(eq(question.id, input.id)) .limit(1); if (!row) return null; if (!row.isPublic && row.creatorUserId !== userId) return null; if (row.creatorUserId === userId) return row; return stripAnswer(row); }), create: protectedProcedure .input( z.object({ examTypeId: z.string(), sectionTypeId: z.string(), format: z.string(), passageText: z.string().min(1), questionText: z.string().min(1), options: z.any().optional(), correctAnswer: z.string(), explanation: z.string().optional(), difficulty: z.number().min(1).max(5).default(3), isCaseSensitive: z.boolean().default(false), skillTags: z.array(z.string()).default([]), isPublic: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => { const [row] = await db .insert(question) .values({ ...input, source: "manual", creatorUserId: ctx.session.user.id, }) .returning(); return row; }), update: protectedProcedure .input( z.object({ id: z.string().uuid(), examTypeId: z.string().optional(), sectionTypeId: z.string().optional(), format: z.string().optional(), passageText: z.string().min(1).optional(), questionText: z.string().min(1).optional(), options: z.any().optional(), correctAnswer: z.string().optional(), explanation: z.string().optional(), difficulty: z.number().min(1).max(5).optional(), skillTags: z.array(z.string()).optional(), isPublic: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; const [row] = await db .update(question) .set(data) .where(and(eq(question.id, id), eq(question.creatorUserId, ctx.session.user.id))) .returning(); return row ?? null; }), togglePublic: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [existing] = await db .select({ id: question.id, isPublic: question.isPublic, creatorUserId: question.creatorUserId }) .from(question) .where(eq(question.id, input.id)) .limit(1); if (!existing) { throwNotFound("Question"); } assertOwnership(existing, ctx.session.user.id, "Question"); const [row] = await db .update(question) .set({ isPublic: !existing.isPublic }) .where(eq(question.id, input.id)) .returning(); return row; }), delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const [existing] = await db .select({ id: question.id, creatorUserId: question.creatorUserId }) .from(question) .where(eq(question.id, input.id)) .limit(1); assertOwnership(existing, ctx.session.user.id, "Question"); await db.delete(question).where(eq(question.id, input.id)); return { success: true }; }), bulkPublish: protectedProcedure .input(z.object({ ids: z.array(z.string().uuid()) })) .mutation(async ({ ctx, input }) => { const rows = await db .select({ id: question.id, creatorUserId: question.creatorUserId, isPublic: question.isPublic }) .from(question) .where(inArray(question.id, input.ids)); const ownRows = rows.filter((r) => r.creatorUserId === ctx.session.user.id); const skipped = rows.length - ownRows.length; if (ownRows.length === 0) { throwNotFound("Question"); } const ownIds = ownRows.map((r) => r.id); await db .update(question) .set({ isPublic: true }) .where(inArray(question.id, ownIds)); return { success: true, updated: ownRows.length, skipped }; }), });