| import { z } from "zod"; |
| import { eq, and, desc, sql, inArray } from "drizzle-orm"; |
| import { router, protectedProcedure, publicProcedure } from "../index"; |
| import { db } from "@pram/db"; |
| import { |
| comboPackage, |
| comboSection, |
| testPackage, |
| packageSection, |
| sectionQuestion, |
| question, |
| examType, |
| sectionType, |
| user, |
| } from "@pram/db"; |
| import { paginationSchema, paginateDefaults } from "../lib/pagination"; |
| import { assertOwnership } from "../lib/ownership"; |
| import { buildVisibilityCondition } from "../lib/visibility"; |
| import { throwBadRequest } from "../lib/errors"; |
|
|
| export const comboRouter = router({ |
| list: publicProcedure |
| .input( |
| z |
| .object({ |
| isPublic: z.boolean().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 = []; |
|
|
| if (input?.isPublic !== undefined) { |
| conditions.push(eq(comboPackage.isPublic, input.isPublic)); |
| } else { |
| const vis = buildVisibilityCondition(comboPackage, userId); |
| if (vis) conditions.push(vis); |
| } |
|
|
| const where = conditions.length > 0 ? and(...conditions) : undefined; |
|
|
| const rows = await db |
| .select({ |
| id: comboPackage.id, |
| title: comboPackage.title, |
| description: comboPackage.description, |
| creatorUserId: comboPackage.creatorUserId, |
| isPublic: comboPackage.isPublic, |
| createdAt: comboPackage.createdAt, |
| updatedAt: comboPackage.updatedAt, |
| creatorName: user.name, |
| }) |
| .from(comboPackage) |
| .leftJoin(user, eq(comboPackage.creatorUserId, user.id)) |
| .where(where) |
| .orderBy(desc(comboPackage.createdAt)) |
| .limit(limit) |
| .offset(offset); |
|
|
| const [countResult] = await db |
| .select({ count: sql<number>`count(*)` }) |
| .from(comboPackage) |
| .where(where); |
|
|
| return { combos: rows, total: Number(countResult?.count ?? 0) }; |
| }), |
|
|
| myCombos: protectedProcedure |
| .input( |
| z |
| .object({ |
| 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(comboPackage.creatorUserId, userId)]; |
| const where = and(...conditions); |
|
|
| const rows = await db |
| .select({ |
| id: comboPackage.id, |
| title: comboPackage.title, |
| description: comboPackage.description, |
| isPublic: comboPackage.isPublic, |
| createdAt: comboPackage.createdAt, |
| updatedAt: comboPackage.updatedAt, |
| }) |
| .from(comboPackage) |
| .where(where) |
| .orderBy(desc(comboPackage.createdAt)) |
| .limit(limit) |
| .offset(offset); |
|
|
| const [countResult] = await db |
| .select({ count: sql<number>`count(*)` }) |
| .from(comboPackage) |
| .where(where); |
|
|
| return { combos: 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 [combo] = await db |
| .select({ |
| id: comboPackage.id, |
| title: comboPackage.title, |
| description: comboPackage.description, |
| creatorUserId: comboPackage.creatorUserId, |
| isPublic: comboPackage.isPublic, |
| createdAt: comboPackage.createdAt, |
| updatedAt: comboPackage.updatedAt, |
| creatorName: user.name, |
| }) |
| .from(comboPackage) |
| .leftJoin(user, eq(comboPackage.creatorUserId, user.id)) |
| .where(eq(comboPackage.id, input.id)) |
| .limit(1); |
|
|
| if (!combo) return null; |
| if (!combo.isPublic && combo.creatorUserId !== userId) return null; |
|
|
| |
| const sections = await db |
| .select({ |
| id: comboSection.id, |
| comboId: comboSection.comboId, |
| sourcePackageId: comboSection.sourcePackageId, |
| sourceSectionId: comboSection.sourceSectionId, |
| orderIndex: comboSection.orderIndex, |
| packageTitle: testPackage.title, |
| sectionTitle: packageSection.title, |
| sectionTypeName: sectionType.name, |
| examTypeName: examType.name, |
| }) |
| .from(comboSection) |
| .leftJoin(testPackage, eq(comboSection.sourcePackageId, testPackage.id)) |
| .leftJoin(packageSection, eq(comboSection.sourceSectionId, packageSection.id)) |
| .leftJoin(sectionType, eq(packageSection.sectionTypeId, sectionType.id)) |
| .leftJoin(examType, eq(testPackage.examTypeId, examType.id)) |
| .where(eq(comboSection.comboId, input.id)) |
| .orderBy(comboSection.orderIndex); |
|
|
| |
| const sectionIds = sections.map((s) => s.sourceSectionId); |
| let sectionQuestions: Array<{ |
| sectionId: string; |
| questionId: string; |
| orderIndex: number; |
| question: any; |
| }> = []; |
|
|
| if (sectionIds.length > 0) { |
| const sqs = await db |
| .select({ |
| sectionId: sectionQuestion.sectionId, |
| questionId: sectionQuestion.questionId, |
| orderIndex: sectionQuestion.orderIndex, |
| }) |
| .from(sectionQuestion) |
| .where(inArray(sectionQuestion.sectionId, sectionIds)); |
|
|
| if (sqs.length > 0) { |
| const questionIds = sqs.map((sq) => sq.questionId); |
| const qs = await db |
| .select() |
| .from(question) |
| .where(inArray(question.id, questionIds)); |
|
|
| const questionMap = new Map(qs.map((q) => [q.id, q])); |
| sectionQuestions = sqs.map((sq) => ({ |
| ...sq, |
| question: questionMap.get(sq.questionId), |
| })); |
| } |
| } |
|
|
| const isOwner = combo.creatorUserId === userId; |
|
|
| const sectionsWithQuestions = sections.map((section) => ({ |
| ...section, |
| questions: sectionQuestions |
| .filter((sq) => sq.sectionId === section.sourceSectionId) |
| .sort((a, b) => a.orderIndex - b.orderIndex) |
| .map((sq) => { |
| if (isOwner) return sq.question; |
| const { correctAnswer, explanation, ...rest } = sq.question; |
| return rest; |
| }), |
| })); |
|
|
| return { ...combo, sections: sectionsWithQuestions }; |
| }), |
|
|
| create: protectedProcedure |
| .input( |
| z.object({ |
| title: z.string().min(1).max(200), |
| description: z.string().optional(), |
| isPublic: z.boolean().default(false), |
| sections: z.array( |
| z.object({ |
| sourcePackageId: z.string().uuid(), |
| sourceSectionId: z.string().uuid(), |
| orderIndex: z.number().default(0), |
| }), |
| ).min(1), |
| }), |
| ) |
| .mutation(async ({ ctx, input }) => { |
| const { sections, ...pkgData } = input; |
|
|
| |
| const [combo] = await db |
| .insert(comboPackage) |
| .values({ |
| ...pkgData, |
| creatorUserId: ctx.session.user.id, |
| }) |
| .returning(); |
|
|
| if (!combo) { |
| throwBadRequest("Failed to create combo package"); |
| } |
|
|
| await db.insert(comboSection).values( |
| sections.map((s) => ({ |
| comboId: combo.id, |
| ...s, |
| })), |
| ); |
|
|
| |
| const firstSourcePkgId = sections[0]?.sourcePackageId; |
| let examTypeId = ""; |
| if (firstSourcePkgId) { |
| const [srcPkg] = await db |
| .select({ examTypeId: testPackage.examTypeId }) |
| .from(testPackage) |
| .where(eq(testPackage.id, firstSourcePkgId)) |
| .limit(1); |
| examTypeId = srcPkg?.examTypeId ?? ""; |
| } |
|
|
| |
| const [pkg] = await db |
| .insert(testPackage) |
| .values({ |
| title: pkgData.title, |
| description: pkgData.description, |
| examTypeId, |
| creatorUserId: ctx.session.user.id, |
| isPublic: pkgData.isPublic, |
| totalQuestions: 0, |
| totalSections: 0, |
| estimatedDurationMin: sections.length * 20, |
| }) |
| .returning(); |
|
|
| if (pkg) { |
| |
| const sourceSectionIds = sections.map((s) => s.sourceSectionId); |
| const sourceSections = sourceSectionIds.length > 0 |
| ? await db |
| .select() |
| .from(packageSection) |
| .where(inArray(packageSection.id, sourceSectionIds)) |
| : []; |
|
|
| const sourceSectionMap = new Map(sourceSections.map((s) => [s.id, s])); |
|
|
| let totalQuestions = 0; |
|
|
| for (const secInput of sections) { |
| const srcSec = sourceSectionMap.get(secInput.sourceSectionId); |
| if (!srcSec) continue; |
|
|
| const [newSec] = await db |
| .insert(packageSection) |
| .values({ |
| packageId: pkg.id, |
| sectionTypeId: srcSec.sectionTypeId, |
| title: srcSec.title, |
| orderIndex: secInput.orderIndex, |
| }) |
| .returning(); |
|
|
| |
| const srcQuestions = await db |
| .select() |
| .from(sectionQuestion) |
| .where(eq(sectionQuestion.sectionId, secInput.sourceSectionId)) |
| .orderBy(sectionQuestion.orderIndex); |
|
|
| if (newSec && srcQuestions.length > 0) { |
| await db.insert(sectionQuestion).values( |
| srcQuestions.map((sq, idx) => ({ |
| sectionId: newSec.id, |
| questionId: sq.questionId, |
| orderIndex: idx, |
| })), |
| ); |
| totalQuestions += srcQuestions.length; |
| } |
| } |
|
|
| |
| await db |
| .update(testPackage) |
| .set({ |
| totalSections: sections.length, |
| totalQuestions, |
| }) |
| .where(eq(testPackage.id, pkg.id)); |
| } |
|
|
| return { ...combo, packageId: pkg?.id }; |
| }), |
|
|
| update: protectedProcedure |
| .input( |
| z.object({ |
| id: z.string().uuid(), |
| title: z.string().min(1).max(200).optional(), |
| description: z.string().optional(), |
| isPublic: z.boolean().optional(), |
| }), |
| ) |
| .mutation(async ({ ctx, input }) => { |
| const { id, ...data } = input; |
| const [combo] = await db |
| .select() |
| .from(comboPackage) |
| .where(eq(comboPackage.id, id)) |
| .limit(1); |
|
|
| assertOwnership(combo, ctx.session.user.id, "Combo"); |
|
|
| const [updated] = await db |
| .update(comboPackage) |
| .set(data) |
| .where(eq(comboPackage.id, id)) |
| .returning(); |
| return updated ?? null; |
| }), |
|
|
| delete: protectedProcedure |
| .input(z.object({ id: z.string().uuid() })) |
| .mutation(async ({ ctx, input }) => { |
| const [combo] = await db |
| .select() |
| .from(comboPackage) |
| .where(eq(comboPackage.id, input.id)) |
| .limit(1); |
|
|
| assertOwnership(combo, ctx.session.user.id, "Combo"); |
|
|
| await db.delete(comboPackage).where(eq(comboPackage.id, input.id)); |
| return { success: true }; |
| }), |
|
|
| |
| availableSections: protectedProcedure |
| .input( |
| z.object({ |
| 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 pkgConditions = []; |
| const vis = buildVisibilityCondition(testPackage, userId); |
| if (vis) pkgConditions.push(vis); |
|
|
| if (input?.examTypeId) { |
| pkgConditions.push(eq(testPackage.examTypeId, input.examTypeId)); |
| } |
|
|
| const pkgWhere = pkgConditions.length > 0 ? and(...pkgConditions) : undefined; |
|
|
| const packages = await db |
| .select({ |
| id: testPackage.id, |
| title: testPackage.title, |
| examTypeId: testPackage.examTypeId, |
| creatorUserId: testPackage.creatorUserId, |
| isPublic: testPackage.isPublic, |
| examTypeName: examType.name, |
| }) |
| .from(testPackage) |
| .leftJoin(examType, eq(testPackage.examTypeId, examType.id)) |
| .where(pkgWhere) |
| .limit(limit) |
| .offset(offset); |
|
|
| const packageIds = packages.map((p) => p.id); |
|
|
| const sections = await db |
| .select({ |
| id: packageSection.id, |
| packageId: packageSection.packageId, |
| sectionTypeId: packageSection.sectionTypeId, |
| title: packageSection.title, |
| orderIndex: packageSection.orderIndex, |
| packageTitle: testPackage.title, |
| examTypeName: examType.name, |
| sectionTypeName: sectionType.name, |
| }) |
| .from(packageSection) |
| .leftJoin(testPackage, eq(packageSection.packageId, testPackage.id)) |
| .leftJoin(examType, eq(testPackage.examTypeId, examType.id)) |
| .leftJoin(sectionType, eq(packageSection.sectionTypeId, sectionType.id)) |
| .where(inArray(packageSection.packageId, packageIds)) |
| .orderBy(testPackage.title, packageSection.orderIndex); |
|
|
| return { packages, sections }; |
| }), |
| }); |
|
|
|
|