diff --git a/CLAUDE.md b/CLAUDE.md index 42327a58..20dc37f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,71 @@ pnpm format:check # Check formatting - All API routes should have JSDoc comments - Run `pnpm lint` before committing +## Input Validation + +All API endpoints should use a **validate function** for input parsing. Use Zod for schema validation. + +### Pattern + +Create a `validateBody.ts` or `validateQuery.ts` file: + +```typescript +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +// Define the schema +export const createExampleBodySchema = z.object({ + name: z.string({ message: "name is required" }).min(1, "name cannot be empty"), + id: z.string().uuid("id must be a valid UUID").optional(), +}); + +// Export the inferred type +export type CreateExampleBody = z.infer; + +/** + * Validates request body for POST /api/example. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateExampleBody(body: unknown): NextResponse | CreateExampleBody { + const result = createExampleBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} +``` + +### Usage in Handler + +```typescript +const validated = validateCreateExampleBody(body); +if (validated instanceof NextResponse) { + return validated; +} +// validated is now typed as CreateExampleBody +``` + +### Naming Convention + +- `validateBody.ts` - For POST/PUT request bodies +- `validateQuery.ts` - For GET query parameters + ## Constants (`lib/const.ts`) All shared constants live in `lib/const.ts`: diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts new file mode 100644 index 00000000..4e311045 --- /dev/null +++ b/app/api/chats/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createChatHandler } from "@/lib/chats/createChatHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/chats + * + * Create a new chat room. + * + * Authentication: x-api-key header required. + * The account ID is inferred from the API key. + * + * Optional body parameters: + * - artistId: UUID of the artist account the chat is associated with + * - chatId: UUID for the new chat (auto-generated if not provided) + * + * @param request - The request object + * @returns A NextResponse with the created chat or an error + */ +export async function POST(request: NextRequest): Promise { + return createChatHandler(request); +} diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts new file mode 100644 index 00000000..ad1c592f --- /dev/null +++ b/lib/chats/createChatHandler.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; + +/** + * Handler for creating a new chat room. + * + * Requires authentication via x-api-key header. + * The account ID is inferred from the API key. + * + * @param request - The NextRequest object + * @returns A NextResponse with the created chat or an error + */ +export async function createChatHandler(request: NextRequest): Promise { + try { + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + const accountId = accountIdOrError; + + const body = await safeParseJson(request); + + const validated = validateCreateChatBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const { artistId, chatId } = validated; + + const roomId = chatId || generateUUID(); + + const chat = await insertRoom({ + id: roomId, + account_id: accountId, + artist_id: artistId || null, + topic: null, + }); + + return NextResponse.json( + { + status: "success", + chat, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] createChatHandler:", error); + return NextResponse.json( + { + status: "error", + message: "Failed to create chat", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/chats/validateCreateChatBody.ts b/lib/chats/validateCreateChatBody.ts new file mode 100644 index 00000000..98d8ab4f --- /dev/null +++ b/lib/chats/validateCreateChatBody.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createChatBodySchema = z.object({ + artistId: z.string().uuid("artistId must be a valid UUID").optional(), + chatId: z.string().uuid("chatId must be a valid UUID").optional(), +}); + +export type CreateChatBody = z.infer; + +/** + * Validates request body for POST /api/chats. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateChatBody(body: unknown): NextResponse | CreateChatBody { + const result = createChatBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/emails/sendEmailSchema.ts b/lib/emails/sendEmailSchema.ts index 7411dd4d..42c0920e 100644 --- a/lib/emails/sendEmailSchema.ts +++ b/lib/emails/sendEmailSchema.ts @@ -28,8 +28,7 @@ export const sendEmailSchema = z.object({ .string() .describe( "Room ID to include in the email footer link. Use the active_conversation_id from context.", - ) - .optional(), + ), }); export type SendEmailInput = z.infer; diff --git a/lib/networking/safeParseJson.ts b/lib/networking/safeParseJson.ts new file mode 100644 index 00000000..3d510ba7 --- /dev/null +++ b/lib/networking/safeParseJson.ts @@ -0,0 +1,16 @@ +import { NextRequest } from "next/server"; + +/** + * Safely parses JSON from a request body. + * Returns an empty object if the body is empty or invalid JSON. + * + * @param request - The NextRequest object + * @returns The parsed JSON body or an empty object + */ +export async function safeParseJson(request: NextRequest): Promise { + try { + return await request.json(); + } catch { + return {}; + } +}