rogasper commited on
Commit
598f0d5
·
1 Parent(s): 32e08ac

feat: enhance backend conventions for tRPC and Hono with new error handling, pagination, authorization, and logging guidelines. Introduce shared utilities for error management, ownership assertions, and visibility conditions. Update routers to utilize new pagination schema and improve error responses. Add comprehensive documentation in AGENTS.md for better developer onboarding.

Browse files
AGENTS.md CHANGED
@@ -145,7 +145,45 @@ bun run build # Build all packages
145
 
146
  ---
147
 
148
- ## 10. Git & Workflow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  - Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it.
151
  - Do **NOT** create `README.md` or documentation files unless explicitly requested.
@@ -154,4 +192,4 @@ bun run build # Build all packages
154
 
155
  ---
156
 
157
- _Last updated: 2026-05-01_
 
145
 
146
  ---
147
 
148
+ ## 10. Backend Conventions (tRPC / Hono)
149
+
150
+ ### Error Handling
151
+ - **Always use English** error messages in tRPC routers.
152
+ - Use helper functions from `packages/api/src/lib/errors.ts`:
153
+ ```ts
154
+ import { throwNotFound, throwForbidden, throwBadRequest } from "@labas/api/lib/errors";
155
+ assertOwnership(row, userId, "Question"); // throws NotFound or Forbidden
156
+ ```
157
+ - Never throw raw `new Error("...")` in tRPC routers.
158
+
159
+ ### Pagination
160
+ - Use shared schema + helper for all list endpoints:
161
+ ```ts
162
+ import { paginationSchema, paginateDefaults } from "@labas/api/lib/pagination";
163
+ .input(z.object({ search: z.string().optional(), ...paginationSchema?.shape }).optional())
164
+ const { limit, offset } = paginateDefaults(input);
165
+ ```
166
+ - Return shape: `{ items: rows, total }` (or domain-specific name like `{ questions: rows, total }` for legacy compatibility).
167
+
168
+ ### Authorization (Ownership)
169
+ - Use `assertOwnership(row, userId, resourceName)` from `packages/api/src/lib/ownership.ts`.
170
+ - Prefer explicit ownership query + assert over embedding `creatorUserId` in `.where()` for mutations that need a clear 404 vs 403.
171
+
172
+ ### Visibility (Public/Private)
173
+ - Use `buildVisibilityCondition(table, userId)` from `packages/api/src/lib/visibility.ts` for list queries.
174
+ - Default behavior: guests see only public; authenticated users see public + their own private.
175
+
176
+ ### Logging
177
+ - Winston logger is available in tRPC context via `ctx.logger`.
178
+ - Use `logger.info()`, `logger.error()`, etc. for business events and errors.
179
+ - Hono HTTP request logging is handled automatically by `hono/logger` in `apps/server/src/index.ts`.
180
+
181
+ ### Router Structure
182
+ - Shared helpers live in `packages/api/src/lib/`.
183
+ - Routers live in `packages/api/src/routers/`.
184
+ - Register new routers in `packages/api/src/routers/index.ts`.
185
+
186
+ ## 11. Git & Workflow
187
 
188
  - Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it.
189
  - Do **NOT** create `README.md` or documentation files unless explicitly requested.
 
192
 
193
  ---
194
 
195
+ _Last updated: 2026-05-06_
packages/api/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { initTRPC, TRPCError } from "@trpc/server";
2
-
3
  import type { Context } from "./context";
4
 
5
  export const t = initTRPC.context<Context>().create();
@@ -23,3 +23,15 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
23
  },
24
  });
25
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { initTRPC, TRPCError } from "@trpc/server";
2
+ import { logger } from "./logger";
3
  import type { Context } from "./context";
4
 
5
  export const t = initTRPC.context<Context>().create();
 
23
  },
24
  });
25
  });
26
+
27
+ // Logging middleware (available to use per-router)
28
+ export const loggedProcedure = t.procedure.use(({ ctx, path, type, next }) => {
29
+ const start = Date.now();
30
+ logger.info(`${type.toUpperCase()} ${path}`, { userId: ctx.session?.user.id });
31
+ return next().finally(() => {
32
+ logger.info(`${type.toUpperCase()} ${path} completed`, {
33
+ userId: ctx.session?.user.id,
34
+ durationMs: Date.now() - start,
35
+ });
36
+ });
37
+ });
packages/api/src/lib/errors.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TRPCError } from "@trpc/server";
2
+
3
+ export function throwUnauthorized(message = "Unauthorized"): never {
4
+ throw new TRPCError({ code: "UNAUTHORIZED", message });
5
+ }
6
+
7
+ export function throwForbidden(message = "Forbidden"): never {
8
+ throw new TRPCError({ code: "FORBIDDEN", message });
9
+ }
10
+
11
+ export function throwNotFound(resource = "Resource"): never {
12
+ throw new TRPCError({ code: "NOT_FOUND", message: `${resource} not found` });
13
+ }
14
+
15
+ export function throwBadRequest(message = "Bad request"): never {
16
+ throw new TRPCError({ code: "BAD_REQUEST", message });
17
+ }
18
+
19
+ export function throwInternal(message = "Internal server error"): never {
20
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
21
+ }
packages/api/src/lib/ownership.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { eq } from "drizzle-orm";
2
+ import { throwForbidden, throwNotFound } from "./errors";
3
+
4
+ interface OwnedRow {
5
+ creatorUserId: string | null;
6
+ }
7
+
8
+ export function assertOwnership(row: OwnedRow | null | undefined, userId: string, resource = "Resource"): void {
9
+ if (!row) throwNotFound(resource);
10
+ if (row.creatorUserId !== userId) throwForbidden();
11
+ }
12
+
13
+ export function ownershipFilter<TTable extends Record<string, any>>(
14
+ table: TTable,
15
+ userId: string,
16
+ idColumn: keyof TTable,
17
+ creatorColumn: keyof TTable,
18
+ ) {
19
+ return eq(table[idColumn as string], userId);
20
+ }
packages/api/src/lib/pagination.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { sql } from "drizzle-orm";
3
+
4
+ export const paginationSchema = z.object({
5
+ limit: z.number().min(1).max(50).default(20),
6
+ offset: z.number().min(0).default(0),
7
+ });
8
+
9
+ export type PaginationInput = z.infer<typeof paginationSchema>;
10
+
11
+ export interface PaginatedResult<T> {
12
+ items: T[];
13
+ total: number;
14
+ }
15
+
16
+ export function paginateDefaults(input?: PaginationInput) {
17
+ return {
18
+ limit: input?.limit ?? 20,
19
+ offset: input?.offset ?? 0,
20
+ };
21
+ }
22
+
23
+ export function countSql(column: any) {
24
+ return sql<number>`count(*)::int`;
25
+ }
packages/api/src/lib/visibility.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { eq, or, isNull, SQL } from "drizzle-orm";
2
+
3
+ interface VisibilityTable {
4
+ isPublic: any;
5
+ creatorUserId: any;
6
+ }
7
+
8
+ export function buildVisibilityCondition<T extends VisibilityTable>(
9
+ table: T,
10
+ userId?: string,
11
+ ): SQL | undefined {
12
+ if (!userId) {
13
+ return eq(table.isPublic, true);
14
+ }
15
+ return or(eq(table.isPublic, true), eq(table.creatorUserId, userId));
16
+ }
packages/api/src/routers/ai.ts CHANGED
@@ -5,6 +5,8 @@ import { generationInputSchema } from "@labas/ai/schemas";
5
  import { cancelGenerationJob, enqueueGeneration } from "../queue";
