Files
sass-mohfarawati/lib/portfolio-validation.ts
MOH 0a5f77d8de REFACTORED - migrate the data layer from Prisma to Drizzle (unify the stack)
- Add lib/db (Drizzle schema: 7 tables + 6 enums + relations, postgres.js client),
  drizzle.config.ts, lib/db/enums.ts (Prisma-compatible enum objects)
- Rewrite all 14 app consumers + 4 admin components to Drizzle
- Move the test DB layer to Drizzle + in-process PGlite; convert all 8 integration
  test files + factories (371 tests green)
- Remove Prisma: deps, prisma/ schema+migrations, lib/prisma.ts, Dockerfile prisma
  generate; wire db:generate/migrate/push/studio to drizzle-kit; update Makefile
- Preserve the old seed as scripts/legacy-prisma-seed.cjs (needs a Drizzle rewrite)
2026-08-07 14:18:41 +02:00

126 lines
4.5 KiB
TypeScript

import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
const requiredText = (label: string) =>
z
.string()
.trim()
.min(1, `${label} is required.`);
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
const portfolioProjectViewModes = ["GRID", "STORY", "CASE_STUDY"] as const;
export const categoryInputSchema = z.object({
id: z.string().trim().optional(),
slug: requiredText("Category slug")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Category slug must be lowercase and hyphenated."),
nameAr: requiredText("Category nameAr"),
nameEn: requiredText("Category nameEn"),
nameDe: requiredText("Category nameDe"),
descriptionAr: requiredText("Category descriptionAr"),
descriptionEn: requiredText("Category descriptionEn"),
descriptionDe: requiredText("Category descriptionDe"),
sortOrder: z.coerce.number().int().min(0).max(9999),
isActive: z.boolean(),
});
export const sectionInputSchema = z
.object({
id: z.string().trim().optional(),
type: z.nativeEnum(PortfolioSectionType),
titleAr: requiredText("Section titleAr"),
titleEn: requiredText("Section titleEn"),
titleDe: requiredText("Section titleDe"),
bodyAr: optionalTrimmedText,
bodyEn: optionalTrimmedText,
bodyDe: optionalTrimmedText,
imagePath: optionalTrimmedText,
media: mediaFieldInputSchema.optional(),
linkUrl: optionalTrimmedText.refine(
(value) => value === "" || /^https?:\/\//.test(value) || value.startsWith("/"),
"Section linkUrl must be an absolute URL or start with /.",
),
sortOrder: z.coerce.number().int().min(0).max(9999),
})
.superRefine((value, context) => {
const hasBody = Boolean(value.bodyAr && value.bodyEn && value.bodyDe);
const hasImage = value.media?.mode === "library"
? Boolean(value.media.assetId)
: value.media?.mode === "upload"
? true
: Boolean(value.imagePath);
if (
(value.type === PortfolioSectionType.RICH_TEXT ||
value.type === PortfolioSectionType.STATS ||
value.type === PortfolioSectionType.DELIVERABLES) &&
!hasBody
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["bodyAr"],
message: "This section type requires body content in all languages.",
});
}
if (value.type === PortfolioSectionType.GALLERY && !hasImage) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["media"],
message: "Single image sections require an image.",
});
}
if (value.type === PortfolioSectionType.LINK && !value.linkUrl) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["linkUrl"],
message: "Link sections require a link URL.",
});
}
});
export const assetInputSchema = z.object({
id: z.string().trim().optional(),
kind: z.literal("IMAGE"),
filePath: optionalTrimmedText,
fileFieldName: optionalTrimmedText,
media: mediaFieldInputSchema.optional(),
altAr: requiredText("Asset altAr"),
altEn: requiredText("Asset altEn"),
altDe: requiredText("Asset altDe"),
sortOrder: z.coerce.number().int().min(0).max(9999),
});
export const projectInputSchema = z.object({
id: z.string().trim().optional(),
categoryId: requiredText("Project categoryId"),
slug: requiredText("Project slug")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
viewMode: z.enum(portfolioProjectViewModes).default("GRID"),
titleAr: requiredText("Project titleAr"),
titleEn: requiredText("Project titleEn"),
titleDe: requiredText("Project titleDe"),
summaryAr: requiredText("Project summaryAr"),
summaryEn: requiredText("Project summaryEn"),
summaryDe: requiredText("Project summaryDe"),
clientName: requiredText("Project clientName"),
projectYear: z.coerce.number().int().min(2000).max(2100),
serviceLabelAr: requiredText("Project serviceLabelAr"),
serviceLabelEn: requiredText("Project serviceLabelEn"),
serviceLabelDe: requiredText("Project serviceLabelDe"),
previewUrl: optionalTrimmedText.refine(
(value) => value === "" || /^https?:\/\//.test(value),
"Project previewUrl must be an absolute URL.",
),
currentCoverImagePath: optionalTrimmedText,
coverMedia: mediaFieldInputSchema.optional(),
sortOrder: z.coerce.number().int().min(0).max(9999),
isFeatured: z.boolean(),
isPublished: z.boolean(),
sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema),
});