Initial commit: Next.js scaffold, Request entity, Prisma+Zod, Tailwind v4+shadcn
- Next.js 14 (TS, App Router) monolith - Request entity (Draft state) via Prisma + Postgres, validated with Zod - ESLint configured - Tailwind v4 + shadcn/ui (new-york-v4 style) - Vitest test suite (6 tests, runs against throwaway Postgres DB per file) - Multi-agent dev workflow docs in CLAUDE.md and .claude/agents/
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
// Standard Next.js dev-mode singleton guard: hot-reloading re-evaluates this
|
||||
// module on every edit, which would otherwise spawn a fresh PrismaClient
|
||||
// (and a fresh connection pool) on every reload. Stashing the instance on
|
||||
// `globalThis` lets it survive across reloads in dev; production and test
|
||||
// runs each just get one instance per process, which is what we want there
|
||||
// too.
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type RequestType = "asset" | "license";
|
||||
|
||||
// Only the 'Draft' state is implemented in this task. Later stages
|
||||
// (Pending Approval, Approved, Ordered, Received, Delivered, Closed,
|
||||
// Rejected/Cancelled — see processes/asset-license-management.md) are
|
||||
// deliberately out of scope.
|
||||
export type RequestStatus = "Draft";
|
||||
|
||||
export interface RequestRecord {
|
||||
id: string;
|
||||
employee_id: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: RequestStatus;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateRequestInput {
|
||||
employee_id: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: CreateRequestInput }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// A string field that must be present and not just whitespace. Mirrors the
|
||||
// old hand-written `typeof x !== "string" || x.trim() === ""` check: the
|
||||
// value itself is NOT trimmed/transformed, only validated — the raw input is
|
||||
// still what gets stored.
|
||||
function nonEmptyString(message: string) {
|
||||
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||
(value) => value.trim() !== "",
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
|
||||
const createRequestSchema = z.object({
|
||||
employee_id: nonEmptyString("employee_id is required and must be a non-empty string"),
|
||||
reason: nonEmptyString("reason is required and must be a non-empty string"),
|
||||
type: z.enum(["asset", "license"], {
|
||||
errorMap: () => ({
|
||||
message: "type is required and must be exactly 'asset' or 'license'",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) request-creation input.
|
||||
* employee_id and reason must be non-empty strings; type must be exactly
|
||||
* 'asset' or 'license'.
|
||||
*/
|
||||
export function validateCreateRequestInput(input: {
|
||||
employee_id?: unknown;
|
||||
type?: unknown;
|
||||
reason?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createRequestSchema.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;
|
||||
employeeId: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}): RequestRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
employee_id: row.employeeId,
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
status: row.status as RequestStatus,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRequest(input: CreateRequestInput): Promise<RequestRecord> {
|
||||
const created = await prisma.request.create({
|
||||
data: {
|
||||
employeeId: input.employee_id,
|
||||
type: input.type,
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
return toRecord(created);
|
||||
}
|
||||
|
||||
export async function listRequests(): Promise<RequestRecord[]> {
|
||||
const rows = await prisma.request.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user