Spaces:
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:
- Identify UI interaction (button click, form submit, page load)
- Determine what data is needed
- Create API endpoint that provides exactly that data
- 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