cpns / packages /api /src /routers /package.ts
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
17.5 kB
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 {
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 { throwNotFound } from "../lib/errors";
export const packageRouter = router({
list: publicProcedure
.input(
z
.object({
examTypeId: z.string().optional(),
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?.examTypeId) conditions.push(eq(testPackage.examTypeId, input.examTypeId));
if (input?.isPublic !== undefined) {
conditions.push(eq(testPackage.isPublic, input.isPublic));
} else {
const vis = buildVisibilityCondition(testPackage, userId);
if (vis) conditions.push(vis);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const rows = await db
.select({
id: testPackage.id,
title: testPackage.title,
description: testPackage.description,
examTypeId: testPackage.examTypeId,
creatorUserId: testPackage.creatorUserId,
isPublic: testPackage.isPublic,
totalQuestions: testPackage.totalQuestions,
totalSections: testPackage.totalSections,
estimatedDurationMin: testPackage.estimatedDurationMin,
usageCount: testPackage.usageCount,
avgRating: testPackage.avgRating,
createdAt: testPackage.createdAt,
updatedAt: testPackage.updatedAt,
creatorName: user.name,
examTypeName: examType.name,
})
.from(testPackage)
.leftJoin(user, eq(testPackage.creatorUserId, user.id))
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
.where(where)
.orderBy(desc(testPackage.createdAt))
.limit(limit)
.offset(offset);
const [countResult] = await db
.select({ count: sql<number>`count(*)` })
.from(testPackage)
.where(where);
const totalCount = Number(countResult?.count ?? 0);
return { packages: rows, total: totalCount };
}),
myPackages: protectedProcedure
.input(
z
.object({
search: z.string().optional(),
examTypeId: z.string().optional(),
isPublic: z.boolean().optional(),
...paginationSchema.shape,
})
.optional(),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
const { limit, offset } = paginateDefaults(input);
const conditions = [eq(testPackage.creatorUserId, userId)];
if (input?.search) {
conditions.push(
sql`LOWER(${testPackage.title}) LIKE LOWER(${"%" + input.search + "%"})`,
);
}
if (input?.examTypeId) {
conditions.push(eq(testPackage.examTypeId, input.examTypeId));
}
if (input?.isPublic !== undefined) {
conditions.push(eq(testPackage.isPublic, input.isPublic));
}
const where = and(...conditions);
const rows = await db
.select({
id: testPackage.id,
title: testPackage.title,
description: testPackage.description,
examTypeId: testPackage.examTypeId,
creatorUserId: testPackage.creatorUserId,
isPublic: testPackage.isPublic,
totalQuestions: testPackage.totalQuestions,
totalSections: testPackage.totalSections,
estimatedDurationMin: testPackage.estimatedDurationMin,
usageCount: testPackage.usageCount,
avgRating: testPackage.avgRating,
createdAt: testPackage.createdAt,
updatedAt: testPackage.updatedAt,
examTypeName: examType.name,
})
.from(testPackage)
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
.where(where)
.orderBy(desc(testPackage.createdAt))
.limit(limit)
.offset(offset);
const [countResult] = await db
.select({ count: sql<number>`count(*)` })
.from(testPackage)
.where(where);
const totalCount = Number(countResult?.count ?? 0);
return { packages: rows, total: totalCount };
}),
getById: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const [pkg] = await db
.select({
id: testPackage.id,
title: testPackage.title,
description: testPackage.description,
examTypeId: testPackage.examTypeId,
creatorUserId: testPackage.creatorUserId,
isPublic: testPackage.isPublic,
totalQuestions: testPackage.totalQuestions,
totalSections: testPackage.totalSections,
estimatedDurationMin: testPackage.estimatedDurationMin,
usageCount: testPackage.usageCount,
avgRating: testPackage.avgRating,
createdAt: testPackage.createdAt,
updatedAt: testPackage.updatedAt,
creatorName: user.name,
examTypeName: examType.name,
})
.from(testPackage)
.leftJoin(user, eq(testPackage.creatorUserId, user.id))
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
.where(eq(testPackage.id, input.id))
.limit(1);
if (!pkg) return null;
if (!pkg.isPublic && pkg.creatorUserId !== userId) return null;
// Fetch sections with questions
const sections = await db
.select({
id: packageSection.id,
packageId: packageSection.packageId,
sectionTypeId: packageSection.sectionTypeId,
title: packageSection.title,
orderIndex: packageSection.orderIndex,
sectionTypeName: sectionType.name,
})
.from(packageSection)
.leftJoin(sectionType, eq(packageSection.sectionTypeId, sectionType.id))
.where(eq(packageSection.packageId, input.id))
.orderBy(packageSection.orderIndex);
const sectionIds = sections.map((s) => s.id);
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 = pkg.creatorUserId === userId;
const sectionsWithQuestions = sections.map((section) => ({
...section,
questions: sectionQuestions
.filter((sq) => sq.sectionId === section.id && sq.question != null)
.sort((a, b) => a.orderIndex - b.orderIndex)
.map((sq) => {
if (isOwner) return sq.question;
const { correctAnswer, explanation, ...rest } = sq.question;
return rest;
}),
}));
return { ...pkg, sections: sectionsWithQuestions };
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
examTypeId: z.string(),
isPublic: z.boolean().default(false),
estimatedDurationMin: z.number().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const [pkg] = await db
.insert(testPackage)
.values({
...input,
creatorUserId: ctx.session.user.id,
totalQuestions: 0,
totalSections: 0,
})
.returning();
return pkg;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
examTypeId: z.string().optional(),
isPublic: z.boolean().optional(),
estimatedDurationMin: z.number().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const [pkg] = await db
.select()
.from(testPackage)
.where(eq(testPackage.id, id))
.limit(1);
assertOwnership(pkg, ctx.session.user.id, "Package");
const [updated] = await db
.update(testPackage)
.set(data)
.where(eq(testPackage.id, id))
.returning();
return updated ?? null;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [pkg] = await db
.select()
.from(testPackage)
.where(eq(testPackage.id, input.id))
.limit(1);
assertOwnership(pkg, ctx.session.user.id, "Package");
await db.delete(testPackage).where(eq(testPackage.id, input.id));
return { success: true };
}),
bulkPublish: protectedProcedure
.input(z.object({ ids: z.array(z.string().uuid()).min(1) }))
.mutation(async ({ ctx, input }) => {
const rows = await db
.select({ id: testPackage.id, creatorUserId: testPackage.creatorUserId, isPublic: testPackage.isPublic })
.from(testPackage)
.where(inArray(testPackage.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("Package");
}
const ownIds = ownRows.map((r) => r.id);
await db
.update(testPackage)
.set({ isPublic: true })
.where(inArray(testPackage.id, ownIds));
return { success: true, updated: ownRows.length, skipped };
}),
// ── Section Management ───────────────────────────────────
addSection: protectedProcedure
.input(
z.object({
packageId: z.string().uuid(),
sectionTypeId: z.string(),
title: z.string().min(1),
orderIndex: z.number().default(0),
}),
)
.mutation(async ({ ctx, input }) => {
const { packageId, ...sectionData } = input;
// Verify ownership
const [pkg] = await db
.select()
.from(testPackage)
.where(eq(testPackage.id, packageId))
.limit(1);
assertOwnership(pkg, ctx.session.user.id, "Package");
const [section] = await db
.insert(packageSection)
.values({
packageId,
...sectionData,
})
.returning();
// Update totalSections
await db
.update(testPackage)
.set({
totalSections: sql`${testPackage.totalSections} + 1`,
})
.where(eq(testPackage.id, packageId));
return section;
}),
removeSection: protectedProcedure
.input(z.object({ sectionId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [section] = await db
.select({ packageId: packageSection.packageId })
.from(packageSection)
.where(eq(packageSection.id, input.sectionId))
.limit(1);
if (!section) throwNotFound("Section");
const [pkg] = await db
.select()
.from(testPackage)
.where(eq(testPackage.id, section.packageId))
.limit(1);
assertOwnership(pkg, ctx.session.user.id, "Package");
// Count questions in this section
const [countResult] = await db
.select({ count: sql<number>`count(*)` })
.from(sectionQuestion)
.where(eq(sectionQuestion.sectionId, input.sectionId));
const questionCount = Number(countResult?.count ?? 0);
await db.delete(packageSection).where(eq(packageSection.id, input.sectionId));
await db
.update(testPackage)
.set({
totalSections: sql`${testPackage.totalSections} - 1`,
totalQuestions: sql`${testPackage.totalQuestions} - ${questionCount}`,
})
.where(eq(testPackage.id, section.packageId));
return { success: true };
}),
// ── Question Management ──────────────────────────────────
addQuestion: protectedProcedure
.input(
z.object({
sectionId: z.string().uuid(),
questionId: z.string().uuid(),
orderIndex: z.number().default(0),
}),
)
.mutation(async ({ ctx, input }) => {
const [section] = await db
.select({ packageId: packageSection.packageId })
.from(packageSection)
.where(eq(packageSection.id, input.sectionId))
.limit(1);
if (!section) throwNotFound("Section");
const [pkg] = await db
.select()
.from(testPackage)
.where(eq(testPackage.id, section.packageId))
.limit(1);
assertOwnership(pkg, ctx.session.user.id, "Package");
const [sq] = await db
.insert(sectionQuestion)
.values({
sectionId: input.sectionId,
questionId: input.questionId,
orderIndex: input.orderIndex,
})
.returning();
await db
.update(testPackage)
.set({
totalQuestions: sql`${testPackage.totalQuestions} + 1`,
})
.where(eq(testPackage.id, section.packageId));
return sq;
}),
removeQuestion: protectedProcedure
.input(z.object({ sectionQuestionId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [sq] = await db
.select({
sectionId: sectionQuestion.sectionId,
})
.from(sectionQuestion)
.where(eq(sectionQuestion.id, input.sectionQuestionId))
.limit(1);
if (!sq) throwNotFound("Section question");
const [section] = await db
.select({ packageId: packageSection.packageId })
.from(packageSection)
.where(eq(packageSection.id, sq.sectionId))
.limit(1);
if (!section) throwNotFound("Section");
const [pkg] = await db
.select()
.from(testPackage)
.where(eq(testPackage.id, section.packageId))
.limit(1);
assertOwnership(pkg, ctx.session.user.id, "Package");
await db
.delete(sectionQuestion)
.where(eq(sectionQuestion.id, input.sectionQuestionId));
await db
.update(testPackage)
.set({
totalQuestions: sql`${testPackage.totalQuestions} - 1`,
})
.where(eq(testPackage.id, section.packageId));
return { success: true };
}),
featured: publicProcedure
.query(async () => {
const rows = await db
.select({
id: testPackage.id,
title: testPackage.title,
description: testPackage.description,
examTypeId: testPackage.examTypeId,
isPublic: testPackage.isPublic,
totalQuestions: testPackage.totalQuestions,
totalSections: testPackage.totalSections,
estimatedDurationMin: testPackage.estimatedDurationMin,
usageCount: testPackage.usageCount,
avgRating: testPackage.avgRating,
createdAt: testPackage.createdAt,
examTypeName: examType.name,
})
.from(testPackage)
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
.where(
and(eq(testPackage.isFeatured, true), eq(testPackage.isPublic, true)),
)
.orderBy(desc(testPackage.createdAt))
.limit(6);
return rows;
}),
trending: publicProcedure
.query(async () => {
const rows = await db
.select({
id: testPackage.id,
title: testPackage.title,
description: testPackage.description,
examTypeId: testPackage.examTypeId,
isPublic: testPackage.isPublic,
totalQuestions: testPackage.totalQuestions,
totalSections: testPackage.totalSections,
estimatedDurationMin: testPackage.estimatedDurationMin,
usageCount: testPackage.usageCount,
avgRating: testPackage.avgRating,
createdAt: testPackage.createdAt,
examTypeName: examType.name,
})
.from(testPackage)
.leftJoin(examType, eq(testPackage.examTypeId, examType.id))
.where(eq(testPackage.isPublic, true))
.orderBy(desc(testPackage.usageCount))
.limit(6);
return rows;
}),
});