6
  import { db } from "@labas/db";
7
  import { question, generationJob } from "@labas/db";
 
 
8
 
9
  export const aiRouter = router({
10
  generate: protectedProcedure
@@ -42,13 +44,9 @@ export const aiRouter = router({
42
  .mutation(async ({ ctx, input }) => {
43
  const result = await cancelGenerationJob(ctx.session.user.id, input.jobId);
44
  if (!result.ok) {
45
- const msg =
46
- result.reason === "not_found"
47
- ? "Job tidak ditemukan"
48
- : result.reason === "forbidden"
49
- ? "Tidak diizinkan"
50
- : "Job sudah selesai atau tidak bisa dibatalkan";
51
- throw new Error(msg);
52
  }
53
  return { ok: true as const };
54
  }),
@@ -56,18 +54,18 @@ export const aiRouter = router({
56
  myJobs: protectedProcedure
57
  .input(
58
  z.object({
59
- limit: z.number().min(1).max(50).default(20),
60
- offset: z.number().min(0).default(0),
61
  }).optional(),
62
  )
63
  .query(async ({ ctx, input }) => {
 
64
  const rows = await db
65
  .select()
66
  .from(generationJob)
67
  .where(eq(generationJob.userId, ctx.session.user.id))
68
  .orderBy(desc(generationJob.createdAt))
69
- .limit(input?.limit ?? 20)
70
- .offset(input?.offset ?? 0);
71
  return rows;
72
  }),
73
 
@@ -126,20 +124,20 @@ export const aiRouter = router({
126
  .where(eq(generationJob.id, input.jobId))
127
  .limit(1);
128
 
129
- if (!job) throw new Error("Job tidak ditemukan");
130
- if (job.userId !== ctx.session.user.id) throw new Error("Tidak diizinkan");
131
  if (job.status !== "failed" && job.status !== "cancelled") {
132
- throw new Error("Hanya job yang gagal atau dibatalkan yang bisa di-retry");
133
  }
134
 
135
  if (!job.inputJson || typeof job.inputJson !== "object") {
136
- throw new Error("Data input job tidak tersedia untuk retry");
137
  }
138
 
139
  const jobInput = job.inputJson as any;
140
  // Ensure the parsed input has the apiKeyConfig shape the pipeline expects
141
  if (!jobInput.apiKeyConfig?.baseUrl || !jobInput.apiKeyConfig?.apiKey || !jobInput.apiKeyConfig?.model) {
142
- throw new Error("Konfigurasi API key tidak valid untuk retry");
143
  }
144
 
145
  const newJobId = await enqueueGeneration(ctx.session.user.id, jobInput);
 
5
  import { cancelGenerationJob, enqueueGeneration } from "../queue";
6
  import { db } from "@labas/db";
7
  import { question, generationJob } from "@labas/db";
8
+ import { paginationSchema, paginateDefaults } from "../lib/pagination";
9
+ import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
10
 
11
  export const aiRouter = router({
12
  generate: protectedProcedure
 
44
  .mutation(async ({ ctx, input }) => {
45
  const result = await cancelGenerationJob(ctx.session.user.id, input.jobId);
46
  if (!result.ok) {
47
+ if (result.reason === "not_found") throwNotFound("Job");
48
+ if (result.reason === "forbidden") throwForbidden();
49
+ throwBadRequest("Job already finished or cannot be cancelled");
 
 
 
 
50
  }
51
  return { ok: true as const };
52
  }),
 
54
  myJobs: protectedProcedure
55
  .input(
56
  z.object({
57
+ ...paginationSchema.shape,
 
58
  }).optional(),
59
  )
60
  .query(async ({ ctx, input }) => {
61
+ const { limit, offset } = paginateDefaults(input);
62
  const rows = await db
63
  .select()
64
  .from(generationJob)
65
  .where(eq(generationJob.userId, ctx.session.user.id))
66
  .orderBy(desc(generationJob.createdAt))
67
+ .limit(limit)
68
+ .offset(offset);
69
  return rows;
70
  }),
71
 
 
124
  .where(eq(generationJob.id, input.jobId))
125
  .limit(1);
126
 
127
+ if (!job) throwNotFound("Job");
128
+ if (job.userId !== ctx.session.user.id) throwForbidden();
129
  if (job.status !== "failed" && job.status !== "cancelled") {
130
+ throwBadRequest("Only failed or cancelled jobs can be retried");
131
  }
132
 
133
  if (!job.inputJson || typeof job.inputJson !== "object") {
134
+ throwBadRequest("Job input data is not available for retry");
135
  }
136
 
137
  const jobInput = job.inputJson as any;
138
  // Ensure the parsed input has the apiKeyConfig shape the pipeline expects
139
  if (!jobInput.apiKeyConfig?.baseUrl || !jobInput.apiKeyConfig?.apiKey || !jobInput.apiKeyConfig?.model) {
140
+ throwBadRequest("Invalid API key configuration for retry");
141
  }
142
 
143
  const newJobId = await enqueueGeneration(ctx.session.user.id, jobInput);
packages/api/src/routers/attempt.ts CHANGED
@@ -12,6 +12,9 @@ import {
12
  question,
13
  examType,
14
  } from "@labas/db";
 
 
 
15
 
16
  function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string): boolean {
17
  const ua = userAnswer.trim();
@@ -55,10 +58,7 @@ export const attemptRouter = router({
55
  .where(eq(testPackage.id, input.packageId))
56
  .limit(1);
57
 
58
- if (!pkg) throw new Error("Package not found");
59
- if (!pkg.isPublic && pkg.creatorUserId !== userId) {
60
- throw new Error("Not authorized to access this package");
61
- }
62
 
63
  const sections = await db
64
  .select({
@@ -78,7 +78,7 @@ export const attemptRouter = router({
78
  })
79
  .returning();
80
 
81
- if (!attempt) throw new Error("Failed to create attempt");
82
 
83
  for (const section of sections) {
84
  await db.insert(sectionResult).values({
@@ -107,7 +107,7 @@ export const attemptRouter = router({
107
  .limit(1);
108
 
109
  if (!attempt) return null;
110
- if (attempt.userId !== userId) throw new Error("Not authorized");
111
 
112
  const dbSections = await db
113
  .select()
@@ -228,9 +228,9 @@ export const attemptRouter = router({
228
  .where(eq(testAttempt.id, input.attemptId))
229
  .limit(1);
230
 
231
- if (!attempt) throw new Error("Attempt not found");
232
- if (attempt.userId !== userId) throw new Error("Not authorized");
233
- if (attempt.status !== "in_progress") throw new Error("Attempt already finished");
234
 
235
  const [q] = await db
236
  .select()
@@ -238,7 +238,7 @@ export const attemptRouter = router({
238
  .where(eq(question.id, input.questionId))
239
  .limit(1);
240
 
241
- if (!q) throw new Error("Question not found");
242
 
243
  const isCorrect = normalizeAnswer(q.format, input.userAnswer, q.correctAnswer);
244
 
@@ -286,9 +286,9 @@ export const attemptRouter = router({
286
  .where(eq(testAttempt.id, input.attemptId))
287
  .limit(1);
288
 
289
- if (!attempt) throw new Error("Attempt not found");
290
- if (attempt.userId !== userId) throw new Error("Not authorized");
291
- if (attempt.status !== "in_progress") throw new Error("Attempt already finished");
292
 
293
  const dbSections = await db
294
  .select()
@@ -420,9 +420,9 @@ export const attemptRouter = router({
420
  .where(eq(testAttempt.id, input.attemptId))
421
  .limit(1);
422
 
423
- if (!attempt) throw new Error("Attempt not found");
424
- if (attempt.userId !== userId) throw new Error("Not authorized");
425
- if (attempt.status !== "in_progress") throw new Error("Attempt not in progress");
426
 
427
  await db
428
  .update(testAttempt)
@@ -437,13 +437,13 @@ export const attemptRouter = router({
437
  z
438
  .object({
439
  packageId: z.string().uuid().optional(),
440
- limit: z.number().min(1).max(50).default(20),
441
- offset: z.number().min(0).default(0),
442
  })
443
  .optional(),
444
  )
445
  .query(async ({ ctx, input }) => {
446
  const userId = ctx.session.user.id;
 
447
  const conditions = [eq(testAttempt.userId, userId)];
448
 
449
  if (input?.packageId) {
@@ -472,8 +472,8 @@ export const attemptRouter = router({
472
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
473
  .where(where)
474
  .orderBy(desc(testAttempt.createdAt))
475
- .limit(input?.limit ?? 20)
476
- .offset(input?.offset ?? 0);
477
 
478
  const [countResult] = await db
479
  .select({ count: sql<number>`count(*)` })
 
12
  question,
13
  examType,
14
  } from "@labas/db";
15
+ import { paginationSchema, paginateDefaults } from "../lib/pagination";
16
+ import { assertOwnership } from "../lib/ownership";
17
+ import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
18
 
19
  function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string): boolean {
20
  const ua = userAnswer.trim();
 
58
  .where(eq(testPackage.id, input.packageId))
59
  .limit(1);
60
 
61
+ assertOwnership(pkg, userId, "Package");
 
 
 
62
 
63
  const sections = await db
64
  .select({
 
78
  })
79
  .returning();
80
 
81
+ if (!attempt) throwBadRequest("Failed to create attempt");
82
 
83
  for (const section of sections) {
84
  await db.insert(sectionResult).values({
 
107
  .limit(1);
108
 
109
  if (!attempt) return null;
110
+ if (attempt.userId !== userId) throwForbidden();
111
 
112
  const dbSections = await db
113
  .select()
 
228
  .where(eq(testAttempt.id, input.attemptId))
229
  .limit(1);
230
 
231
+ if (!attempt) throwNotFound("Attempt");
232
+ if (attempt.userId !== userId) throwForbidden();
233
+ if (attempt.status !== "in_progress") throwBadRequest("Attempt already finished");
234
 
235
  const [q] = await db
236
  .select()
 
238
  .where(eq(question.id, input.questionId))
239
  .limit(1);
240
 
241
+ if (!q) throwNotFound("Question");
242
 
243
  const isCorrect = normalizeAnswer(q.format, input.userAnswer, q.correctAnswer);
244
 
 
286
  .where(eq(testAttempt.id, input.attemptId))
287
  .limit(1);
288
 
289
+ if (!attempt) throwNotFound("Attempt");
290
+ if (attempt.userId !== userId) throwForbidden();
291
+ if (attempt.status !== "in_progress") throwBadRequest("Attempt already finished");
292
 
293
  const dbSections = await db
294
  .select()
 
420
  .where(eq(testAttempt.id, input.attemptId))
421
  .limit(1);
422
 
423
+ if (!attempt) throwNotFound("Attempt");
424
+ if (attempt.userId !== userId) throwForbidden();
425
+ if (attempt.status !== "in_progress") throwBadRequest("Attempt not in progress");
426
 
427
  await db
428
  .update(testAttempt)
 
437
  z
438
  .object({
439
  packageId: z.string().uuid().optional(),
440
+ ...paginationSchema.shape,
 
441
  })
442
  .optional(),
443
  )
444
  .query(async ({ ctx, input }) => {
445
  const userId = ctx.session.user.id;
446
+ const { limit, offset } = paginateDefaults(input);
447
  const conditions = [eq(testAttempt.userId, userId)];
448
 
449
  if (input?.packageId) {
 
472
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
473
  .where(where)
474
  .orderBy(desc(testAttempt.createdAt))
475
+ .limit(limit)
476
+ .offset(offset);
477
 
478
  const [countResult] = await db
479
  .select({ count: sql<number>`count(*)` })
packages/api/src/routers/combo.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { z } from "zod";
2
- import { eq, and, desc, sql, inArray, or } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
@@ -13,6 +13,10 @@ import {
13
  sectionType,
14
  user,
15
  } from "@labas/db";
 
 
 
 
16
 
17
  export const comboRouter = router({
18
  list: publicProcedure
@@ -21,19 +25,20 @@ export const comboRouter = router({
21
  .object({
22
  isPublic: z.boolean().optional(),
23
  search: z.string().optional(),
24
- limit: z.number().min(1).max(50).default(20),
25
- offset: z.number().min(0).default(0),
26
  })
27
  .optional(),
28
  )
29
  .query(async ({ ctx, input }) => {
30
  const userId = ctx.session?.user.id;
 
31
  const conditions = [];
32
 
33
  if (input?.isPublic !== undefined) {
34
  conditions.push(eq(comboPackage.isPublic, input.isPublic));
35
- } else if (!userId) {
36
- conditions.push(eq(comboPackage.isPublic, true));
 
37
  }
38
 
39
  const where = conditions.length > 0 ? and(...conditions) : undefined;
@@ -53,8 +58,8 @@ export const comboRouter = router({
53
  .leftJoin(user, eq(comboPackage.creatorUserId, user.id))
54
  .where(where)
55
  .orderBy(desc(comboPackage.createdAt))
56
- .limit(input?.limit ?? 20)
57
- .offset(input?.offset ?? 0);
58
 
59
  const [countResult] = await db
60
  .select({ count: sql<number>`count(*)` })
@@ -69,13 +74,13 @@ export const comboRouter = router({
69
  z
70
  .object({
71
  search: z.string().optional(),
72
- limit: z.number().min(1).max(50).default(20),
73
- offset: z.number().min(0).default(0),
74
  })
75
  .optional(),
76
  )
77
  .query(async ({ ctx, input }) => {
78
  const userId = ctx.session.user.id;
 
79
  const conditions = [eq(comboPackage.creatorUserId, userId)];
80
  const where = and(...conditions);
81
 
@@ -91,8 +96,8 @@ export const comboRouter = router({
91
  .from(comboPackage)
92
  .where(where)
93
  .orderBy(desc(comboPackage.createdAt))
94
- .limit(input?.limit ?? 20)
95
- .offset(input?.offset ?? 0);
96
 
97
  const [countResult] = await db
98
  .select({ count: sql<number>`count(*)` })
@@ -220,7 +225,7 @@ export const comboRouter = router({
220
  .returning();
221
 
222
  if (!combo) {
223
- throw new Error("Failed to create combo package");
224
  }
225
 
226
  await db.insert(comboSection).values(
@@ -329,23 +334,33 @@ export const comboRouter = router({
329
  .mutation(async ({ ctx, input }) => {
330
  const { id, ...data } = input;
331
  const [combo] = await db
 
 
 
 
 
 
 
 
332
  .update(comboPackage)
333
  .set(data)
334
- .where(
335
- and(eq(comboPackage.id, id), eq(comboPackage.creatorUserId, ctx.session.user.id)),
336
- )
337
  .returning();
338
- return combo ?? null;
339
  }),
340
 
341
  delete: protectedProcedure
342
  .input(z.object({ id: z.string().uuid() }))
343
  .mutation(async ({ ctx, input }) => {
344
- await db
345
- .delete(comboPackage)
346
- .where(
347
- and(eq(comboPackage.id, input.id), eq(comboPackage.creatorUserId, ctx.session.user.id)),
348
- );
 
 
 
 
349
  return { success: true };
350
  }),
351
 
@@ -355,25 +370,23 @@ export const comboRouter = router({
355
  z.object({
356
  examTypeId: z.string().optional(),
357
  search: z.string().optional(),
358
- limit: z.number().min(1).max(50).default(20),
359
- offset: z.number().min(0).default(0),
360
  }).optional(),
361
  )
362
  .query(async ({ ctx, input }) => {
363
  const userId = ctx.session.user.id;
 
364
 
365
- // Get packages the user can access (public or owned)
366
- const pkgConditions = [
367
- and(
368
- eq(testPackage.isPublic, true),
369
- eq(testPackage.creatorUserId, userId),
370
- ),
371
- ];
372
 
373
  if (input?.examTypeId) {
374
  pkgConditions.push(eq(testPackage.examTypeId, input.examTypeId));
375
  }
376
 
 
 
377
  const packages = await db
378
  .select({
379
  id: testPackage.id,
@@ -385,14 +398,9 @@ export const comboRouter = router({
385
  })
386
  .from(testPackage)
387
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
388
- .where(
389
- or(
390
- eq(testPackage.isPublic, true),
391
- eq(testPackage.creatorUserId, userId),
392
- ),
393
- )
394
- .limit(input?.limit ?? 20)
395
- .offset(input?.offset ?? 0);
396
 
397
  const packageIds = packages.map((p) => p.id);
398
 
 
1
  import { z } from "zod";
2
+ import { eq, and, desc, sql, inArray } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
 
13
  sectionType,
14
  user,
15
  } from "@labas/db";
16
+ import { paginationSchema, paginateDefaults } from "../lib/pagination";
17
+ import { assertOwnership } from "../lib/ownership";
18
+ import { buildVisibilityCondition } from "../lib/visibility";
19
+ import { throwBadRequest } from "../lib/errors";
20
 
21
  export const comboRouter = router({
22
  list: publicProcedure
 
25
  .object({
26
  isPublic: z.boolean().optional(),
27
  search: z.string().optional(),
28
+ ...paginationSchema.shape,
 
29
  })
30
  .optional(),
31
  )
32
  .query(async ({ ctx, input }) => {
33
  const userId = ctx.session?.user.id;
34
+ const { limit, offset } = paginateDefaults(input);
35
  const conditions = [];
36
 
37
  if (input?.isPublic !== undefined) {
38
  conditions.push(eq(comboPackage.isPublic, input.isPublic));
39
+ } else {
40
+ const vis = buildVisibilityCondition(comboPackage, userId);
41
+ if (vis) conditions.push(vis);
42
  }
43
 
44
  const where = conditions.length > 0 ? and(...conditions) : undefined;
 
58
  .leftJoin(user, eq(comboPackage.creatorUserId, user.id))
59
  .where(where)
60
  .orderBy(desc(comboPackage.createdAt))
61
+ .limit(limit)
62
+ .offset(offset);
63
 
64
  const [countResult] = await db
65
  .select({ count: sql<number>`count(*)` })
 
74
  z
75
  .object({
76
  search: z.string().optional(),
77
+ ...paginationSchema.shape,
 
78
  })
79
  .optional(),
80
  )
81
  .query(async ({ ctx, input }) => {
82
  const userId = ctx.session.user.id;
83
+ const { limit, offset } = paginateDefaults(input);
84
  const conditions = [eq(comboPackage.creatorUserId, userId)];
85
  const where = and(...conditions);
86
 
 
96
  .from(comboPackage)
97
  .where(where)
98
  .orderBy(desc(comboPackage.createdAt))
99
+ .limit(limit)
100
+ .offset(offset);
101
 
102
  const [countResult] = await db
103
  .select({ count: sql<number>`count(*)` })
 
225
  .returning();
226
 
227
  if (!combo) {
228
+ throwBadRequest("Failed to create combo package");
229
  }
230
 
231
  await db.insert(comboSection).values(
 
334
  .mutation(async ({ ctx, input }) => {
335
  const { id, ...data } = input;
336
  const [combo] = await db
337
+ .select()
338
+ .from(comboPackage)
339
+ .where(eq(comboPackage.id, id))
340
+ .limit(1);
341
+
342
+ assertOwnership(combo, ctx.session.user.id, "Combo");
343
+
344
+ const [updated] = await db
345
  .update(comboPackage)
346
  .set(data)
347
+ .where(eq(comboPackage.id, id))
 
 
348
  .returning();
349
+ return updated ?? null;
350
  }),
351
 
352
  delete: protectedProcedure
353
  .input(z.object({ id: z.string().uuid() }))
354
  .mutation(async ({ ctx, input }) => {
355
+ const [combo] = await db
356
+ .select()
357
+ .from(comboPackage)
358
+ .where(eq(comboPackage.id, input.id))
359
+ .limit(1);
360
+
361
+ assertOwnership(combo, ctx.session.user.id, "Combo");
362
+
363
+ await db.delete(comboPackage).where(eq(comboPackage.id, input.id));
364
  return { success: true };
365
  }),
366
 
 
370
  z.object({
371
  examTypeId: z.string().optional(),
372
  search: z.string().optional(),
373
+ ...paginationSchema.shape,
 
374
  }).optional(),
375
  )
376
  .query(async ({ ctx, input }) => {
377
  const userId = ctx.session.user.id;
378
+ const { limit, offset } = paginateDefaults(input);
379
 
380
+ const pkgConditions = [];
381
+ const vis = buildVisibilityCondition(testPackage, userId);
382
+ if (vis) pkgConditions.push(vis);
 
 
 
 
383
 
384
  if (input?.examTypeId) {
385
  pkgConditions.push(eq(testPackage.examTypeId, input.examTypeId));
386
  }
387
 
388
+ const pkgWhere = pkgConditions.length > 0 ? and(...pkgConditions) : undefined;
389
+
390
  const packages = await db
391
  .select({
392
  id: testPackage.id,
 
398
  })
399
  .from(testPackage)
400
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
401
+ .where(pkgWhere)
402
+ .limit(limit)
403
+ .offset(offset);
 
 
 
 
 
404
 
405
  const packageIds = packages.map((p) => p.id);
406
 
packages/api/src/routers/package.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { z } from "zod";
2
- import { eq, and, or, desc, sql, inArray } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
@@ -11,6 +11,10 @@ import {
11
  sectionType,
12
  user,
13
  } from "@labas/db";
 
 
 
 
14
 
15
  export const packageRouter = router({
16
  list: publicProcedure
@@ -20,27 +24,22 @@ export const packageRouter = router({
20
  examTypeId: z.string().optional(),
21
  isPublic: z.boolean().optional(),
22
  search: z.string().optional(),
23
- limit: z.number().min(1).max(50).default(20),
24
- offset: z.number().min(0).default(0),
25
  })
26
  .optional(),
27
  )
28
  .query(async ({ ctx, input }) => {
29
  const userId = ctx.session?.user.id;
 
30
  const conditions = [];
31
 
32
  if (input?.examTypeId) conditions.push(eq(testPackage.examTypeId, input.examTypeId));
33
 
34
  if (input?.isPublic !== undefined) {
35
  conditions.push(eq(testPackage.isPublic, input.isPublic));
36
- } else if (!userId) {
37
- // Guests: only public packages
38
- conditions.push(eq(testPackage.isPublic, true));
39
  } else {
40
- // Logged-in users: public packages + their own private packages
41
- conditions.push(
42
- or(eq(testPackage.isPublic, true), eq(testPackage.creatorUserId, userId)),
43
- );
44
  }
45
 
46
  const where = conditions.length > 0 ? and(...conditions) : undefined;
@@ -68,8 +67,8 @@ export const packageRouter = router({
68
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
69
  .where(where)
70
  .orderBy(desc(testPackage.createdAt))
71
- .limit(input?.limit ?? 20)
72
- .offset(input?.offset ?? 0);
73
 
74
  const [countResult] = await db
75
  .select({ count: sql<number>`count(*)` })
@@ -86,13 +85,13 @@ export const packageRouter = router({
86
  .object({
87
  search: z.string().optional(),
88
  examTypeId: z.string().optional(),
89
- limit: z.number().min(1).max(50).default(20),
90
- offset: z.number().min(0).default(0),
91
  })
92
  .optional(),
93
  )
94
  .query(async ({ ctx, input }) => {
95
  const userId = ctx.session.user.id;
 
96
  const conditions = [eq(testPackage.creatorUserId, userId)];
97
 
98
  if (input?.search) {
@@ -127,8 +126,8 @@ export const packageRouter = router({
127
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
128
  .where(where)
129
  .orderBy(desc(testPackage.createdAt))
130
- .limit(input?.limit ?? 20)
131
- .offset(input?.offset ?? 0);
132
 
133
  const [countResult] = await db
134
  .select({ count: sql<number>`count(*)` })
@@ -268,23 +267,33 @@ export const packageRouter = router({
268
  .mutation(async ({ ctx, input }) => {
269
  const { id, ...data } = input;
270
  const [pkg] = await db
 
 
 
 
 
 
 
 
271
  .update(testPackage)
272
  .set(data)
273
- .where(
274
- and(eq(testPackage.id, id), eq(testPackage.creatorUserId, ctx.session.user.id)),
275
- )
276
  .returning();
277
- return pkg ?? null;
278
  }),
279
 
280
  delete: protectedProcedure
281
  .input(z.object({ id: z.string().uuid() }))
282
  .mutation(async ({ ctx, input }) => {
283
- await db
284
- .delete(testPackage)
285
- .where(
286
- and(eq(testPackage.id, input.id), eq(testPackage.creatorUserId, ctx.session.user.id)),
287
- );
 
 
 
 
288
  return { success: true };
289
  }),
290
 
@@ -304,14 +313,12 @@ export const packageRouter = router({
304
 
305
  // Verify ownership
306
  const [pkg] = await db
307
- .select({ creatorUserId: testPackage.creatorUserId })
308
  .from(testPackage)
309
  .where(eq(testPackage.id, packageId))
310
  .limit(1);
311
 
312
- if (!pkg || pkg.creatorUserId !== ctx.session.user.id) {
313
- throw new Error("Package not found or not authorized");
314
- }
315
 
316
  const [section] = await db
317
  .insert(packageSection)
@@ -341,17 +348,15 @@ export const packageRouter = router({
341
  .where(eq(packageSection.id, input.sectionId))
342
  .limit(1);
343
 
344
- if (!section) throw new Error("Section not found");
345
 
346
  const [pkg] = await db
347
- .select({ creatorUserId: testPackage.creatorUserId })
348
  .from(testPackage)
349
  .where(eq(testPackage.id, section.packageId))
350
  .limit(1);
351
 
352
- if (!pkg || pkg.creatorUserId !== ctx.session.user.id) {
353
- throw new Error("Not authorized");
354
- }
355
 
356
  // Count questions in this section
357
  const [countResult] = await db
@@ -390,17 +395,15 @@ export const packageRouter = router({
390
  .where(eq(packageSection.id, input.sectionId))
391
  .limit(1);
392
 
393
- if (!section) throw new Error("Section not found");
394
 
395
  const [pkg] = await db
396
- .select({ creatorUserId: testPackage.creatorUserId })
397
  .from(testPackage)
398
  .where(eq(testPackage.id, section.packageId))
399
  .limit(1);
400
 
401
- if (!pkg || pkg.creatorUserId !== ctx.session.user.id) {
402
- throw new Error("Not authorized");
403
- }
404
 
405
  const [sq] = await db
406
  .insert(sectionQuestion)
@@ -432,7 +435,7 @@ export const packageRouter = router({
432
  .where(eq(sectionQuestion.id, input.sectionQuestionId))
433
  .limit(1);
434
 
435
- if (!sq) throw new Error("Section question not found");
436
 
437
  const [section] = await db
438
  .select({ packageId: packageSection.packageId })
@@ -440,17 +443,15 @@ export const packageRouter = router({
440
  .where(eq(packageSection.id, sq.sectionId))
441
  .limit(1);
442
 
443
- if (!section) throw new Error("Section not found");
444
 
445
  const [pkg] = await db
446
- .select({ creatorUserId: testPackage.creatorUserId })
447
  .from(testPackage)
448
  .where(eq(testPackage.id, section.packageId))
449
  .limit(1);
450
 
451
- if (!pkg || pkg.creatorUserId !== ctx.session.user.id) {
452
- throw new Error("Not authorized");
453
- }
454
 
455
  await db
456
  .delete(sectionQuestion)
 
1
  import { z } from "zod";
2
+ import { eq, and, desc, sql, inArray } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
 
11
  sectionType,
12
  user,
13
  } from "@labas/db";
14
+ import { paginationSchema, paginateDefaults } from "../lib/pagination";
15
+ import { assertOwnership } from "../lib/ownership";
16
+ import { buildVisibilityCondition } from "../lib/visibility";
17
+ import { throwNotFound, throwBadRequest } from "../lib/errors";
18
 
19
  export const packageRouter = router({
20
  list: publicProcedure
 
24
  examTypeId: z.string().optional(),
25
  isPublic: z.boolean().optional(),
26
  search: z.string().optional(),
27
+ ...paginationSchema.shape,
 
28
  })
29
  .optional(),
30
  )
31
  .query(async ({ ctx, input }) => {
32
  const userId = ctx.session?.user.id;
33
+ const { limit, offset } = paginateDefaults(input);
34
  const conditions = [];
35
 
36
  if (input?.examTypeId) conditions.push(eq(testPackage.examTypeId, input.examTypeId));
37
 
38
  if (input?.isPublic !== undefined) {
39
  conditions.push(eq(testPackage.isPublic, input.isPublic));
 
 
 
40
  } else {
41
+ const vis = buildVisibilityCondition(testPackage, userId);
42
+ if (vis) conditions.push(vis);
 
 
43
  }
44
 
45
  const where = conditions.length > 0 ? and(...conditions) : undefined;
 
67
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
68
  .where(where)
69
  .orderBy(desc(testPackage.createdAt))
70
+ .limit(limit)
71
+ .offset(offset);
72
 
73
  const [countResult] = await db
74
  .select({ count: sql<number>`count(*)` })
 
85
  .object({
86
  search: z.string().optional(),
87
  examTypeId: z.string().optional(),
88
+ ...paginationSchema.shape,
 
89
  })
90
  .optional(),
91
  )
92
  .query(async ({ ctx, input }) => {
93
  const userId = ctx.session.user.id;
94
+ const { limit, offset } = paginateDefaults(input);
95
  const conditions = [eq(testPackage.creatorUserId, userId)];
96
 
97
  if (input?.search) {
 
126
  .leftJoin(examType, eq(testPackage.examTypeId, examType.id))
127
  .where(where)
128
  .orderBy(desc(testPackage.createdAt))
129
+ .limit(limit)
130
+ .offset(offset);
131
 
132
  const [countResult] = await db
133
  .select({ count: sql<number>`count(*)` })
 
267
  .mutation(async ({ ctx, input }) => {
268
  const { id, ...data } = input;
269
  const [pkg] = await db
270
+ .select()
271
+ .from(testPackage)
272
+ .where(eq(testPackage.id, id))
273
+ .limit(1);
274
+
275
+ assertOwnership(pkg, ctx.session.user.id, "Package");
276
+
277
+ const [updated] = await db
278
  .update(testPackage)
279
  .set(data)
280
+ .where(eq(testPackage.id, id))
 
 
281
  .returning();
282
+ return updated ?? null;
283
  }),
284
 
285
  delete: protectedProcedure
286
  .input(z.object({ id: z.string().uuid() }))
287
  .mutation(async ({ ctx, input }) => {
288
+ const [pkg] = await db
289
+ .select()
290
+ .from(testPackage)
291
+ .where(eq(testPackage.id, input.id))
292
+ .limit(1);
293
+
294
+ assertOwnership(pkg, ctx.session.user.id, "Package");
295
+
296
+ await db.delete(testPackage).where(eq(testPackage.id, input.id));
297
  return { success: true };
298
  }),
299
 
 
313
 
314
  // Verify ownership
315
  const [pkg] = await db
316
+ .select()
317
  .from(testPackage)
318
  .where(eq(testPackage.id, packageId))
319
  .limit(1);
320
 
321
+ assertOwnership(pkg, ctx.session.user.id, "Package");
 
 
322
 
323
  const [section] = await db
324
  .insert(packageSection)
 
348
  .where(eq(packageSection.id, input.sectionId))
349
  .limit(1);
350
 
351
+ if (!section) throwNotFound("Section");
352
 
353
  const [pkg] = await db
354
+ .select()
355
  .from(testPackage)
356
  .where(eq(testPackage.id, section.packageId))
357
  .limit(1);
358
 
359
+ assertOwnership(pkg, ctx.session.user.id, "Package");
 
 
360
 
361
  // Count questions in this section
362
  const [countResult] = await db
 
395
  .where(eq(packageSection.id, input.sectionId))
396
  .limit(1);
397
 
398
+ if (!section) throwNotFound("Section");
399
 
400
  const [pkg] = await db
401
+ .select()
402
  .from(testPackage)
403
  .where(eq(testPackage.id, section.packageId))
404
  .limit(1);
405
 
406
+ assertOwnership(pkg, ctx.session.user.id, "Package");
 
 
407
 
408
  const [sq] = await db
409
  .insert(sectionQuestion)
 
435
  .where(eq(sectionQuestion.id, input.sectionQuestionId))
436
  .limit(1);
437
 
438
+ if (!sq) throwNotFound("Section question");
439
 
440
  const [section] = await db
441
  .select({ packageId: packageSection.packageId })
 
443
  .where(eq(packageSection.id, sq.sectionId))
444
  .limit(1);
445
 
446
+ if (!section) throwNotFound("Section");
447
 
448
  const [pkg] = await db
449
+ .select()
450
  .from(testPackage)
451
  .where(eq(testPackage.id, section.packageId))
452
  .limit(1);
453
 
454
+ assertOwnership(pkg, ctx.session.user.id, "Package");
 
 
455
 
456
  await db
457
  .delete(sectionQuestion)
packages/api/src/routers/profile.ts CHANGED
@@ -3,6 +3,7 @@ import { eq, and, desc, sql } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import { testPackage, question, examType, user } from "@labas/db";
 
6
 
7
  export const profileRouter = router({
8
  getById: publicProcedure
@@ -102,7 +103,7 @@ export const profileRouter = router({
102
  image: user.image,
103
  });
104
 
105
- if (!updated) throw new Error("Failed to update profile");
106
  return updated;
107
  }),
108
  });
 
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import { testPackage, question, examType, user } from "@labas/db";
6
+ import { throwBadRequest } from "../lib/errors";
7
 
8
  export const profileRouter = router({
9
  getById: publicProcedure
 
103
  image: user.image,
104
  });
105
 
106
+ if (!updated) throwBadRequest("Failed to update profile");
107
  return updated;
108
  }),
109
  });
packages/api/src/routers/question.ts CHANGED
@@ -3,6 +3,48 @@ import { eq, and, desc, sql, like, or } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import { question, examType, sectionType, user } from "@labas/db";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  export const questionRouter = router({
8
  list: publicProcedure
@@ -17,8 +59,7 @@ export const questionRouter = router({
17
  creatorUserId: z.string().optional(),
18
  search: z.string().optional(),
19
  skillTags: z.array(z.string()).optional(),
20
- limit: z.number().min(1).max(50).default(20),
21
- offset: z.number().min(0).default(0),
22
  })
23
  .optional(),
24
  )
@@ -31,77 +72,40 @@ export const questionRouter = router({
31
  if (input?.format) conditions.push(eq(question.format, input.format));
32
  if (input?.difficulty) conditions.push(eq(question.difficulty, input.difficulty));
33
  if (input?.skillTags?.length) {
34
- conditions.push(
35
- sql`${question.skillTags} && ${input.skillTags}`,
36
- );
37
  }
38
 
39
  if (input?.creatorUserId) {
40
  conditions.push(eq(question.creatorUserId, input.creatorUserId));
41
  } else if (input?.isPublic !== undefined) {
42
  conditions.push(eq(question.isPublic, input.isPublic));
43
- } else if (userId) {
44
- // Default for authenticated users: show public questions + their own private questions
45
- conditions.push(
46
- or(eq(question.isPublic, true), eq(question.creatorUserId, userId)),
47
- );
48
  } else {
49
- // Default for guests: only public questions
50
- conditions.push(eq(question.isPublic, true));
51
  }
52
 
53
  if (input?.search) {
54
- const term = `%${input.search}%`;
55
- const searchCond = or(
56
- like(question.passageText, term),
57
- like(question.questionText, term),
58
- like(question.explanation, term),
59
- );
60
  if (searchCond) conditions.push(searchCond);
61
  }
62
 
63
  const where = conditions.length > 0 ? and(...conditions) : undefined;
 
64
 
65
  const rows = await db
66
- .select({
67
- id: question.id,
68
- examTypeId: question.examTypeId,
69
- sectionTypeId: question.sectionTypeId,
70
- format: question.format,
71
- passageText: question.passageText,
72
- questionText: question.questionText,
73
- options: question.options,
74
- correctAnswer: question.correctAnswer,
75
- explanation: question.explanation,
76
- difficulty: question.difficulty,
77
- skillTags: question.skillTags,
78
- source: question.source,
79
- aiModel: question.aiModel,
80
- creatorUserId: question.creatorUserId,
81
- isPublic: question.isPublic,
82
- usageCount: question.usageCount,
83
- avgRating: question.avgRating,
84
- createdAt: question.createdAt,
85
- updatedAt: question.updatedAt,
86
- creatorName: user.name,
87
- examTypeName: examType.name,
88
- sectionTypeName: sectionType.name,
89
- })
90
  .from(question)
91
  .leftJoin(user, eq(question.creatorUserId, user.id))
92
  .leftJoin(examType, eq(question.examTypeId, examType.id))
93
  .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id))
94
  .where(where)
95
  .orderBy(desc(question.createdAt))
96
- .limit(input?.limit ?? 20)
97
- .offset(input?.offset ?? 0);
98
 
99
  const [countResult] = await db
100
  .select({ count: sql<number>`count(*)` })
101
  .from(question)
102
- .leftJoin(user, eq(question.creatorUserId, user.id))
103
- .leftJoin(examType, eq(question.examTypeId, examType.id))
104
- .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id))
105
  .where(where);
106
 
107
  return { questions: rows, total: Number(countResult?.count ?? 0) };
@@ -112,8 +116,7 @@ export const questionRouter = router({
112
  z
113
  .object({
114
  search: z.string().optional(),
115
- limit: z.number().min(1).max(50).default(20),
116
- offset: z.number().min(0).default(0),
117
  })
118
  .optional(),
119
  )
@@ -122,15 +125,12 @@ export const questionRouter = router({
122
  const conditions = [eq(question.creatorUserId, userId)];
123
 
124
  if (input?.search) {
125
- const term = `%${input.search}%`;
126
- const searchCond = or(
127
- like(question.passageText, term),
128
- like(question.questionText, term),
129
- );
130
  if (searchCond) conditions.push(searchCond);
131
  }
132
 
133
  const where = and(...conditions);
 
134
 
135
  const rows = await db
136
  .select({
@@ -159,8 +159,8 @@ export const questionRouter = router({
159
  .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id))
160
  .where(where)
161
  .orderBy(desc(question.createdAt))
162
- .limit(input?.limit ?? 20)
163
- .offset(input?.offset ?? 0);
164
 
165
  const [countResult] = await db
166
  .select({ count: sql<number>`count(*)` })
@@ -175,30 +175,7 @@ export const questionRouter = router({
175
  .query(async ({ ctx, input }) => {
176
  const userId = ctx.session?.user.id;
177
  const [row] = await db
178
- .select({
179
- id: question.id,
180
- examTypeId: question.examTypeId,
181
- sectionTypeId: question.sectionTypeId,
182
- format: question.format,
183
- passageText: question.passageText,
184
- questionText: question.questionText,
185
- options: question.options,
186
- correctAnswer: question.correctAnswer,
187
- explanation: question.explanation,
188
- difficulty: question.difficulty,
189
- skillTags: question.skillTags,
190
- source: question.source,
191
- aiModel: question.aiModel,
192
- creatorUserId: question.creatorUserId,
193
- isPublic: question.isPublic,
194
- usageCount: question.usageCount,
195
- avgRating: question.avgRating,
196
- createdAt: question.createdAt,
197
- updatedAt: question.updatedAt,
198
- creatorName: user.name,
199
- examTypeName: examType.name,
200
- sectionTypeName: sectionType.name,
201
- })
202
  .from(question)
203
  .leftJoin(user, eq(question.creatorUserId, user.id))
204
  .leftJoin(examType, eq(question.examTypeId, examType.id))
@@ -271,12 +248,15 @@ export const questionRouter = router({
271
  .input(z.object({ id: z.string().uuid() }))
272
  .mutation(async ({ ctx, input }) => {
273
  const [existing] = await db
274
- .select({ isPublic: question.isPublic })
275
  .from(question)
276
- .where(and(eq(question.id, input.id), eq(question.creatorUserId, ctx.session.user.id)))
277
  .limit(1);
278
 
279
- if (!existing) throw new Error("Question not found or not authorized");
 
 
 
280
 
281
  const [row] = await db
282
  .update(question)
@@ -289,9 +269,15 @@ export const questionRouter = router({
289
  delete: protectedProcedure
290
  .input(z.object({ id: z.string().uuid() }))
291
  .mutation(async ({ ctx, input }) => {
292
- await db
293
- .delete(question)
294
- .where(and(eq(question.id, input.id), eq(question.creatorUserId, ctx.session.user.id)));
 
 
 
 
 
 
295
  return { success: true };
296
  }),
297
  });
 
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import { question, examType, sectionType, user } from "@labas/db";
6
+ import { paginationSchema, paginateDefaults } from "../lib/pagination";
7
+ import { buildVisibilityCondition } from "../lib/visibility";
8
+ import { assertOwnership } from "../lib/ownership";
9
+ import { throwNotFound } from "../lib/errors";
10
+
11
+ const questionListSelect = {
12
+ id: question.id,
13
+ examTypeId: question.examTypeId,
14
+ sectionTypeId: question.sectionTypeId,
15
+ format: question.format,
16
+ passageText: question.passageText,
17
+ questionText: question.questionText,
18
+ options: question.options,
19
+ correctAnswer: question.correctAnswer,
20
+ explanation: question.explanation,
21
+ difficulty: question.difficulty,
22
+ skillTags: question.skillTags,
23
+ source: question.source,
24
+ aiModel: question.aiModel,
25
+ creatorUserId: question.creatorUserId,
26
+ isPublic: question.isPublic,
27
+ usageCount: question.usageCount,
28
+ avgRating: question.avgRating,
29
+ createdAt: question.createdAt,
30
+ updatedAt: question.updatedAt,
31
+ creatorName: user.name,
32
+ examTypeName: examType.name,
33
+ sectionTypeName: sectionType.name,
34
+ };
35
+
36
+ const questionDetailSelect = {
37
+ ...questionListSelect,
38
+ };
39
+
40
+ function buildSearchCondition(term: string) {
41
+ const pattern = `%${term}%`;
42
+ return or(
43
+ like(question.passageText, pattern),
44
+ like(question.questionText, pattern),
45
+ like(question.explanation, pattern),
46
+ );
47
+ }
48
 
49
  export const questionRouter = router({
50
  list: publicProcedure
 
59
  creatorUserId: z.string().optional(),
60
  search: z.string().optional(),
61
  skillTags: z.array(z.string()).optional(),
62
+ ...paginationSchema?.shape,
 
63
  })
64
  .optional(),
65
  )
 
72
  if (input?.format) conditions.push(eq(question.format, input.format));
73
  if (input?.difficulty) conditions.push(eq(question.difficulty, input.difficulty));
74
  if (input?.skillTags?.length) {
75
+ conditions.push(sql`${question.skillTags} && ${input.skillTags}`);
 
 
76
  }
77
 
78
  if (input?.creatorUserId) {
79
  conditions.push(eq(question.creatorUserId, input.creatorUserId));
80
  } else if (input?.isPublic !== undefined) {
81
  conditions.push(eq(question.isPublic, input.isPublic));
 
 
 
 
 
82
  } else {
83
+ const vis = buildVisibilityCondition(question, userId);
84
+ if (vis) conditions.push(vis);
85
  }
86
 
87
  if (input?.search) {
88
+ const searchCond = buildSearchCondition(input.search);
 
 
 
 
 
89
  if (searchCond) conditions.push(searchCond);
90
  }
91
 
92
  const where = conditions.length > 0 ? and(...conditions) : undefined;
93
+ const { limit, offset } = paginateDefaults(input);
94
 
95
  const rows = await db
96
+ .select(questionListSelect)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  .from(question)
98
  .leftJoin(user, eq(question.creatorUserId, user.id))
99
  .leftJoin(examType, eq(question.examTypeId, examType.id))
100
  .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id))
101
  .where(where)
102
  .orderBy(desc(question.createdAt))
103
+ .limit(limit)
104
+ .offset(offset);
105
 
106
  const [countResult] = await db
107
  .select({ count: sql<number>`count(*)` })
108
  .from(question)
 
 
 
109
  .where(where);
110
 
111
  return { questions: rows, total: Number(countResult?.count ?? 0) };
 
116
  z
117
  .object({
118
  search: z.string().optional(),
119
+ ...paginationSchema?.shape,
 
120
  })
121
  .optional(),
122
  )
 
125
  const conditions = [eq(question.creatorUserId, userId)];
126
 
127
  if (input?.search) {
128
+ const searchCond = buildSearchCondition(input.search);
 
 
 
 
129
  if (searchCond) conditions.push(searchCond);
130
  }
131
 
132
  const where = and(...conditions);
133
+ const { limit, offset } = paginateDefaults(input);
134
 
135
  const rows = await db
136
  .select({
 
159
  .leftJoin(sectionType, eq(question.sectionTypeId, sectionType.id))
160
  .where(where)
161
  .orderBy(desc(question.createdAt))
162
+ .limit(limit)
163
+ .offset(offset);
164
 
165
  const [countResult] = await db
166
  .select({ count: sql<number>`count(*)` })
 
175
  .query(async ({ ctx, input }) => {
176
  const userId = ctx.session?.user.id;
177
  const [row] = await db
178
+ .select(questionDetailSelect)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  .from(question)
180
  .leftJoin(user, eq(question.creatorUserId, user.id))
181
  .leftJoin(examType, eq(question.examTypeId, examType.id))
 
248
  .input(z.object({ id: z.string().uuid() }))
249
  .mutation(async ({ ctx, input }) => {
250
  const [existing] = await db
251
+ .select({ id: question.id, isPublic: question.isPublic, creatorUserId: question.creatorUserId })
252
  .from(question)
253
+ .where(eq(question.id, input.id))
254
  .limit(1);
255
 
256
+ if (!existing) {
257
+ throwNotFound("Question");
258
+ }
259
+ assertOwnership(existing, ctx.session.user.id, "Question");
260
 
261
  const [row] = await db
262
  .update(question)
 
269
  delete: protectedProcedure
270
  .input(z.object({ id: z.string().uuid() }))
271
  .mutation(async ({ ctx, input }) => {
272
+ const [existing] = await db
273
+ .select({ id: question.id, creatorUserId: question.creatorUserId })
274
+ .from(question)
275
+ .where(eq(question.id, input.id))
276
+ .limit(1);
277
+
278
+ assertOwnership(existing, ctx.session.user.id, "Question");
279
+
280
+ await db.delete(question).where(eq(question.id, input.id));
281
  return { success: true };
282
  }),
283
  });