SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
5.6 KB · 113 lines typescript
Raw Blame History
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