Spaces:
Sleeping
Sleeping
| # API Design Instructions | |
| > **Parent Document**: `main-instructions.md` - Read that first! | |
| ## 🔌 API Design Principles | |
| ### Rule #1: API Endpoints Serve the UI | |
| Every API endpoint must exist to support a specific UI feature or user action. | |
| **The Process**: | |
| 1. Identify UI interaction (button click, form submit, page load) | |
| 2. Determine what data is needed | |
| 3. Create API endpoint that provides exactly that data | |
| 4. No more, no less | |
| ### API Architecture in Next.js | |
| ``` | |
| src/ | |
| app/ | |
| api/ # REST API Routes (when needed) | |
| auth/ | |
| route.ts | |
| work-orders/ | |
| route.ts | |
| [id]/ | |
| route.ts | |
| [feature]/ | |
| actions.ts # Server Actions (preferred for mutations) | |
| ``` | |
| ## 🔒 Type Safety in Server Actions | |
| ### NEVER Use `any` Type | |
| **ALWAYS validate inputs with Zod - no exceptions:** | |
| ```typescript | |
| "use server"; | |
| import { z } from "zod"; | |
| import type { ActionResult } from "@/lib/types"; | |
| // ❌ BAD - using any | |
| export async function createProject(data: any): Promise<ActionResult<Project>> { | |
| // FORBIDDEN! Never use 'any' | |
| } | |
| // ❌ BAD - unvalidated FormData | |
| export async function createProject( | |
| formData: FormData, | |
| ): Promise<ActionResult<Project>> { | |
| const name = formData.get("name"); // Type is FormDataEntryValue | null - not safe! | |
| // No validation! | |
| } | |
| // ✅ GOOD - strict Zod schema | |
| const createProjectSchema = z.object({ | |
| name: z.string().min(1, "Name is required").max(255), | |
| description: z.string().optional(), | |
| clientId: z.string().uuid("Invalid client ID"), | |
| startDate: z.string().datetime(), | |
| budget: z.number().positive("Budget must be positive"), | |
| }); | |
| type CreateProjectInput = z.infer<typeof createProjectSchema>; | |
| export async function createProject( | |
| input: unknown, // Unknown until validated | |
| ): Promise<ActionResult<Project>> { | |
| // Validate at runtime + get full type safety | |
| const result = createProjectSchema.safeParse(input); | |
| if (!result.success) { | |
| return { | |
| success: false, | |
| error: result.error.errors | |
| .map((e) => `${e.path.join(".")}: ${e.message}`) | |
| .join(", "), | |
| }; | |
| } | |
| // result.data is now fully typed as CreateProjectInput | |
| const validatedData = result.data; | |
| // ... rest of implementation with full type safety | |
| } | |
| ``` | |
| ### Avoid Type Casting with `as` | |
| **Use Zod validation instead of unsafe casting:** | |
| ```typescript | |
| // ❌ BAD - type casting (unsafe!) | |
| const project = data as Project; // No validation! | |
| const userId = (formData.get("userId") as string).toUpperCase(); // Can crash! | |
| // ✅ GOOD - Zod validation | |
| const projectSchema = z.object({ | |
| id: z.string().uuid(), | |
| data: z.object({ | |
| name: z.string(), | |
| status: z.enum(["active", "completed", "cancelled"]), | |
| }), | |
| createdAt: z.coerce.date(), // Converts + validates | |
| updatedAt: z.coerce.date(), | |
| }); | |
| const project = projectSchema.parse(data); // Type-safe + runtime validation ✅ | |
| const userIdSchema = z.string().min(1); | |
| const userId = userIdSchema.parse(formData.get("userId")); // Safe ✅ | |
| ``` | |
| ### FormData Validation Pattern | |
| **Always validate FormData with Zod:** | |
| ```typescript | |
| "use server"; | |
| export async function updateUser( | |
| formData: FormData, | |
| ): Promise<ActionResult<User>> { | |
| // Convert FormData to object | |
| const rawData = Object.fromEntries(formData); | |
| // Define schema | |
| const schema = z.object({ | |
| userId: z.string().uuid(), | |
| name: z.string().min(1).max(255), | |
| email: z.string().email(), | |
| age: z.coerce.number().int().positive(), // Coerce string to number | |
| }); | |
| // Validate | |
| const result = schema.safeParse(rawData); | |
| if (!result.success) { | |
| return { success: false, error: result.error.message }; | |
| } | |
| // Now result.data is fully typed and validated | |
| const validatedData = result.data; | |
| // ... safe to use | |
| } | |
| ``` | |
| ## 🎯 ActionResult Pattern | |
| ### Use Shared ActionResult Type | |
| **ALWAYS use the shared ActionResult type with helper functions:** | |
| ```typescript | |
| import { type ActionResult, success, failure, toFailure } from "@/lib/types"; | |
| // ❌ BAD - inline result objects | |
| export async function createProject(input: unknown) { | |
| return { success: true as const, data: project }; | |
| return { success: false as const, error: "Failed" }; | |
| } | |
| // ✅ GOOD - use helpers | |
| export async function createProject( | |
| input: unknown, | |
| ): Promise<ActionResult<{ id: string }>> { | |
| try { | |
| // ... validation and logic | |
| return success({ id: result.id }); | |
| } catch (error) { | |
| return toFailure(error, "Failed to create project"); | |
| } | |
| } | |
| ``` | |
| ### Error Handling with toFailure | |
| **Use `toFailure()` for catch blocks - it handles unknown error types safely:** | |
| ```typescript | |
| // ❌ BAD - manual error handling | |
| catch (error) { | |
| return failure(error instanceof Error ? error.message : "Unknown error"); | |
| } | |
| // ✅ GOOD - use toFailure helper | |
| catch (error) { | |
| console.error("Action error:", error); | |
| return toFailure(error, "Failed to perform action"); | |
| } | |
| ``` | |
| ## 🔑 Authentication Helper | |
| ### Use Shared getCurrentUser Helper | |
| **ALWAYS use the shared helper instead of duplicating auth logic:** | |
| ```typescript | |
| // ❌ BAD - duplicated auth logic in each action file | |
| async function getCurrentUser() { | |
| if (skipAuth) return mockUser; | |
| const session = await auth.api.getSession({ headers: await headers() }); | |
| return session?.user ?? null; | |
| } | |
| // ✅ GOOD - import shared helper | |
| import { getCurrentUser } from "@/lib/utils/get-current-user"; | |
| export async function myAction(input: unknown): Promise<ActionResult<Data>> { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return failure("Unauthorized. Please log in."); | |
| } | |
| // ... | |
| } | |
| ``` | |
| ## 📋 Zod Schema Patterns | |
| ### Use .partial() at Object Level | |
| **Prefer `.partial()` on object level instead of `.optional()` on each field:** | |
| ```typescript | |
| // ❌ BAD - verbose, repetitive | |
| const schema = z.object({ | |
| title: z.string().optional(), | |
| description: z.string().optional(), | |
| status: z.string().optional(), | |
| // ... 20 more fields with .optional() | |
| }); | |
| // ✅ GOOD - concise, maintainable | |
| const baseSchema = z.object({ | |
| title: z.string(), | |
| description: z.string(), | |
| status: z.string(), | |
| }); | |
| const updateSchema = baseSchema.partial(); // All fields optional | |
| const createSchema = baseSchema.partial().required({ title: true }); // title required | |
| ``` | |
| ### Shared Enums | |
| **Put reusable enum values in `@/lib/schemas/shared-enums.ts`:** | |
| ```typescript | |
| // ❌ BAD - duplicated in multiple files | |
| // In file A: | |
| const statusSchema = z.enum(["New", "In Progress", "Completed"]); | |
| // In file B: | |
| const STATUS_OPTIONS = ["New", "In Progress", "Completed"]; | |
| // ✅ GOOD - single source of truth | |
| // shared-enums.ts | |
| export const STATUS_VALUES = ["New", "In Progress", "Completed"] as const; | |
| export const statusSchema = z.enum(STATUS_VALUES); | |
| export type Status = z.infer<typeof statusSchema>; | |
| // Other files import from shared-enums | |
| import { STATUS_VALUES, statusSchema } from "@/lib/schemas/shared-enums"; | |
| ``` | |
| ## 🗃️ Drizzle Type Inference | |
| ### Use $inferSelect for Types | |
| **Use Drizzle's `$inferSelect` to avoid manual type definitions and casting:** | |
| ```typescript | |
| // ❌ BAD - manual type + casting | |
| type Project = { | |
| id: string; | |
| data: ProjectData; | |
| createdAt: Date; | |
| updatedAt: Date; | |
| }; | |
| const result = await db.select().from(projects); | |
| return result as Project[]; // Unsafe! | |
| // ✅ GOOD - inferred type from schema | |
| export type Project = typeof projects.$inferSelect; | |
| const result = await db.select().from(projects); | |
| return result; // Already typed correctly! | |
| ``` | |
| ## 🛡️ Security in Server Actions | |
| ### 1. Authentication Check (ALWAYS) | |
| **EVERY Server Action MUST check authentication:** | |
| ```typescript | |
| "use server"; | |
| import { getServerSession } from "@/lib/auth"; | |
| export async function deleteProject( | |
| projectId: string, | |
| ): Promise<ActionResult<void>> { | |
| // ❌ FORBIDDEN - no auth check | |
| // await db.delete(projects).where(eq(projects.id, projectId)); | |
| // ✅ REQUIRED - check authentication first | |
| const session = await getServerSession(); | |
| if (!session?.user) { | |
| return { success: false, error: "Unauthorized - please log in" }; | |
| } | |
| // Now proceed with validated user | |
| // ... rest of implementation | |
| } | |
| ``` | |
| ### 2. Authorization Check (ALWAYS) | |
| **Check if user has permission to perform the action:** | |
| ```typescript | |
| "use server"; | |
| export async function updateProject( | |
| projectId: string, | |
| updates: unknown, | |
| ): Promise<ActionResult<Project>> { | |
| // 1. Validate input | |
| const idSchema = z.string().uuid(); | |
| const projectIdResult = idSchema.safeParse(projectId); | |
| if (!projectIdResult.success) { | |
| return { success: false, error: "Invalid project ID" }; | |
| } | |
| const updateSchema = z.object({ | |
| name: z.string().min(1).max(255).optional(), | |
| status: z.enum(["active", "completed", "cancelled"]).optional(), | |
| }); | |
| const updatesResult = updateSchema.safeParse(updates); | |
| if (!updatesResult.success) { | |
| return { success: false, error: updatesResult.error.message }; | |
| } | |
| // 2. Check authentication | |
| const session = await getServerSession(); | |
| if (!session?.user) { | |
| return { success: false, error: "Unauthorized" }; | |
| } | |
| // 3. Check authorization - user must own the project or be admin | |
| const db = await getDatabase(); | |
| const [project] = await db | |
| .select() | |
| .from(projects) | |
| .where(eq(projects.id, projectIdResult.data)); | |
| if (!project) { | |
| return { success: false, error: "Project not found" }; | |
| } | |
| // Check ownership or admin role | |
| const isOwner = project.data.ownerId === session.user.id; | |
| const isAdmin = session.user.role === "admin"; | |
| if (!isOwner && !isAdmin) { | |
| return { success: false, error: "Forbidden - you do not have permission" }; | |
| } | |
| // 4. Now safe to perform action | |
| const [updated] = await db | |
| .update(projects) | |
| .set({ | |
| data: { ...project.data, ...updatesResult.data }, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(projects.id, projectIdResult.data)) | |
| .returning(); | |
| return { success: true, data: updated }; | |
| } | |
| ``` | |
| ### 3. Input Sanitization (ALWAYS) | |
| **Validate ALL inputs, even from trusted sources:** | |
| ```typescript | |
| "use server"; | |
| export async function searchProjects( | |
| query: unknown, | |
| filters: unknown, | |
| ): Promise<ActionResult<Project[]>> { | |
| // Validate search query | |
| const querySchema = z.string().min(1).max(255); | |
| const queryResult = querySchema.safeParse(query); | |
| if (!queryResult.success) { | |
| return { success: false, error: "Invalid search query" }; | |
| } | |
| // Validate filters | |
| const filtersSchema = z.object({ | |
| status: z.enum(["active", "completed", "cancelled"]).optional(), | |
| clientId: z.string().uuid().optional(), | |
| dateFrom: z.string().datetime().optional(), | |
| dateTo: z.string().datetime().optional(), | |
| }); | |
| const filtersResult = filtersSchema.safeParse(filters); | |
| if (!filtersResult.success) { | |
| return { success: false, error: "Invalid filters" }; | |
| } | |
| // Now both are fully typed and validated | |
| const safeQuery = queryResult.data; | |
| const safeFilters = filtersResult.data; | |
| // Safe to use in query (Drizzle prevents SQL injection) | |
| const db = await getDatabase(); | |
| let dbQuery = db.select().from(projects); | |
| if (safeFilters.status) { | |
| dbQuery = dbQuery.where(sql`data->>'status' = ${safeFilters.status}`); | |
| } | |
| const results = await dbQuery; | |
| return { success: true, data: results }; | |
| } | |
| ``` | |
| ### 4. Rate Limiting (Optional - Add When Needed) | |
| **For now: simple validation is enough. Add rate limiting later if abuse occurs.** | |
| ```typescript | |
| // ✅ NOW - simple validation prevents most abuse | |
| export async function sendEmail(input: unknown): Promise<ActionResult<void>> { | |
| // Validate + authenticate | |
| const schema = z.object({ to: z.string().email() }); | |
| const result = schema.safeParse(input); | |
| if (!result.success) { | |
| return { success: false, error: "Invalid email" }; | |
| } | |
| const session = await getServerSession(); | |
| if (!session?.user) { | |
| return { success: false, error: "Unauthorized" }; | |
| } | |
| // ... send email | |
| } | |
| // 🔮 FUTURE - add rate limiting if users abuse the feature | |
| // import { ratelimit } from '@/lib/ratelimit'; // Will use Redis when scaling | |
| // const { success } = await ratelimit.limit(`email:${session.user.id}`, 5, '1h'); | |
| ``` | |
| ## 🎯 Server Actions (Preferred) | |
| ### When to Use Server Actions | |
| - ✅ Form submissions | |
| - ✅ Data mutations (create, update, delete) | |
| - ✅ Actions triggered by user interactions | |
| - ✅ When you need to revalidate data | |
| - ✅ **Data fetching from Client Components** (when you can't use Server Component) | |
| **Best Practice**: Prefer Server Components for data fetching. Only fetch from Client Components when: | |
| - You need client-side interactivity (filters, search, pagination) that triggers refetch | |
| - Data depends on client state | |
| - You're polling/refreshing data on interval | |
| ### Server Action Template | |
| ```typescript | |
| // app/projects/actions.ts | |
| "use server"; | |
| import { revalidatePath } from "next/cache"; | |
| import { redirect } from "next/navigation"; | |
| import { db } from "@/lib/getDatabase"; | |
| import { projects } from "@/schema/schema"; | |
| import { getCurrentUser } from "@/lib/auth-helpers-server"; | |
| import { eq } from "drizzle-orm"; | |
| // Define return type for consistency | |
| type ActionResult<T = void> = | |
| | { success: true; data: T } | |
| | { success: false; error: string }; | |
| /** | |
| * Creates a new project | |
| */ | |
| export async function createProject( | |
| formData: FormData, | |
| ): Promise<ActionResult<{ id: string }>> { | |
| try { | |
| // 1. Authentication check | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return { success: false, error: "Not authenticated" }; | |
| } | |
| // 2. Validate input | |
| const title = formData.get("title") as string; | |
| const description = formData.get("description") as string; | |
| if (!title || title.trim().length === 0) { | |
| return { success: false, error: "Title is required" }; | |
| } | |
| // 3. Database operation | |
| const [newProject] = await db | |
| .insert(projects) | |
| .values({ | |
| title: title.trim(), | |
| description: description?.trim() || "", | |
| userId: user.id, | |
| status: "draft", | |
| }) | |
| .returning(); | |
| // 4. Revalidate affected paths | |
| revalidatePath("/projects"); | |
| revalidatePath("/"); | |
| // 5. Return success | |
| return { | |
| success: true, | |
| data: { id: newProject.id }, | |
| }; | |
| } catch (error) { | |
| console.error("Error creating project:", error); | |
| return { | |
| success: false, | |
| error: "Failed to create project", | |
| }; | |
| } | |
| } | |
| /** | |
| * Updates an existing project | |
| */ | |
| export async function updateProject( | |
| projectId: string, | |
| formData: FormData, | |
| ): Promise<ActionResult> { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return { success: false, error: "Not authenticated" }; | |
| } | |
| // Check ownership | |
| const project = await db.query.projects.findFirst({ | |
| where: eq(projects.id, projectId), | |
| }); | |
| if (!project) { | |
| return { success: false, error: "Project not found" }; | |
| } | |
| if (project.userId !== user.id) { | |
| return { success: false, error: "Not authorized" }; | |
| } | |
| // Update | |
| const title = formData.get("title") as string; | |
| const description = formData.get("description") as string; | |
| await db | |
| .update(projects) | |
| .set({ | |
| title: title.trim(), | |
| description: description?.trim() || "", | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(projects.id, projectId)); | |
| revalidatePath("/projects"); | |
| revalidatePath(`/projects/${projectId}`); | |
| return { success: true, data: undefined }; | |
| } catch (error) { | |
| console.error("Error updating project:", error); | |
| return { success: false, error: "Failed to update project" }; | |
| } | |
| } | |
| /** | |
| * Deletes a project (soft delete) | |
| */ | |
| export async function deleteProject(projectId: string): Promise<ActionResult> { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return { success: false, error: "Not authenticated" }; | |
| } | |
| // Check ownership | |
| const project = await db.query.projects.findFirst({ | |
| where: eq(projects.id, projectId), | |
| }); | |
| if (!project) { | |
| return { success: false, error: "Project not found" }; | |
| } | |
| if (project.userId !== user.id) { | |
| return { success: false, error: "Not authorized" }; | |
| } | |
| // Soft delete | |
| await db | |
| .update(projects) | |
| .set({ deletedAt: new Date() }) | |
| .where(eq(projects.id, projectId)); | |
| revalidatePath("/projects"); | |
| return { success: true, data: undefined }; | |
| } catch (error) { | |
| console.error("Error deleting project:", error); | |
| return { success: false, error: "Failed to delete project" }; | |
| } | |
| } | |
| ``` | |
| ### Using Server Actions in Components | |
| ```typescript | |
| 'use client' | |
| import { useTransition } from 'react' | |
| import { useRouter } from 'next/navigation' | |
| import { createProject } from './actions' | |
| import { Button } from '@/components/ui/button' | |
| import { Input } from '@/components/ui/input' | |
| import { useToast } from '@/hooks/use-toast' | |
| export function ProjectForm() { | |
| const router = useRouter() | |
| const { toast } = useToast() | |
| const [isPending, startTransition] = useTransition() | |
| const handleSubmit = async (formData: FormData) => { | |
| startTransition(async () => { | |
| const result = await createProject(formData) | |
| if (result.success) { | |
| toast({ | |
| title: 'Success', | |
| description: 'Project created successfully', | |
| }) | |
| router.push(`/projects/${result.data.id}`) | |
| } else { | |
| toast({ | |
| title: 'Error', | |
| description: result.error, | |
| variant: 'destructive', | |
| }) | |
| } | |
| }) | |
| } | |
| return ( | |
| <form action={handleSubmit} className="space-y-4"> | |
| <Input name="title" placeholder="Project title" required /> | |
| <Input name="description" placeholder="Description" /> | |
| <Button type="submit" disabled={isPending}> | |
| {isPending ? 'Creating...' : 'Create Project'} | |
| </Button> | |
| </form> | |
| ) | |
| } | |
| ``` | |
| ## 🌐 REST API Routes (When Needed) | |
| ### When to Use API Routes | |
| - ✅ Webhooks from third-party services (external systems calling your app) | |
| - ✅ Public APIs that need to be consumed by non-Next.js clients | |
| **Note**: Even these cases are RARE in this project. Default to Server Actions. | |
| ### API Route Template | |
| ```typescript | |
| // app/api/work-orders/route.ts | |
| import { NextRequest, NextResponse } from "next/server"; | |
| import { db } from "@/lib/getDatabase"; | |
| import { workOrders } from "@/schema/schema"; | |
| import { getCurrentUser } from "@/lib/auth-helpers-server"; | |
| import { eq, and, desc } from "drizzle-orm"; | |
| /** | |
| * GET /api/work-orders | |
| * Fetch work orders with optional filters | |
| */ | |
| export async function GET(request: NextRequest) { | |
| try { | |
| // 1. Authentication | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); | |
| } | |
| // 2. Parse query parameters | |
| const { searchParams } = new URL(request.url); | |
| const status = searchParams.get("status"); | |
| const assignedTo = searchParams.get("assignedTo"); | |
| // 3. Build query | |
| const conditions = []; | |
| if (status) { | |
| conditions.push(eq(workOrders.status, status)); | |
| } | |
| if (assignedTo) { | |
| conditions.push(eq(workOrders.assignedTo, assignedTo)); | |
| } | |
| // 4. Fetch data (RLS will filter based on user) | |
| const results = await db.query.workOrders.findMany({ | |
| where: conditions.length > 0 ? and(...conditions) : undefined, | |
| orderBy: [desc(workOrders.createdAt)], | |
| with: { | |
| user: { | |
| columns: { | |
| id: true, | |
| name: true, | |
| email: true, | |
| }, | |
| }, | |
| }, | |
| }); | |
| // 5. Return response | |
| return NextResponse.json({ | |
| success: true, | |
| data: results, | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching work orders:", error); | |
| return NextResponse.json( | |
| { error: "Internal server error" }, | |
| { status: 500 }, | |
| ); | |
| } | |
| } | |
| /** | |
| * POST /api/work-orders | |
| * Create a new work order | |
| */ | |
| export async function POST(request: NextRequest) { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); | |
| } | |
| // Parse body | |
| const body = await request.json(); | |
| const { title, description, dueDate, assignedTo } = body; | |
| // Validate | |
| if (!title || title.trim().length === 0) { | |
| return NextResponse.json({ error: "Title is required" }, { status: 400 }); | |
| } | |
| // Create work order | |
| const [newWorkOrder] = await db | |
| .insert(workOrders) | |
| .values({ | |
| title: title.trim(), | |
| description: description?.trim() || "", | |
| dueDate: dueDate ? new Date(dueDate) : null, | |
| assignedTo: assignedTo || null, | |
| userId: user.id, | |
| status: "draft", | |
| }) | |
| .returning(); | |
| return NextResponse.json( | |
| { | |
| success: true, | |
| data: newWorkOrder, | |
| }, | |
| { status: 201 }, | |
| ); | |
| } catch (error) { | |
| console.error("Error creating work order:", error); | |
| return NextResponse.json( | |
| { error: "Internal server error" }, | |
| { status: 500 }, | |
| ); | |
| } | |
| } | |
| ``` | |
| ### Dynamic Route Template | |
| ```typescript | |
| // app/api/work-orders/[id]/route.ts | |
| import { NextRequest, NextResponse } from "next/server"; | |
| import { db } from "@/lib/getDatabase"; | |
| import { workOrders } from "@/schema/schema"; | |
| import { getCurrentUser } from "@/lib/auth-helpers-server"; | |
| import { eq } from "drizzle-orm"; | |
| type Params = { | |
| params: { | |
| id: string; | |
| }; | |
| }; | |
| /** | |
| * GET /api/work-orders/[id] | |
| */ | |
| export async function GET(request: NextRequest, { params }: Params) { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); | |
| } | |
| const workOrder = await db.query.workOrders.findFirst({ | |
| where: eq(workOrders.id, params.id), | |
| with: { | |
| user: true, | |
| }, | |
| }); | |
| if (!workOrder) { | |
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| } | |
| return NextResponse.json({ success: true, data: workOrder }); | |
| } catch (error) { | |
| console.error("Error fetching work order:", error); | |
| return NextResponse.json( | |
| { error: "Internal server error" }, | |
| { status: 500 }, | |
| ); | |
| } | |
| } | |
| /** | |
| * PUT /api/work-orders/[id] | |
| */ | |
| export async function PUT(request: NextRequest, { params }: Params) { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); | |
| } | |
| // Check existence and ownership | |
| const existing = await db.query.workOrders.findFirst({ | |
| where: eq(workOrders.id, params.id), | |
| }); | |
| if (!existing) { | |
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| } | |
| if (existing.userId !== user.id) { | |
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| } | |
| // Update | |
| const body = await request.json(); | |
| const [updated] = await db | |
| .update(workOrders) | |
| .set({ | |
| ...body, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(workOrders.id, params.id)) | |
| .returning(); | |
| return NextResponse.json({ success: true, data: updated }); | |
| } catch (error) { | |
| console.error("Error updating work order:", error); | |
| return NextResponse.json( | |
| { error: "Internal server error" }, | |
| { status: 500 }, | |
| ); | |
| } | |
| } | |
| /** | |
| * DELETE /api/work-orders/[id] | |
| */ | |
| export async function DELETE(request: NextRequest, { params }: Params) { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); | |
| } | |
| const existing = await db.query.workOrders.findFirst({ | |
| where: eq(workOrders.id, params.id), | |
| }); | |
| if (!existing) { | |
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| } | |
| if (existing.userId !== user.id) { | |
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| } | |
| // Soft delete | |
| await db | |
| .update(workOrders) | |
| .set({ deletedAt: new Date() }) | |
| .where(eq(workOrders.id, params.id)); | |
| return NextResponse.json({ success: true }); | |
| } catch (error) { | |
| console.error("Error deleting work order:", error); | |
| return NextResponse.json( | |
| { error: "Internal server error" }, | |
| { status: 500 }, | |
| ); | |
| } | |
| } | |
| ``` | |
| ## 📋 API Response Standards | |
| ### Consistent Response Format | |
| ```typescript | |
| // Success Response | |
| { | |
| "success": true, | |
| "data": { /* result data */ } | |
| } | |
| // Error Response | |
| { | |
| "success": false, | |
| "error": "Human-readable error message" | |
| } | |
| // List Response (with pagination) | |
| { | |
| "success": true, | |
| "data": [ /* items */ ], | |
| "pagination": { | |
| "total": 100, | |
| "page": 1, | |
| "limit": 10, | |
| "pages": 10 | |
| } | |
| } | |
| ``` | |
| ### HTTP Status Codes | |
| | Code | Meaning | When to Use | | |
| | ---- | --------------------- | --------------------------------------------------- | | |
| | 200 | OK | Successful GET, PUT, DELETE | | |
| | 201 | Created | Successful POST (resource created) | | |
| | 204 | No Content | Successful DELETE with no response body | | |
| | 400 | Bad Request | Invalid input, validation error | | |
| | 401 | Unauthorized | Not authenticated | | |
| | 403 | Forbidden | Authenticated but not authorized | | |
| | 404 | Not Found | Resource doesn't exist | | |
| | 409 | Conflict | Resource conflict (duplicate, constraint violation) | | |
| | 500 | Internal Server Error | Unexpected server error | | |
| ## 🔐 Authentication & Authorization | |
| ### Authentication Check | |
| ```typescript | |
| import { getCurrentUser } from "@/lib/auth-helpers-server"; | |
| export async function GET(request: NextRequest) { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); | |
| } | |
| // Continue with authenticated request | |
| } | |
| ``` | |
| ### Authorization Check | |
| ```typescript | |
| // Check ownership | |
| const resource = await db.query.projects.findFirst({ | |
| where: eq(projects.id, projectId), | |
| }); | |
| if (!resource) { | |
| return NextResponse.json({ error: "Not found" }, { status: 404 }); | |
| } | |
| if (resource.userId !== user.id) { | |
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| } | |
| // Check role | |
| const hasPermission = user.role === "admin" || user.role === "manager"; | |
| if (!hasPermission) { | |
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | |
| } | |
| ``` | |
| ## ✅ Input Validation | |
| ### Using Zod for Validation | |
| ```typescript | |
| import { z } from "zod"; | |
| const createWorkOrderSchema = z.object({ | |
| title: z.string().min(1, "Title is required").max(200), | |
| description: z.string().max(2000).optional(), | |
| status: z.enum(["draft", "pending", "approved", "in_progress", "completed"]), | |
| dueDate: z.string().datetime().optional(), | |
| assignedTo: z.string().uuid().optional(), | |
| }); | |
| export async function POST(request: NextRequest) { | |
| try { | |
| const body = await request.json(); | |
| // Validate | |
| const validationResult = createWorkOrderSchema.safeParse(body); | |
| if (!validationResult.success) { | |
| return NextResponse.json( | |
| { | |
| error: "Validation failed", | |
| details: validationResult.error.issues, | |
| }, | |
| { status: 400 }, | |
| ); | |
| } | |
| const data = validationResult.data; | |
| // Proceed with validated data | |
| // ... | |
| } catch (error) { | |
| // Handle error | |
| } | |
| } | |
| ``` | |
| ## 🔄 Data Revalidation | |
| ### When to Revalidate | |
| ```typescript | |
| import { revalidatePath, revalidateTag } from "next/cache"; | |
| // After creating/updating/deleting | |
| export async function createProject(formData: FormData) { | |
| // ... create project | |
| // Revalidate specific path | |
| revalidatePath("/projects"); | |
| revalidatePath("/"); | |
| // Or revalidate by tag (if using fetch with tags) | |
| revalidateTag("projects"); | |
| } | |
| ``` | |
| --- | |
| ## 💡 Custom API Guidelines | |
| ### ⚠️ CRITICAL: NO REST API Routes | |
| <!-- Zaktualizowano: 2026-02-13 --> | |
| **MANDATORY RULE**: | |
| - ❌ **DO NOT create REST API routes** (`app/api/*/route.ts`) | |
| - ✅ **USE Server Actions ONLY** for all data operations | |
| **ONLY Exception**: Webhooks from external services (e.g., payment providers) or integrations with external service providers | |
| **Why?** | |
| - Server Actions are more efficient | |
| - Better type safety | |
| - Automatic revalidation | |
| - No API route overhead | |
| ### Server Actions with Typed Schema | |
| <!-- Zaktualizowano: 2026-02-13 --> | |
| **Working with Properly Typed Database Schema:** | |
| ```typescript | |
| "use server"; | |
| import { revalidatePath } from "next/cache"; | |
| import { db } from "@/lib/getDatabase"; | |
| import { workOrders } from "@/schema/schema"; | |
| import { getCurrentUser } from "@/lib/auth-helpers-server"; | |
| import { z } from "zod"; | |
| import { eq } from "drizzle-orm"; | |
| // Define input schemas with Zod | |
| const createWorkOrderSchema = z.object({ | |
| title: z.string().min(1, "Title is required").max(200), | |
| description: z.string().max(2000).optional(), | |
| status: z | |
| .enum(["draft", "pending", "approved", "completed"]) | |
| .default("draft"), | |
| assignedTo: z.string().uuid().nullable().optional(), | |
| dueDate: z.coerce.date().nullable().optional(), | |
| budget: z.number().positive().nullable().optional(), | |
| priority: z.number().int().min(0).max(5).default(0), | |
| }); | |
| const updateWorkOrderSchema = createWorkOrderSchema.partial(); | |
| type ActionResult<T = void> = | |
| | { success: true; data: T } | |
| | { success: false; error: string }; | |
| /** | |
| * Create work order - stores data in typed columns | |
| */ | |
| export async function createWorkOrder( | |
| formData: FormData, | |
| ): Promise<ActionResult<{ id: string }>> { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return { success: false, error: "Not authenticated" }; | |
| } | |
| // Parse and validate data | |
| const rawData = { | |
| title: formData.get("title"), | |
| description: formData.get("description") || undefined, | |
| status: formData.get("status") || "draft", | |
| assignedTo: formData.get("assignedTo") || null, | |
| dueDate: formData.get("dueDate") || null, | |
| budget: formData.get("budget") ? Number(formData.get("budget")) : null, | |
| priority: formData.get("priority") ? Number(formData.get("priority")) : 0, | |
| }; | |
| const validationResult = createWorkOrderSchema.safeParse(rawData); | |
| if (!validationResult.success) { | |
| return { | |
| success: false, | |
| error: validationResult.error.issues[0].message, | |
| }; | |
| } | |
| // Insert with typed columns | |
| const [newWorkOrder] = await db | |
| .insert(workOrders) | |
| .values({ | |
| ...validationResult.data, | |
| createdBy: user.id, | |
| }) | |
| .returning(); | |
| revalidatePath("/work-orders"); | |
| return { | |
| success: true, | |
| data: { id: newWorkOrder.id }, | |
| }; | |
| } catch (error) { | |
| console.error("Error creating work order:", error); | |
| return { success: false, error: "Failed to create work order" }; | |
| } | |
| } | |
| /** | |
| * Update work order - update specific columns | |
| */ | |
| export async function updateWorkOrder( | |
| id: string, | |
| formData: FormData, | |
| ): Promise<ActionResult> { | |
| try { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| return { success: false, error: "Not authenticated" }; | |
| } | |
| // Get existing work order | |
| const [existing] = await db | |
| .select() | |
| .from(workOrders) | |
| .where(eq(workOrders.id, id)); | |
| if (!existing) { | |
| return { success: false, error: "Not found" }; | |
| } | |
| // Parse updates | |
| const updates: Record<string, unknown> = {}; | |
| const title = formData.get("title"); | |
| const status = formData.get("status"); | |
| const description = formData.get("description"); | |
| if (title) updates.title = title; | |
| if (status) updates.status = status; | |
| if (description !== null) updates.description = description; | |
| // Validate updates | |
| const validationResult = updateWorkOrderSchema.safeParse(updates); | |
| if (!validationResult.success) { | |
| return { success: false, error: "Invalid data" }; | |
| } | |
| // Update typed columns | |
| await db | |
| .update(workOrders) | |
| .set({ | |
| ...validationResult.data, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(workOrders.id, id)); | |
| revalidatePath("/work-orders"); | |
| revalidatePath(`/work-orders/${id}`); | |
| return { success: true, data: undefined }; | |
| } catch (error) { | |
| console.error("Error updating work order:", error); | |
| return { success: false, error: "Failed to update" }; | |
| } | |
| } | |
| /** | |
| * Fetch work orders - query typed columns | |
| */ | |
| export async function getWorkOrders() { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| throw new Error("Not authenticated"); | |
| } | |
| return await db | |
| .select() | |
| .from(workOrders) | |
| .where(eq(workOrders.deletedAt, null)) | |
| .orderBy(workOrders.createdAt); | |
| } | |
| /** | |
| * Filter by status - simple column query | |
| */ | |
| export async function getWorkOrdersByStatus(status: string) { | |
| const user = await getCurrentUser(); | |
| if (!user) { | |
| throw new Error("Not authenticated"); | |
| } | |
| return await db | |
| .select() | |
| .from(workOrders) | |
| .where(eq(workOrders.status, status)) | |
| .orderBy(workOrders.createdAt); | |
| } | |
| ``` | |
| ### Rate Limiting | |
| <!-- Dodaj tutaj zasady rate limiting --> | |
| ### Webhooks | |
| <!-- Dodaj tutaj zasady obsługi webhooks --> | |
| ### Third-Party Integrations | |
| <!-- Dodaj tutaj zasady integracji z zewnętrznymi API --> | |
| ### Error Logging | |
| <!-- Dodaj tutaj zasady logowania błędów --> | |
| --- | |
| **Last Updated**: 2026-02-13 | |
| **Version**: 1.1.0 | |