From ffc0fe7b4de43d3126aa95d5d8e80f9cbe9eee59 Mon Sep 17 00:00:00 2001 From: mahikasharma <64667561+mahikasharma@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:23:01 -0400 Subject: [PATCH 1/2] server actions --- src/actions/projects.ts | 179 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/actions/projects.ts diff --git a/src/actions/projects.ts b/src/actions/projects.ts new file mode 100644 index 0000000..4f86386 --- /dev/null +++ b/src/actions/projects.ts @@ -0,0 +1,179 @@ +"use server"; + +import "server-only"; + +import { prisma } from "@/lib/prisma"; + +type ProjectRow = { + id: string; + name: string; + description: string | null; + apiKey: string; + createdAt: Date; + updatedAt: Date; +}; + +function generateApiKey(): string { + return crypto.randomUUID(); +} + +function maskApiKey(apiKey: string): string { + if (apiKey.length <= 8) { + return "•".repeat(apiKey.length); + } + return `•••••••••••••••••••••••••••••••••${apiKey.slice(-4)}`; +} + +function toMasked(project: ProjectRow): ProjectMasked { + return { + id: project.id, + name: project.name, + description: project.description, + apiKeyMasked: maskApiKey(project.apiKey), + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; +} + +export type ProjectMasked = { + id: string; + name: string; + description: string | null; + apiKeyMasked: string; + createdAt: Date; + updatedAt: Date; +}; + +export type ProjectWithApiKey = { + id: string; + name: string; + description: string | null; + apiKey: string; + createdAt: Date; + updatedAt: Date; +}; + +export type UpdateProjectData = { + name?: string; + description?: string | null; +}; + +/** Registers an external app; returns the full API key once. */ +export async function createProject( + name: string, + description: string | null, +): Promise { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Project name is required"); + } + + const apiKey = generateApiKey(); + const project = await prisma.project.create({ + data: { + name: trimmed, + description: description?.trim() || null, + apiKey, + }, + }); + + return { + id: project.id, + name: project.name, + description: project.description, + apiKey: project.apiKey, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; +} + +/** Single project; API key is masked. */ +export async function getProject(id: string): Promise { + const project = await prisma.project.findUnique({ where: { id } }); + if (!project) return null; + return toMasked(project); +} + +/** All projects; API keys masked. */ +export async function getProjects(): Promise { + const projects = await prisma.project.findMany({ + orderBy: { name: "asc" }, + }); + return projects.map(toMasked); +} + +/** Updates name and/or description only. */ +export async function updateProject( + id: string, + data: UpdateProjectData, +): Promise { + const hasName = data.name !== undefined; + const hasDescription = data.description !== undefined; + if (!hasName && !hasDescription) { + throw new Error("No fields to update"); + } + + const updatePayload: { name?: string; description?: string | null } = {}; + if (hasName) { + const trimmed = data.name!.trim(); + if (!trimmed) { + throw new Error("Project name cannot be empty"); + } + updatePayload.name = trimmed; + } + if (hasDescription) { + updatePayload.description = + data.description === null || data.description === "" + ? null + : data.description!.trim() || null; + } + + try { + const project = await prisma.project.update({ + where: { id }, + data: updatePayload, + }); + return toMasked(project); + } catch { + return null; + } +} + +/** + * Removes related sessions and user–project links. + */ +export async function deleteProject(id: string): Promise { + try { + await prisma.$transaction(async (tx) => { + await tx.session.deleteMany({ where: { projectId: id } }); + await tx.userProject.deleteMany({ where: { projectId: id } }); + await tx.project.delete({ where: { id } }); + }); + return true; + } catch { + return false; + } +} + +/** Regenerates the API key; returns the full new key once. */ +export async function resetProjectAPIKey( + id: string, +): Promise { + const apiKey = generateApiKey(); + try { + const project = await prisma.project.update({ + where: { id }, + data: { apiKey }, + }); + return { + id: project.id, + name: project.name, + description: project.description, + apiKey: project.apiKey, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; + } catch { + return null; + } +} From 8d3ab061af6a74163901f75fe3fea3ed52c33d48 Mon Sep 17 00:00:00 2001 From: Logan Ravinuthala Date: Tue, 25 Aug 2026 00:01:34 -0400 Subject: [PATCH 2/2] Implement Zod validation for project actions and refactor error handling --- src/actions/projects.ts | 117 +++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 32 deletions(-) diff --git a/src/actions/projects.ts b/src/actions/projects.ts index 4f86386..bb42599 100644 --- a/src/actions/projects.ts +++ b/src/actions/projects.ts @@ -2,8 +2,64 @@ import "server-only"; +import { z } from "zod"; + +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/prisma"; +const NAME_MAX_LENGTH = 100; +const DESCRIPTION_MAX_LENGTH = 500; + +const idSchema = z.uuid("Invalid project id"); + +const nameSchema = z + .string() + .trim() + .min(1, "Project name is required") + .max( + NAME_MAX_LENGTH, + `Project name must be ${NAME_MAX_LENGTH} characters or fewer`, + ); + +/** Trims, then collapses empty strings to null so blank input clears the field. */ +const descriptionSchema = z + .string() + .trim() + .max( + DESCRIPTION_MAX_LENGTH, + `Description must be ${DESCRIPTION_MAX_LENGTH} characters or fewer`, + ) + .nullable() + .transform((value) => value || null); + +const updateProjectSchema = z + .object({ + name: nameSchema.optional(), + description: descriptionSchema.optional(), + }) + .refine((data) => data.name !== undefined || data.description !== undefined, { + message: "No fields to update", + }); + +function parseOrThrow( + schema: S, + value: unknown, +): z.output { + const result = schema.safeParse(value); + if (!result.success) { + throw new Error(result.error.issues[0].message); + } + return result.data; +} + +/** True only for Prisma's "record does not exist" error, so real failures stay loud. */ +function isRecordNotFound(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ); +} + type ProjectRow = { id: string; name: string; @@ -63,16 +119,17 @@ export async function createProject( name: string, description: string | null, ): Promise { - const trimmed = name.trim(); - if (!trimmed) { - throw new Error("Project name is required"); - } + const parsedName = parseOrThrow(nameSchema, name); + const parsedDescription = parseOrThrow( + descriptionSchema, + description ?? null, + ); const apiKey = generateApiKey(); const project = await prisma.project.create({ data: { - name: trimmed, - description: description?.trim() || null, + name: parsedName, + description: parsedDescription, apiKey, }, }); @@ -89,7 +146,8 @@ export async function createProject( /** Single project; API key is masked. */ export async function getProject(id: string): Promise { - const project = await prisma.project.findUnique({ where: { id } }); + const parsedId = parseOrThrow(idSchema, id); + const project = await prisma.project.findUnique({ where: { id: parsedId } }); if (!project) return null; return toMasked(project); } @@ -107,35 +165,26 @@ export async function updateProject( id: string, data: UpdateProjectData, ): Promise { - const hasName = data.name !== undefined; - const hasDescription = data.description !== undefined; - if (!hasName && !hasDescription) { - throw new Error("No fields to update"); - } + const parsedId = parseOrThrow(idSchema, id); + const parsed = parseOrThrow(updateProjectSchema, data); const updatePayload: { name?: string; description?: string | null } = {}; - if (hasName) { - const trimmed = data.name!.trim(); - if (!trimmed) { - throw new Error("Project name cannot be empty"); - } - updatePayload.name = trimmed; + if (parsed.name !== undefined) { + updatePayload.name = parsed.name; } - if (hasDescription) { - updatePayload.description = - data.description === null || data.description === "" - ? null - : data.description!.trim() || null; + if (parsed.description !== undefined) { + updatePayload.description = parsed.description; } try { const project = await prisma.project.update({ - where: { id }, + where: { id: parsedId }, data: updatePayload, }); return toMasked(project); - } catch { - return null; + } catch (error) { + if (isRecordNotFound(error)) return null; + throw error; } } @@ -143,15 +192,18 @@ export async function updateProject( * Removes related sessions and user–project links. */ export async function deleteProject(id: string): Promise { + const parsedId = parseOrThrow(idSchema, id); + try { await prisma.$transaction(async (tx) => { - await tx.session.deleteMany({ where: { projectId: id } }); - await tx.userProject.deleteMany({ where: { projectId: id } }); - await tx.project.delete({ where: { id } }); + await tx.session.deleteMany({ where: { projectId: parsedId } }); + await tx.userProject.deleteMany({ where: { projectId: parsedId } }); + await tx.project.delete({ where: { id: parsedId } }); }); return true; - } catch { - return false; + } catch (error) { + if (isRecordNotFound(error)) return false; + throw error; } } @@ -159,10 +211,11 @@ export async function deleteProject(id: string): Promise { export async function resetProjectAPIKey( id: string, ): Promise { + const parsedId = parseOrThrow(idSchema, id); const apiKey = generateApiKey(); try { const project = await prisma.project.update({ - where: { id }, + where: { id: parsedId }, data: { apiKey }, }); return {