diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3a64d0b7..6c18b508 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,10 +1,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; + +import { validateBody } from '@/lib/validation'; +import { LoginRequestSchema } from '@/types/api/auth.dto'; +import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; + import type { AuthResponse } from '@/types/api'; import { edgeLog } from '@/../infra/edge-config'; export const runtime = 'edge'; + +// --------------------------------------------------------------------------- +// POST /api/auth/login +// --------------------------------------------------------------------------- + + +export async function POST( + request: NextRequest, +): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; export async function POST(request: NextRequest) { edgeLog('info', '/api/auth/login', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); @@ -12,9 +28,12 @@ export async function POST(request: NextRequest) { return rateLimitResponse as NextResponse<{ message: string }>; } - try { - const body = await request.json(); - const { email, password } = body; + + const result = validateBody(LoginRequestSchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + + + const { email, password } = result.data; // Mock validation if (!email || !password) { @@ -23,6 +42,39 @@ export async function POST(request: NextRequest) { ) as NextResponse<{ message: string }>; } + + + // Mock: demo credentials + if (email === 'demo@teachlink.com' && password === 'password123') { + return addHeaders( + NextResponse.json( + { + message: 'Login successful', + user: { id: '1', name: 'Demo User', email }, + token: `mock-jwt-token-${Date.now()}`, + }, + { status: 200 }, + ), + ); + } + + // Mock: accept any valid email + password >= 6 chars + if (password.length >= 6) { + return addHeaders( + NextResponse.json( + { + message: 'Login successful', + user: { + id: Math.random().toString(36).substring(2, 9), + name: email.split('@')[0], + email, + }, + token: `mock-jwt-token-${Date.now()}`, + }, + { status: 200 }, + ), + ); + // Mock authentication - check for demo credentials if (email === 'demo@teachlink.com' && password === 'password123') { // Simulate successful login @@ -69,5 +121,8 @@ export async function POST(request: NextRequest) { return addHeaders( NextResponse.json({ message: 'Internal server error' }, { status: 500 }), ) as NextResponse<{ message: string }>; + } + + return addHeaders(NextResponse.json({ message: 'Invalid email or password' }, { status: 401 })); } diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index fb603469..18652493 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,10 +1,27 @@ import { NextRequest, NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; + +import { validateBody } from '@/lib/validation'; +import { SignupRequestSchema } from '@/types/api/auth.dto'; +import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; + +// --------------------------------------------------------------------------- +// POST /api/auth/signup +// --------------------------------------------------------------------------- + + +export async function POST( + request: NextRequest, +): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + import type { AuthResponse } from '@/types/api'; import { edgeLog } from '@/../infra/edge-config'; export const runtime = 'edge'; + export async function POST(request: NextRequest) { edgeLog('info', '/api/auth/signup', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); @@ -16,6 +33,19 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { name, email, password, confirmPassword } = body; + + + const result = validateBody(SignupRequestSchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + + const { name, email } = result.data; + + + // Mock: block already-registered email + if (email === 'existing@teachlink.com') { + return addHeaders(NextResponse.json({ message: 'Email already registered' }, { status: 409 })); + } + if (!name || !email || !password || !confirmPassword) { return addHeaders( NextResponse.json({ message: 'All fields are required' }, { status: 400 }), @@ -28,6 +58,7 @@ export async function POST(request: NextRequest) { ) as NextResponse<{ message: string }>; } + if (password.length < 6) { return addHeaders( NextResponse.json({ message: 'Password must be at least 6 characters' }, { status: 400 }), @@ -40,17 +71,23 @@ export async function POST(request: NextRequest) { ) as NextResponse<{ message: string }>; } - return addHeaders( - NextResponse.json( - { - message: 'Account created successfully', - user: { - id: Math.random().toString(36).substr(2, 9), - name: name, - email: email, - }, - token: 'mock-jwt-token-' + Date.now(), + + return addHeaders( + NextResponse.json( + { + message: 'Account created successfully', + user: { + id: Math.random().toString(36).substring(2, 9), + name, + email, }, + + token: `mock-jwt-token-${Date.now()}`, + }, + { status: 201 }, + ), + ); + { status: 201 }, ), ) as NextResponse; @@ -60,4 +97,5 @@ export async function POST(request: NextRequest) { NextResponse.json({ message: 'Internal server error' }, { status: 500 }), ) as NextResponse<{ message: string }>; } + } diff --git a/src/app/api/bookmarks/route.ts b/src/app/api/bookmarks/route.ts index 06ed4684..519d82fa 100644 --- a/src/app/api/bookmarks/route.ts +++ b/src/app/api/bookmarks/route.ts @@ -1,6 +1,28 @@ import { NextResponse } from 'next/server'; -import type { VideoBookmark, ApiResponse, SuccessResponse } from '@/types/api'; +import type { VideoBookmark } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; + +import { validateBody, validateQuery } from '@/lib/validation'; +import { + BookmarksGetQuerySchema, + BookmarksCreateBodySchema, + BookmarksPatchBodySchema, + BookmarksDeleteBodySchema, +} from '@/types/api/bookmarks.dto'; +import type { + BookmarksListResponseDTO, + BookmarkResponseDTO, + BookmarksSuccessResponseDTO, +} from '@/types/api/bookmarks.dto'; + +// --------------------------------------------------------------------------- +// In-memory store (replace with DB layer) +// --------------------------------------------------------------------------- + +const bookmarksStore = new Map(); + +const keyFor = (userId: string | undefined, lessonId: string): string => { + import { edgeLog } from '@/../infra/edge-config'; export const runtime = 'edge'; @@ -10,10 +32,26 @@ type PersistedVideoBookmark = VideoBookmark; const bookmarksStore = new Map(); const keyFor = (userId: string | undefined, lessonId: string) => { + const safeUserId = encodeURIComponent(userId ?? 'anon'); return `${safeUserId}::${encodeURIComponent(lessonId)}`; }; + +// --------------------------------------------------------------------------- +// GET /api/bookmarks?lessonId=&userId= +// --------------------------------------------------------------------------- + +export async function GET( + request: Request, +): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const { searchParams } = new URL(request.url); + const result = validateQuery(BookmarksGetQuerySchema, searchParams); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function GET(request: Request) { edgeLog('info', '/api/bookmarks', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -33,14 +71,29 @@ export async function GET(request: Request) { ); } + return addHeaders( NextResponse.json({ - data: bookmarksStore.get(keyFor(userId, lessonId)) ?? [], + data: bookmarksStore.get(keyFor(result.data.userId, result.data.lessonId)) ?? [], success: true, }), ); } + +// --------------------------------------------------------------------------- +// POST /api/bookmarks +// --------------------------------------------------------------------------- + +export async function POST( + request: Request, +): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const result = validateBody(BookmarksCreateBodySchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function POST(request: Request) { edgeLog('info', '/api/bookmarks', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -60,26 +113,36 @@ export async function POST(request: Request) { ); } - const now = new Date().toISOString(); - const id = body.bookmark.id ?? `bookmark-${Date.now()}`; - const persisted: PersistedVideoBookmark = { - id, - time: Math.max(0, body.bookmark.time), - title: body.bookmark.title.trim(), - note: body.bookmark.note?.trim() ? body.bookmark.note.trim() : undefined, + const now = new Date().toISOString(); + const persisted: VideoBookmark = { + id: result.data.bookmark.id ?? `bookmark-${Date.now()}`, + time: result.data.bookmark.time, + title: result.data.bookmark.title, + note: result.data.bookmark.note, createdAt: now, updatedAt: now, }; - const key = keyFor(body.userId, body.lessonId); + const key = keyFor(result.data.userId, result.data.lessonId); const prev = bookmarksStore.get(key) ?? []; - const next = [persisted, ...prev.filter((b) => b.id !== persisted.id)]; - bookmarksStore.set(key, next); + bookmarksStore.set(key, [persisted, ...prev.filter((b) => b.id !== persisted.id)]); return addHeaders(NextResponse.json({ success: true, data: persisted })); } + +// --------------------------------------------------------------------------- +// PATCH /api/bookmarks +// --------------------------------------------------------------------------- + +export async function PATCH(request: Request): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const result = validateBody(BookmarksPatchBodySchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function PATCH(request: Request) { edgeLog('info', '/api/bookmarks', 'PATCH request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -102,26 +165,41 @@ export async function PATCH(request: Request) { ); } - const key = keyFor(body.userId, body.lessonId); + + const key = keyFor(result.data.userId, result.data.lessonId); const prev = bookmarksStore.get(key) ?? []; const now = new Date().toISOString(); - const next = prev.map((b) => - b.id === body.id - ? { - ...b, - title: body.title.trim(), - note: body.note?.trim() ? body.note.trim() : undefined, - time: typeof body.time === 'number' ? Math.max(0, body.time) : b.time, - updatedAt: now, - } - : b, + bookmarksStore.set( + key, + prev.map((b) => + b.id === result.data.id + ? { + ...b, + title: result.data.title, + note: result.data.note, + time: result.data.time !== undefined ? result.data.time : b.time, + updatedAt: now, + } + : b, + ), ); - bookmarksStore.set(key, next); return addHeaders(NextResponse.json({ success: true })); } + +// --------------------------------------------------------------------------- +// DELETE /api/bookmarks +// --------------------------------------------------------------------------- + +export async function DELETE(request: Request): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const result = validateBody(BookmarksDeleteBodySchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function DELETE(request: Request) { edgeLog('info', '/api/bookmarks', 'DELETE request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -136,11 +214,12 @@ export async function DELETE(request: Request) { ); } - const key = keyFor(body.userId, body.lessonId); + + const key = keyFor(result.data.userId, result.data.lessonId); const prev = bookmarksStore.get(key) ?? []; bookmarksStore.set( key, - prev.filter((b) => b.id !== body.id), + prev.filter((b) => b.id !== result.data.id), ); return addHeaders(NextResponse.json({ success: true })); diff --git a/src/app/api/courses/[id]/route.ts b/src/app/api/courses/[id]/route.ts index 13947d79..67b61a57 100644 --- a/src/app/api/courses/[id]/route.ts +++ b/src/app/api/courses/[id]/route.ts @@ -1,20 +1,42 @@ import { NextResponse } from 'next/server'; -import type { Course, ApiResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; + +import { validateBody } from '@/lib/validation'; +import { CourseByIdParamsSchema } from '@/types/api/courses.dto'; +import type { CourseResponseDTO } from '@/types/api/courses.dto'; + +// --------------------------------------------------------------------------- +// GET /api/courses/[id] +// --------------------------------------------------------------------------- + + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise> { + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + + import { edgeLog, CDN_CACHE_HEADERS } from '@/../infra/edge-config'; export const runtime = 'edge'; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { edgeLog('info', '/api/courses/[id]', 'GET request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { - return rateLimitResponse as NextResponse>; + return rateLimitResponse as NextResponse; } - const { id } = await params; + const rawParams = await params; + const result = validateBody(CourseByIdParamsSchema, rawParams); + if (!result.ok) return addHeaders(result.error) as NextResponse; + + // Mock course lookup — replace with real DB query const course = { - id, + id: result.data.id, title: 'Web3 UX Design Principles', description: 'Create intuitive interfaces for decentralized applications', instructor: 'Sarah Johnson', diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts index d5849b4e..f3a5f773 100644 --- a/src/app/api/courses/route.ts +++ b/src/app/api/courses/route.ts @@ -1,20 +1,33 @@ import { NextResponse } from 'next/server'; import type { Course, PaginatedResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; + +import { validateQuery } from '@/lib/validation'; +import { CourseListQuerySchema } from '@/types/api/courses.dto'; +import type { CourseListResponseDTO } from '@/types/api/courses.dto'; + + +export async function GET(request: Request): Promise> { + +export async function GET(request: Request) { + + import { edgeLog, CDN_CACHE_HEADERS } from '@/../infra/edge-config'; export const runtime = 'edge'; export async function GET(request: Request) { edgeLog('info', '/api/courses', 'GET request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { - return rateLimitResponse as NextResponse>; + return rateLimitResponse as NextResponse; } const { searchParams } = new URL(request.url); - const limit = parseInt(searchParams.get('limit') || '10', 10); - const cursor = searchParams.get('cursor'); + const result = validateQuery(CourseListQuerySchema, searchParams); + if (!result.ok) return addHeaders(result.error) as NextResponse; + const { limit, cursor } = result.data as { limit: number; cursor?: string }; const courses = [ { diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 644f2e4b..8cb25d9d 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -1,6 +1,28 @@ import { NextResponse } from 'next/server'; -import type { VideoNote, ApiResponse, SuccessResponse } from '@/types/api'; +import type { VideoNote } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; + +import { validateBody, validateQuery } from '@/lib/validation'; +import { + NotesGetQuerySchema, + NotesCreateBodySchema, + NotesPatchBodySchema, + NotesDeleteBodySchema, +} from '@/types/api/notes.dto'; +import type { + NotesListResponseDTO, + NoteResponseDTO, + NotesSuccessResponseDTO, +} from '@/types/api/notes.dto'; + +// --------------------------------------------------------------------------- +// In-memory store (replace with DB layer) +// --------------------------------------------------------------------------- + +const notesStore = new Map(); + +const keyFor = (userId: string | undefined, lessonId: string): string => { + import { edgeLog } from '@/../infra/edge-config'; export const runtime = 'edge'; @@ -10,10 +32,26 @@ type PersistedVideoNote = VideoNote; const notesStore = new Map(); const keyFor = (userId: string | undefined, lessonId: string) => { + const safeUserId = encodeURIComponent(userId ?? 'anon'); return `${safeUserId}::${encodeURIComponent(lessonId)}`; }; + +// --------------------------------------------------------------------------- +// GET /api/notes?lessonId=&userId= +// --------------------------------------------------------------------------- + +export async function GET( + request: Request, +): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const { searchParams } = new URL(request.url); + const result = validateQuery(NotesGetQuerySchema, searchParams); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function GET(request: Request) { edgeLog('info', '/api/notes', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -31,14 +69,29 @@ export async function GET(request: Request) { ); } + return addHeaders( NextResponse.json({ - data: notesStore.get(keyFor(userId, lessonId)) ?? [], + data: notesStore.get(keyFor(result.data.userId, result.data.lessonId)) ?? [], success: true, }), ); } + +// --------------------------------------------------------------------------- +// POST /api/notes +// --------------------------------------------------------------------------- + +export async function POST( + request: Request, +): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const result = validateBody(NotesCreateBodySchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function POST(request: Request) { edgeLog('info', '/api/notes', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -58,24 +111,35 @@ export async function POST(request: Request) { ); } + const now = new Date().toISOString(); - const id = body.note.id ?? `note-${Date.now()}`; - const persisted: PersistedVideoNote = { - id, - time: Math.max(0, body.note.time), - text: body.note.text.trim(), + const persisted: VideoNote = { + id: result.data.note.id ?? `note-${Date.now()}`, + time: result.data.note.time, + text: result.data.note.text, createdAt: now, updatedAt: now, }; - const key = keyFor(body.userId, body.lessonId); + const key = keyFor(result.data.userId, result.data.lessonId); const prev = notesStore.get(key) ?? []; - const next = [persisted, ...prev.filter((n) => n.id !== persisted.id)]; - notesStore.set(key, next); + notesStore.set(key, [persisted, ...prev.filter((n) => n.id !== persisted.id)]); return addHeaders(NextResponse.json({ success: true, data: persisted })); } + +// --------------------------------------------------------------------------- +// PATCH /api/notes +// --------------------------------------------------------------------------- + +export async function PATCH(request: Request): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const result = validateBody(NotesPatchBodySchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function PATCH(request: Request) { edgeLog('info', '/api/notes', 'PATCH request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -97,25 +161,40 @@ export async function PATCH(request: Request) { ); } - const key = keyFor(body.userId, body.lessonId); + + const key = keyFor(result.data.userId, result.data.lessonId); const prev = notesStore.get(key) ?? []; const now = new Date().toISOString(); - const next = prev.map((n) => - n.id === body.id - ? { - ...n, - text: body.text.trim(), - time: typeof body.time === 'number' ? Math.max(0, body.time) : n.time, - updatedAt: now, - } - : n, + notesStore.set( + key, + prev.map((n) => + n.id === result.data.id + ? { + ...n, + text: result.data.text, + time: result.data.time !== undefined ? result.data.time : n.time, + updatedAt: now, + } + : n, + ), ); - notesStore.set(key, next); return addHeaders(NextResponse.json({ success: true })); } + +// --------------------------------------------------------------------------- +// DELETE /api/notes +// --------------------------------------------------------------------------- + +export async function DELETE(request: Request): Promise> { + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); + if (rateLimitResponse) return rateLimitResponse as NextResponse; + + const result = validateBody(NotesDeleteBodySchema, await request.json()); + if (!result.ok) return addHeaders(result.error) as NextResponse; + export async function DELETE(request: Request) { edgeLog('info', '/api/notes', 'DELETE request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); @@ -130,11 +209,12 @@ export async function DELETE(request: Request) { ); } - const key = keyFor(body.userId, body.lessonId); + + const key = keyFor(result.data.userId, result.data.lessonId); const prev = notesStore.get(key) ?? []; notesStore.set( key, - prev.filter((n) => n.id !== body.id), + prev.filter((n) => n.id !== result.data.id), ); return addHeaders(NextResponse.json({ success: true })); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 00000000..ee970fe1 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { ZodTypeAny, ZodError, z } from 'zod'; + +// --------------------------------------------------------------------------- +// Discriminated union result type — TypeScript narrows correctly on `.ok` +// --------------------------------------------------------------------------- + +type ValidationSuccess = { ok: true; data: T }; +type ValidationFailure = { ok: false; error: NextResponse }; +export type ValidationResult = ValidationSuccess | ValidationFailure; + +// --------------------------------------------------------------------------- +// Validates a raw JSON body against a Zod schema +// --------------------------------------------------------------------------- + +export function validateBody( + schema: S, + input: unknown, +): ValidationResult> { + const result = schema.safeParse(input); + if (!result.success) { + return { + ok: false, + error: NextResponse.json( + { success: false, message: formatZodError(result.error) }, + { status: 400 }, + ), + }; + } + return { ok: true, data: result.data as z.infer }; +} + +// --------------------------------------------------------------------------- +// Validates URLSearchParams (converted to plain object) against a Zod schema +// --------------------------------------------------------------------------- + +export function validateQuery( + schema: S, + searchParams: URLSearchParams, +): ValidationResult> { + const raw = Object.fromEntries(searchParams.entries()); + return validateBody(schema, raw); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatZodError(error: ZodError): string { + return error.errors.map((e) => e.message).join('; '); +} diff --git a/src/types/api/auth.dto.ts b/src/types/api/auth.dto.ts new file mode 100644 index 00000000..b9d8cea8 --- /dev/null +++ b/src/types/api/auth.dto.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; +import type { AuthResponse, User } from '@/types/api'; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +export const LoginRequestSchema = z.object({ + email: z.string({ required_error: 'Email is required' }).email('Invalid email address'), + password: z.string({ required_error: 'Password is required' }).min(1, 'Password is required'), +}); + +export const SignupRequestSchema = z + .object({ + name: z.string({ required_error: 'Name is required' }).min(1, 'Name is required'), + email: z.string({ required_error: 'Email is required' }).email('Invalid email address'), + password: z + .string({ required_error: 'Password is required' }) + .min(6, 'Password must be at least 6 characters'), + confirmPassword: z + .string({ required_error: 'Confirm password is required' }) + .min(1, 'Confirm password is required'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); + +// --------------------------------------------------------------------------- +// DTO types inferred from schemas +// --------------------------------------------------------------------------- + +export type LoginRequestDTO = z.infer; +export type SignupRequestDTO = z.infer; + +// --------------------------------------------------------------------------- +// Response DTOs (re-export from shared types for co-location) +// --------------------------------------------------------------------------- + +export type AuthUserDTO = User; +export type AuthResponseDTO = AuthResponse; + +export interface AuthErrorDTO { + message: string; +} diff --git a/src/types/api/bookmarks.dto.ts b/src/types/api/bookmarks.dto.ts new file mode 100644 index 00000000..e815be99 --- /dev/null +++ b/src/types/api/bookmarks.dto.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; +import type { VideoBookmark, ApiResponse, SuccessResponse } from '@/types/api'; + +// --------------------------------------------------------------------------- +// Shared field schemas +// --------------------------------------------------------------------------- + +const lessonIdField = z + .string({ required_error: 'lessonId is required' }) + .min(1, 'lessonId is required'); +const userIdField = z.string().optional(); +const bookmarkIdField = z + .string({ required_error: 'Bookmark ID is required' }) + .min(1, 'Bookmark ID is required'); +const timeField = z + .number({ required_error: 'time is required' }) + .finite() + .nonnegative('time must be >= 0'); +const titleField = z + .string({ required_error: 'title is required' }) + .min(1, 'title is required') + .transform((v) => v.trim()); +const noteField = z + .string() + .optional() + .transform((v) => (v?.trim() ? v.trim() : undefined)); + +// --------------------------------------------------------------------------- +// Request schemas +// --------------------------------------------------------------------------- + +export const BookmarksGetQuerySchema = z.object({ + lessonId: lessonIdField, + userId: userIdField, +}); + +export const BookmarksCreateBodySchema = z.object({ + userId: userIdField, + lessonId: lessonIdField, + bookmark: z.object({ + id: z.string().optional(), + time: timeField, + title: titleField, + note: noteField, + }), +}); + +export const BookmarksPatchBodySchema = z.object({ + userId: userIdField, + lessonId: lessonIdField, + id: bookmarkIdField, + title: titleField, + note: noteField, + time: timeField.optional(), +}); + +export const BookmarksDeleteBodySchema = z.object({ + userId: userIdField, + lessonId: lessonIdField, + id: bookmarkIdField, +}); + +// --------------------------------------------------------------------------- +// DTO types inferred from schemas +// --------------------------------------------------------------------------- + +export type BookmarksGetQueryDTO = z.infer; +export type BookmarksCreateBodyDTO = z.infer; +export type BookmarksPatchBodyDTO = z.infer; +export type BookmarksDeleteBodyDTO = z.infer; + +// --------------------------------------------------------------------------- +// Response DTOs +// --------------------------------------------------------------------------- + +export type VideoBookmarkDTO = VideoBookmark; +export type BookmarksListResponseDTO = ApiResponse; +export type BookmarkResponseDTO = ApiResponse; +export type BookmarksSuccessResponseDTO = SuccessResponse; diff --git a/src/types/api/courses.dto.ts b/src/types/api/courses.dto.ts new file mode 100644 index 00000000..996d38fe --- /dev/null +++ b/src/types/api/courses.dto.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import type { Course, ApiResponse, PaginatedResponse } from '@/types/api'; + +// --------------------------------------------------------------------------- +// Request schemas +// --------------------------------------------------------------------------- + +export const CourseListQuerySchema = z.object({ + limit: z + .string() + .optional() + .transform((val) => (val !== undefined ? parseInt(val, 10) : 10)) + .pipe(z.number().int().min(1).max(100)), + cursor: z.string().optional(), +}); + +export const CourseByIdParamsSchema = z.object({ + id: z.string().min(1, 'Course ID is required'), +}); + +// --------------------------------------------------------------------------- +// DTO types inferred from schemas +// --------------------------------------------------------------------------- + +export type CourseListQueryDTO = z.infer; +export type CourseByIdParamsDTO = z.infer; + +// --------------------------------------------------------------------------- +// Response DTOs +// --------------------------------------------------------------------------- + +export type CourseDTO = Course; +export type CourseListResponseDTO = PaginatedResponse; +export type CourseResponseDTO = ApiResponse; diff --git a/src/types/api/index.ts b/src/types/api/index.ts new file mode 100644 index 00000000..edd8d053 --- /dev/null +++ b/src/types/api/index.ts @@ -0,0 +1,15 @@ +/** + * types/api — Request/Response DTOs and Zod validation schemas for all API routes. + * + * Each module follows the pattern: + * - Schema — Zod schema used for runtime validation + * - DTO — TypeScript type inferred from the schema (or re-exported from shared types) + * + * Usage in route handlers: + * import { LoginRequestSchema, type LoginRequestDTO } from '@/types/api/auth.dto'; + */ + +export * from './auth.dto'; +export * from './notes.dto'; +export * from './courses.dto'; +export * from './bookmarks.dto'; diff --git a/src/types/api/notes.dto.ts b/src/types/api/notes.dto.ts new file mode 100644 index 00000000..a54178cb --- /dev/null +++ b/src/types/api/notes.dto.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import type { VideoNote, ApiResponse, SuccessResponse } from '@/types/api'; + +// --------------------------------------------------------------------------- +// Shared field schemas +// --------------------------------------------------------------------------- + +const lessonIdField = z + .string({ required_error: 'lessonId is required' }) + .min(1, 'lessonId is required'); +const userIdField = z.string().optional(); +const noteIdField = z + .string({ required_error: 'Note ID is required' }) + .min(1, 'Note ID is required'); +const timeField = z + .number({ required_error: 'time is required' }) + .finite() + .nonnegative('time must be >= 0'); +const textField = z + .string({ required_error: 'text is required' }) + .min(1, 'text is required') + .transform((v) => v.trim()); + +// --------------------------------------------------------------------------- +// Request schemas +// --------------------------------------------------------------------------- + +export const NotesGetQuerySchema = z.object({ + lessonId: lessonIdField, + userId: userIdField, +}); + +export const NotesCreateBodySchema = z.object({ + userId: userIdField, + lessonId: lessonIdField, + note: z.object({ + id: z.string().optional(), + time: timeField, + text: textField, + }), +}); + +export const NotesPatchBodySchema = z.object({ + userId: userIdField, + lessonId: lessonIdField, + id: noteIdField, + text: textField, + time: timeField.optional(), +}); + +export const NotesDeleteBodySchema = z.object({ + userId: userIdField, + lessonId: lessonIdField, + id: noteIdField, +}); + +// --------------------------------------------------------------------------- +// DTO types inferred from schemas +// --------------------------------------------------------------------------- + +export type NotesGetQueryDTO = z.infer; +export type NotesCreateBodyDTO = z.infer; +export type NotesPatchBodyDTO = z.infer; +export type NotesDeleteBodyDTO = z.infer; + +// --------------------------------------------------------------------------- +// Response DTOs +// --------------------------------------------------------------------------- + +export type VideoNoteDTO = VideoNote; +export type NotesListResponseDTO = ApiResponse; +export type NoteResponseDTO = ApiResponse; +export type NotesSuccessResponseDTO = SuccessResponse;