TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use server";23import { cookies } from "next/headers";4import { revalidatePath } from "next/cache";5import { redirect } from "next/navigation";6import { z } from "zod";7import { getDb, projects, eq, and } from "@fetcha/db";8import { newId } from "@fetcha/core";9import { getWorkspace, PROJECT_COOKIE, requestMeta } from "@/lib/session";10import { writeAudit } from "@/lib/audit";11import { slugify } from "@/lib/utils";1213export type ActionResult<T = undefined> = { ok: true; data?: T } | { ok: false; error: string; field?: string };1415const projectSchema = z.object({16 name: z.string().trim().min(2, "Name must be at least 2 characters").max(64),17 description: z.string().trim().max(280).optional().or(z.literal("")),18 environment: z.enum(["production", "development"]).default("production"),19 defaultCountry: z.string().trim().length(2).toUpperCase().optional().or(z.literal("")),20 defaultNetwork: z.enum(["auto", "datacenter", "residential", "isp", "mobile"]).default("auto"),21 logLevel: z.enum(["none", "metadata", "headers", "full"]).default("metadata"),22 softLimitUsd: z.coerce.number().min(0).optional().or(z.literal("")),23 hardLimitUsd: z.coerce.number().min(0).optional().or(z.literal("")),24 monthlyRequestLimit: z.coerce.number().int().min(0).optional().or(z.literal("")),25});2627function num(v: number | "" | undefined): number | null {28 return v === "" || v === undefined || Number.isNaN(v) ? null : v;29}3031export async function createProject(formData: FormData): Promise<ActionResult<{ id: string }>> {32 const ws = await getWorkspace();33 const parsed = projectSchema.safeParse(Object.fromEntries(formData));34 if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input", field: String(parsed.error.issues[0]?.path[0] ?? "") };35 const d = parsed.data;36 const db = getDb();37 const id = newId("proj");38 let slug = slugify(d.name);39 const existing = await db.select({ id: projects.id }).from(projects).where(and(eq(projects.organizationId, ws.organization.id), eq(projects.slug, slug)));40 if (existing.length) slug = `${slug}-${id.slice(-4)}`;41 await db.insert(projects).values({42 id,43 organizationId: ws.organization.id,44 name: d.name,45 slug,46 description: d.description || null,47 environment: d.environment,48 defaultCountry: d.defaultCountry || null,49 defaultNetwork: d.defaultNetwork,50 logLevel: d.logLevel,51 softLimitUsd: num(d.softLimitUsd),52 hardLimitUsd: num(d.hardLimitUsd),53 monthlyRequestLimit: num(d.monthlyRequestLimit),54 });55 const meta = await requestMeta();56 await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "project.created", target: id, metadata: { name: d.name }, ipAddress: meta.ip, userAgent: meta.userAgent });57 (await cookies()).set(PROJECT_COOKIE, id, { path: "/", httpOnly: true, sameSite: "lax", maxAge: 60 * 60 * 24 * 365 });58 revalidatePath("/dashboard", "layout");59 return { ok: true, data: { id } };60}6162export async function updateProject(projectId: string, formData: FormData): Promise<ActionResult> {63 const ws = await getWorkspace();64 const parsed = projectSchema.safeParse(Object.fromEntries(formData));65 if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input", field: String(parsed.error.issues[0]?.path[0] ?? "") };66 const d = parsed.data;67 const db = getDb();68 const [p] = await db.select().from(projects).where(and(eq(projects.id, projectId), eq(projects.organizationId, ws.organization.id))).limit(1);69 if (!p) return { ok: false, error: "Project not found" };70 await db71 .update(projects)72 .set({73 name: d.name,74 description: d.description || null,75 environment: d.environment,76 defaultCountry: d.defaultCountry || null,77 defaultNetwork: d.defaultNetwork,78 logLevel: d.logLevel,79 softLimitUsd: num(d.softLimitUsd),80 hardLimitUsd: num(d.hardLimitUsd),81 monthlyRequestLimit: num(d.monthlyRequestLimit),82 updatedAt: new Date(),83 })84 .where(eq(projects.id, projectId));85 const meta = await requestMeta();86 await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "project.updated", target: projectId, ipAddress: meta.ip, userAgent: meta.userAgent });87 revalidatePath("/dashboard", "layout");88 return { ok: true };89}9091export async function archiveProject(projectId: string): Promise<ActionResult> {92 const ws = await getWorkspace();93 if (ws.projects.length <= 1) return { ok: false, error: "You need at least one active project." };94 const db = getDb();95 const [p] = await db.select().from(projects).where(and(eq(projects.id, projectId), eq(projects.organizationId, ws.organization.id))).limit(1);96 if (!p) return { ok: false, error: "Project not found" };97 await db.update(projects).set({ archivedAt: new Date(), updatedAt: new Date() }).where(eq(projects.id, projectId));98 const meta = await requestMeta();99 await writeAudit({ userId: ws.user.id, organizationId: ws.organization.id, action: "project.archived", target: projectId, ipAddress: meta.ip, userAgent: meta.userAgent });100 const store = await cookies();101 if (store.get(PROJECT_COOKIE)?.value === projectId) store.delete(PROJECT_COOKIE);102 revalidatePath("/dashboard", "layout");103 return { ok: true };104}105106export async function selectProject(projectId: string, redirectTo?: string): Promise<void> {107 const ws = await getWorkspace();108 if (!ws.projects.some((p) => p.id === projectId)) return;109 (await cookies()).set(PROJECT_COOKIE, projectId, { path: "/", httpOnly: true, sameSite: "lax", maxAge: 60 * 60 * 24 * 365 });110 revalidatePath("/dashboard", "layout");111 if (redirectTo) redirect(redirectTo);112}113