From 1e6dec9cf2595556ced4933133df9ef452587909 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Wed, 19 Aug 2026 22:06:58 -0400 Subject: [PATCH] feat: add Area validation + CRUD (src/lib/areas.ts) Co-Authored-By: Claude Sonnet 5 --- src/lib/areas.ts | 92 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/lib/areas.ts diff --git a/src/lib/areas.ts b/src/lib/areas.ts new file mode 100644 index 0000000..dd29c84 --- /dev/null +++ b/src/lib/areas.ts @@ -0,0 +1,92 @@ +// Area — physical location tag for the office-move inventory walk (see +// prisma/schema.prisma's Area model comment). Same shape/conventions as +// src/lib/employees.ts: validate -> create (P2002 on duplicate name) -> +// list. +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export interface AreaRecord { + id: string; + name: string; + team: string | null; + created_at: string; +} + +export interface CreateAreaInput { + name: string; + team?: string; +} + +export type ValidationResult = + | { ok: true; value: CreateAreaInput } + | { ok: false; error: string }; + +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createAreaSchema = z.object({ + name: nonEmptyString("name is required and must be a non-empty string"), + team: z.string({ invalid_type_error: "team must be a string" }).optional(), +}); + +export function validateCreateAreaInput(input: { + name?: unknown; + team?: unknown; +}): ValidationResult { + const result = createAreaSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Invalid input"; + return { ok: false, error: message }; + } + return { ok: true, value: result.data }; +} + +function toRecord(row: { + id: string; + name: string; + team: string | null; + createdAt: Date; +}): AreaRecord { + return { + id: row.id, + name: row.name, + team: row.team, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreateAreaResult = { ok: true; value: AreaRecord } | { ok: false; error: string }; + +/** + * Creates an Area, or returns the existing one if `name` already exists + * (idempotent-by-name) — this is deliberate, not just P2002 error handling: + * the bot's "reuse existing or type a new one" flow calls this every time a + * user picks/types an Area name, and re-submitting an Area the bot already + * showed as an existing option must succeed, not 409. + */ +export async function createOrGetArea(input: CreateAreaInput): Promise { + try { + const created = await prisma.area.create({ + data: { name: input.name, team: input.team ?? null }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + const existing = await prisma.area.findUnique({ where: { name: input.name } }); + if (existing) { + return { ok: true, value: toRecord(existing) }; + } + } + throw error; + } +} + +export async function listAreas(): Promise { + const rows = await prisma.area.findMany({ orderBy: [{ name: "asc" }] }); + return rows.map(toRecord); +}