cpns / packages /api /src /routers /question.ts
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
10.5 kB
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<T extends { correctAnswer?: string; explanation?: string | null }>(row: T) {
const { correctAnswer, explanation, ...rest } = row;
return rest as Omit<T, "correctAnswer" | "explanation">;
}
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<number>`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<number>`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 };
}),
});