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 +40 -2
- packages/api/src/index.ts +13 -1
- packages/api/src/lib/errors.ts +21 -0
- packages/api/src/lib/ownership.ts +20 -0
- packages/api/src/lib/pagination.ts +25 -0
- packages/api/src/lib/visibility.ts +16 -0
- packages/api/src/routers/ai.ts +14 -16
- packages/api/src/routers/attempt.ts +20 -20
- packages/api/src/routers/combo.ts +46 -38
- packages/api/src/routers/package.ts +46 -45
- packages/api/src/routers/profile.ts +2 -1
- packages/api/src/routers/question.ts +72 -86
AGENTS.md
CHANGED
|
@@ -145,7 +145,45 @@ bun run build # Build all packages
|
|
| 145 |
|
| 146 |
---
|
| 147 |
|
| 148 |
-
## 10.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
|
|
|
| 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 |
-
|
| 46 |
-
|
| 47 |
-
|
| 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 |
-
|
| 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(
|
| 70 |
-
.offset(
|
| 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)
|
| 130 |
-
if (job.userId !== ctx.session.user.id)
|
| 131 |
if (job.status !== "failed" && job.status !== "cancelled") {
|
| 132 |
-
|
| 133 |
}
|
| 134 |
|
| 135 |
if (!job.inputJson || typeof job.inputJson !== "object") {
|
| 136 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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)
|
| 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)
|
| 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)
|
| 232 |
-
if (attempt.userId !== userId)
|
| 233 |
-
if (attempt.status !== "in_progress")
|
| 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)
|
| 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)
|
| 290 |
-
if (attempt.userId !== userId)
|
| 291 |
-
if (attempt.status !== "in_progress")
|
| 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)
|
| 424 |
-
if (attempt.userId !== userId)
|
| 425 |
-
if (attempt.status !== "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 |
-
|
| 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(
|
| 476 |
-
.offset(
|
| 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
|
| 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 |
-
|
| 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
|
| 36 |
-
|
|
|
|
| 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(
|
| 57 |
-
.offset(
|
| 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 |
-
|
| 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(
|
| 95 |
-
.offset(
|
| 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 |
-
|
| 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
|
| 339 |
}),
|
| 340 |
|
| 341 |
delete: protectedProcedure
|
| 342 |
.input(z.object({ id: z.string().uuid() }))
|
| 343 |
.mutation(async ({ ctx, input }) => {
|
| 344 |
-
await db
|
| 345 |
-
.
|
| 346 |
-
.
|
| 347 |
-
|
| 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 |
-
|
| 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 |
-
|
| 366 |
-
const
|
| 367 |
-
|
| 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 |
-
|
| 390 |
-
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
| 72 |
-
.offset(
|
| 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 |
-
|
| 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(
|
| 131 |
-
.offset(
|
| 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
|
| 278 |
}),
|
| 279 |
|
| 280 |
delete: protectedProcedure
|
| 281 |
.input(z.object({ id: z.string().uuid() }))
|
| 282 |
.mutation(async ({ ctx, input }) => {
|
| 283 |
-
await db
|
| 284 |
-
.
|
| 285 |
-
.
|
| 286 |
-
|
| 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(
|
| 308 |
.from(testPackage)
|
| 309 |
.where(eq(testPackage.id, packageId))
|
| 310 |
.limit(1);
|
| 311 |
|
| 312 |
-
|
| 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)
|
| 345 |
|
| 346 |
const [pkg] = await db
|
| 347 |
-
.select(
|
| 348 |
.from(testPackage)
|
| 349 |
.where(eq(testPackage.id, section.packageId))
|
| 350 |
.limit(1);
|
| 351 |
|
| 352 |
-
|
| 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)
|
| 394 |
|
| 395 |
const [pkg] = await db
|
| 396 |
-
.select(
|
| 397 |
.from(testPackage)
|
| 398 |
.where(eq(testPackage.id, section.packageId))
|
| 399 |
.limit(1);
|
| 400 |
|
| 401 |
-
|
| 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)
|
| 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)
|
| 444 |
|
| 445 |
const [pkg] = await db
|
| 446 |
-
.select(
|
| 447 |
.from(testPackage)
|
| 448 |
.where(eq(testPackage.id, section.packageId))
|
| 449 |
.limit(1);
|
| 450 |
|
| 451 |
-
|
| 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)
|
| 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 |
-
|
| 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 |
-
|
| 50 |
-
conditions.push(
|
| 51 |
}
|
| 52 |
|
| 53 |
if (input?.search) {
|
| 54 |
-
const
|
| 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(
|
| 97 |
-
.offset(
|
| 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 |
-
|
| 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
|
| 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(
|
| 163 |
-
.offset(
|
| 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(
|
| 277 |
.limit(1);
|
| 278 |
|
| 279 |
-
if (!existing)
|
|
|
|
|
|
|
|
|
|
| 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 |
-
.
|
| 294 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
});
|