142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
// Input parsing/validation for admin write endpoints. Each parser returns the
|
|
// Prisma-ready data object or throws a ValidationError (mapped to HTTP 400).
|
|
|
|
import { ProjectSize } from "@/generated/prisma/client";
|
|
|
|
export class ValidationError extends Error {}
|
|
|
|
function str(value: unknown, field: string): string {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw new ValidationError(`"${field}" is required`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function optStr(value: unknown): string | null {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
if (typeof value !== "string") throw new ValidationError("Expected a string");
|
|
const trimmed = value.trim();
|
|
return trimmed === "" ? null : trimmed;
|
|
}
|
|
|
|
function bool(value: unknown): boolean {
|
|
return value === true;
|
|
}
|
|
|
|
function int(value: unknown): number {
|
|
const n = typeof value === "number" ? value : Number.parseInt(String(value ?? 0), 10);
|
|
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
|
}
|
|
|
|
function optInt(value: unknown): number | null {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const n = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
return Number.isFinite(n) ? Math.trunc(n) : null;
|
|
}
|
|
|
|
function tags(value: unknown, max: number): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const cleaned = value
|
|
.map((t) => (typeof t === "string" ? t.trim() : ""))
|
|
.filter(Boolean);
|
|
if (cleaned.length > max) {
|
|
throw new ValidationError(`Too many tags (max ${max})`);
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
function strList(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map((t) => (typeof t === "string" ? t.trim() : "")).filter(Boolean);
|
|
}
|
|
|
|
function date(value: unknown, field: string): Date {
|
|
const d = new Date(String(value));
|
|
if (Number.isNaN(d.getTime())) throw new ValidationError(`"${field}" is not a valid date`);
|
|
return d;
|
|
}
|
|
|
|
function optDate(value: unknown): Date | null {
|
|
if (value === undefined || value === null || value === "") return null;
|
|
const d = new Date(String(value));
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
export function parseProject(body: Record<string, unknown>) {
|
|
const size = String(body.size);
|
|
const validSize = (Object.values(ProjectSize) as string[]).includes(size)
|
|
? (size as ProjectSize)
|
|
: ProjectSize.MediumSized;
|
|
|
|
return {
|
|
name: str(body.name, "name"),
|
|
label: str(body.label, "label"),
|
|
size: validSize,
|
|
imageUrl: optStr(body.imageUrl),
|
|
link: str(body.link, "link"),
|
|
linkIsDemo: bool(body.linkIsDemo),
|
|
sourceCode: optStr(body.sourceCode),
|
|
description: str(body.description, "description"),
|
|
why: str(body.why, "why"),
|
|
note: optStr(body.note),
|
|
tags: tags(body.tags, 3),
|
|
loc: optInt(body.loc),
|
|
locEndpoint: optStr(body.locEndpoint),
|
|
};
|
|
}
|
|
|
|
export function parseExperience(body: Record<string, unknown>) {
|
|
return {
|
|
company: str(body.company, "company"),
|
|
role: str(body.role, "role"),
|
|
fromDate: date(body.fromDate, "fromDate"),
|
|
toDate: optDate(body.toDate),
|
|
url: optStr(body.url),
|
|
iconUrl: optStr(body.iconUrl),
|
|
summary: str(body.summary, "summary"),
|
|
tags: tags(body.tags, 6),
|
|
sortIndex: int(body.sortIndex),
|
|
};
|
|
}
|
|
|
|
export function parseAffiliate(body: Record<string, unknown>) {
|
|
return {
|
|
name: str(body.name, "name"),
|
|
link: str(body.link, "link"),
|
|
icon: str(body.icon, "icon"),
|
|
location: str(body.location, "location"),
|
|
provides: strList(body.provides),
|
|
good: strList(body.good),
|
|
bad: strList(body.bad),
|
|
sortIndex: int(body.sortIndex),
|
|
};
|
|
}
|
|
|
|
function slug(value: unknown): string {
|
|
const normalized = str(value, "slug")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
|
|
if (!normalized) {
|
|
throw new ValidationError('"slug" is invalid');
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export function parseBlogPost(body: Record<string, unknown>) {
|
|
const isPublished = bool(body.isPublished);
|
|
const publishedAt = optDate(body.publishedAt);
|
|
|
|
return {
|
|
title: str(body.title, "title"),
|
|
slug: slug(body.slug),
|
|
excerpt: str(body.excerpt, "excerpt"),
|
|
coverImageUrl: optStr(body.coverImageUrl),
|
|
content: str(body.content, "content"),
|
|
isPublished,
|
|
publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt,
|
|
};
|
|
}
|