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:
2026-07-30 18:14:36 +07:00
commit afdbdbdcfb
30 changed files with 13950 additions and 0 deletions
+104
View File
@@ -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);
}