gts-x / src /instructions /api-design-instructions.md
JAMES HAN
Initial commit — GTS Modern subtitle converter
dff41a8
|
Raw
History Blame Contribute Delete
33.5 kB

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:

"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:

// ❌ 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:

"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:

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:

// ❌ 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:

// ❌ 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:

// ❌ 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:

// ❌ 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:

// ❌ 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:

"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:

"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:

"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.

// ✅ 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

// 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

'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

// 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

// 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

// 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

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

// 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

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

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

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

Working with Properly Typed Database Schema:

"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

Webhooks

Third-Party Integrations

Error Logging


Last Updated: 2026-02-13 Version: 1.1.0