Compare commits

..
12 Commits
Author SHA1 Message Date
Moh 3a5c275c52 CLEANED - Remove unused comingSoon translation keys
CI / quality (push) Waiting to run
- Drop badge, kicker, status, primaryCta, secondaryCta, countdownLabel from all locales
- These were orphaned when the coming-soon page was trimmed to title + countdown
2026-08-08 03:52:08 +02:00
Moh b2b90bd1bf STYLED - Trim coming-soon to title + countdown
CI / quality (push) Waiting to run
- Remove the status badge, kicker, and description texts
- Keep only the title and the countdown (all locales)
2026-08-08 03:48:19 +02:00
Moh aadc93e870 STYLED - Simplify closed coming-soon page
CI / quality (push) Waiting to run
- Remove CTA buttons (site is closed, no navigation)
- Drop the card wrapper: content sits on the page background, gradient included
- Remove the launch label; keep the countdown
- Fit the viewport (no scroll)
2026-08-08 03:34:02 +02:00
Moh 0f436ec05a FIXED - Drop leftover Prisma ?schema=public from the DB URL
CI / quality (push) Waiting to run
- postgres.js forwarded schema as a startup param; Postgres rejected the connection
- Remove ?schema=public from docker-compose, CI, and the client default
- Strip any query string in lib/db/index.ts so this cannot recur
2026-08-08 03:04:26 +02:00
Moh 1264b577a1 FIXED - Restore the tested package-lock.json
CI / quality (push) Waiting to run
- Revert an unintended lockfile change from a machine with a newer npm
- Keeps the exact dependency tree the test suite ran against (npm ci verified)
2026-08-08 02:34:37 +02:00
Moh 61a58b31a7 CONFIGURED - Wire docker-compose to Traefik for first deploy
CI / quality (push) Waiting to run
- Join the app to the external proxy network and drop the published host port
- Add Traefik labels for mohfarawati.de, www, and root.mohfarawati.de
- Parametrize network/entrypoint/certresolver via TRAEFIK_* env with sane defaults
- Pass ADMIN_HOST to the app and document production env in .env.example
2026-08-08 02:33:09 +02:00
MOH cdf0380f79 IMPROVED - unify dev pattern: DB-only Docker + host app, safe start, add test:watch
CI / quality (push) Waiting to run
2026-08-07 15:05:34 +02:00
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
MOH e377877e7e ADDED - test gate: pre-push hook, copy-paste summary, make targets, mandatory-tests rule, CI test step 2026-08-06 20:03:37 +02:00
MOH 9c40d9030f FIXED - build type errors (vitest fileParallelism placement, NODE_ENV assignment) 2026-08-06 20:03:27 +02:00
MOH 777dcbab7b update 2026-08-06 02:27:49 +02:00
Moh e2e06be86e test: add comprehensive automated test coverage
- Vitest multi-project setup (unit / integration / component)
- Real-Postgres integration harness via in-process PGlite (TEST_DATABASE_URL
  override), migrations applied per worker; production code untouched
- Unit: routing, locale, validation/Zod schemas, metadata, mail, site-theme,
  marquee, media, portfolio helpers, plus architecture-rule tests
- Integration: Prisma data layer, API routes, and all server actions
- Component: UI primitives and form components (jsdom + Testing Library)
- 371 tests passing
2026-08-06 02:27:20 +02:00
80 changed files with 8015 additions and 1631 deletions
+26 -4
View File
@@ -1,9 +1,31 @@
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/moh_sass?schema=public" # Local dev: app runs on host (npm run dev), only Postgres runs in Docker.
NEXT_PUBLIC_APP_URL="https://mohfarawati.de" # Production: docker-compose.yml runs the full stack (deploy via `make deploy`).
NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de" # --- Database ---
# Local: localhost:5432 (exposed from Docker). Server: db:5432 (Compose internal).
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/moh_sass"
# --- URLs ---
NEXT_PUBLIC_APP_URL="http://localhost:3014"
NEXT_PUBLIC_SITE_URL="http://localhost:3014"
NEXT_PUBLIC_ADMIN_URL="http://rootmohfarawati.localhost:3014"
ADMIN_HOST="rootmohfarawati.localhost"
NEXT_TELEMETRY_DISABLED="1" NEXT_TELEMETRY_DISABLED="1"
# --- Admin auth ---
ADMIN_PASSWORD="change-me" ADMIN_PASSWORD="change-me"
ADMIN_AUTH_SECRET="replace-with-a-long-random-secret" ADMIN_AUTH_SECRET="replace-with-a-long-random-secret"
ADMIN_BASIC_AUTH_USER="change-me" ADMIN_BASIC_AUTH_USER="change-me"
ADMIN_BASIC_AUTH_PASS="change-me" ADMIN_BASIC_AUTH_PASS="change-me"
# --- Production only (server .env) ---
# The full stack runs via docker-compose.yml behind Traefik. On the server set:
# NEXT_PUBLIC_SITE_URL="https://mohfarawati.de"
# NEXT_PUBLIC_ADMIN_URL="https://root.mohfarawati.de"
# ADMIN_HOST="root.mohfarawati.de"
# Traefik wiring (override only if your server differs from the defaults):
# TRAEFIK_NETWORK="proxy" # the external network Traefik is on
# TRAEFIK_ENTRYPOINT="websecure" # HTTPS entrypoint name
# TRAEFIK_CERTRESOLVER="cf" # cert resolver that covers *.mohfarawati.de
# DATABASE_URL is set by docker-compose.yml to the internal db service — do not set it here.
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
#
# Pre-push test gate. The deploy server only ever PULLS, so the real place to
# stop broken code is right here — before anything leaves this machine.
#
# The suite needs no external database: integration tests spin up an in-process
# PGlite database per worker (see tests/helpers/integration-setup.ts), so
# `npm test` runs standalone. A real failure blocks the push; the run ends with a
# compact copy-pasteable summary (scripts/test-summary.mjs).
#
# Install once: git config core.hooksPath .githooks
# Emergency skip: git push --no-verify
#
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
echo "▶ pre-push: running the full test suite…"
node scripts/test-summary.mjs
echo "✓ all tests green — pushing."
+6 -1
View File
@@ -8,7 +8,7 @@ jobs:
quality: quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moh_sass
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
steps: steps:
- name: Checkout - name: Checkout
@@ -29,5 +29,10 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Test
# Integration tests use an in-process PGlite database, so no service
# container is needed — the suite runs standalone.
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
+1
View File
@@ -110,6 +110,7 @@ SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state
## Working rules ## Working rules
- **Tests are mandatory for every logic change, in the SAME change.** New behaviour → new tests covering the intent (positive **and** negative cases), not one happy example. Deliberate change → update the affected tests and say which/why. A test that fails unexpectedly is a real bug → fix the code, not the test. Test the real thing — integration tests use a real (in-process PGlite) database, so do NOT mock our own `lib/`/DB layer; only true external boundaries (the admin session, third-party APIs, SMTP) may be substituted. Keep the suite green (`make test`); the `pre-push` hook (`.githooks/pre-push`) enforces it. Frontend/UI is verified manually; add component tests only for components with real logic.
- Before making any change, first explain the plan briefly and list the files that will be touched. - Before making any change, first explain the plan briefly and list the files that will be touched.
- Make the smallest safe change that solves the task. - Make the smallest safe change that solves the task.
- Do not modify unrelated files. - Do not modify unrelated files.
+91 -56
View File
@@ -1,74 +1,109 @@
.PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-generate db-migrate db-push db-seed help # mohfarawati.de — task runner.
# Local dev = DB in Docker, app on host (`npm run dev`).
# Production = docker-compose.yml runs the full stack on the server.
.DEFAULT_GOAL := help
.PHONY: help install start stop restart db-up db-down \
migrate generate db-generate db-push studio psql \
build deploy deploy-logs deploy-down \
test test-watch \
health help
## --- Help ------------------------------------------------------------------
help:
@echo "Local development (DB in Docker, app on host):"
@echo " make start Start DB + dev server (http://localhost:3014)"
@echo " make stop Stop the database container"
@echo " make restart Restart DB + dev server"
@echo " make install Install host dependencies"
@echo ""
@echo "Database:"
@echo " make db-up Start only the database container"
@echo " make db-down Stop the database container"
@echo " make migrate Apply Drizzle migrations"
@echo " make db-generate Generate a migration from schema changes"
@echo " make db-push Push schema to DB (dev shortcut, no migration)"
@echo " make studio Open Drizzle Studio to browse the database"
@echo " make psql Open a psql shell on the database"
@echo ""
@echo "Quality:"
@echo " make build Production build"
@echo " make test Run the whole test suite + copy-paste summary"
@echo " make test-watch Run the test suite in watch mode"
@echo ""
@echo "Deploy (run on the SERVER, with .env filled in):"
@echo " make deploy Pull latest, rebuild, restart"
@echo " make deploy-logs Follow the app logs"
@echo " make deploy-down Stop the production stack"
@echo " make health Check app health endpoint via public domain"
## --- Local development (DB in Docker, app on host) -------------------------
install:
npm install
start: start:
docker compose up -d --build -@docker compose stop app 2>/dev/null || true
docker compose up -d db
@for _ in $$(seq 1 20); do docker compose exec -T db pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
npm run dev
stop: stop:
docker compose down docker compose down
restart: stop start restart: stop start
## --- Database --------------------------------------------------------------
db-up:
docker compose up -d db
db-down:
docker compose down
migrate:
npm run db:migrate
generate: db-generate ## Alias for db-generate
db-generate:
npm run db:generate
db-push:
npm run db:push
studio:
npm run db:studio
psql:
docker compose exec db psql -U postgres -d moh_sass
## --- Quality ---------------------------------------------------------------
build:
npm run build
test:
@node scripts/test-summary.mjs
test-watch:
npm run test:watch
## --- Deploy (server) -------------------------------------------------------
# Run these ON THE SERVER, inside the repo. docker-compose.yml is the full stack
# (app + db); the server does `git pull` + `make deploy`.
deploy: deploy:
git pull git pull
docker compose up -d --build docker compose up -d --build
@git log -1 --oneline @git log -1 --oneline
logs: deploy-logs:
docker compose logs -f --tail=200 docker compose logs -f --tail=200
build: deploy-down:
docker compose build docker compose down
ps:
docker compose ps
port:
@echo "Site: http://localhost:3014"
@echo "Admin: http://rootmohfarawati.localhost:3014"
clean-orphans:
docker compose up -d --remove-orphans
app-shell:
docker compose exec app sh
db-shell:
docker compose exec db psql -U postgres -d moh_sass
db-init:
docker compose exec app sh -lc "npm run db:migrate && npm run db:seed"
db-generate:
docker compose exec app npm run db:generate
db-migrate:
docker compose exec app npm run db:migrate
db-push:
docker compose exec app npm run db:push
db-seed:
docker compose exec app npm run db:seed
health: health:
curl -sS https://mohfarawati.de/api/health curl -sS https://mohfarawati.de/api/health
help:
@echo "Available targets:"
@echo " make start Start all containers"
@echo " make stop Stop and remove containers"
@echo " make restart Restart all containers"
@echo " make deploy Pull latest code and deploy updated stack"
@echo " make logs Follow container logs"
@echo " make build Build images"
@echo " make ps Show container status"
@echo " make port Show app public URL"
@echo " make clean-orphans Remove orphaned old containers"
@echo " make app-shell Open shell in app container"
@echo " make db-shell Open PostgreSQL shell"
@echo " make db-init Apply migrations and run seed (first run)"
@echo " make db-generate Generate a Drizzle migration from schema changes"
@echo " make db-migrate Apply pending Drizzle migrations"
@echo " make db-push Push schema directly (dev convenience)"
@echo " make db-seed Seed database data"
@echo " make health Check app health endpoint via public domain"
+98 -4
View File
@@ -3,9 +3,19 @@ import { getLocale, getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero"; import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { CapabilitiesSection } from "@/components/home/capabilities-section";
import { ProcessSection } from "@/components/home/process-section";
import { ContactCtaSection } from "@/components/home/contact-cta-section";
import type {
CapabilitiesSectionCopy,
ContactCtaSectionCopy,
ProcessSectionCopy,
} from "@/components/home/types";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
type AboutPageProps = { type AboutPageProps = {
@@ -36,6 +46,38 @@ export default async function AboutPage({ params }: AboutPageProps) {
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" }); const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
const contactHref = getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale);
const capabilitiesCopy: CapabilitiesSectionCopy = {
eyebrow: t("capabilities.eyebrow"),
title: t("capabilities.title"),
description: t("capabilities.description"),
items: t.raw("capabilities.items") as CapabilitiesSectionCopy["items"],
};
const processCopy: ProcessSectionCopy = {
eyebrow: t("process.eyebrow"),
title: t("process.title"),
description: t("process.description"),
steps: t.raw("process.steps") as ProcessSectionCopy["steps"],
};
const contactCtaCopy: ContactCtaSectionCopy = {
eyebrow: t("contactCta.eyebrow"),
title: t("contactCta.title"),
description: t("contactCta.description"),
contactCta: t("contactCta.contactCta"),
githubCta: t("contactCta.githubCta"),
emailLabel: t("contactCta.emailLabel"),
emailValue: t("contactCta.emailValue"),
availabilityLabel: t("contactCta.availabilityLabel"),
availabilityValue: t("contactCta.availabilityValue"),
githubHref: t("contactCta.githubHref"),
};
const storyParagraphs = t.raw("story.paragraphs") as string[];
const toolItems = t.raw("tools.items") as string[];
return ( return (
<> <>
<PageHero <PageHero
@@ -45,10 +87,62 @@ export default async function AboutPage({ params }: AboutPageProps) {
description={t("description")} description={t("description")}
/> />
<Container className="pb-16 lg:pb-20"> <Container className="flex flex-col gap-16 pb-16 sm:gap-20 lg:gap-24 lg:pb-20">
<AppCard level={3} padding="lg" className="mx-auto max-w-3xl text-center"> <MotionFade>
<p className="text-lg font-medium text-foreground">{t("placeholder")}</p> <section className="mx-auto max-w-3xl space-y-6">
<p className="eyebrow text-caption font-medium text-brand-primary">
{t("story.eyebrow")}
</p>
<h2 className="text-balance text-h2 text-foreground">{t("story.title")}</h2>
<div className="space-y-4">
{storyParagraphs.map((paragraph) => (
<p key={paragraph} className="text-body-lg text-muted-foreground">
{paragraph}
</p>
))}
</div>
</section>
</MotionFade>
<MotionFade delay={0.06}>
<CapabilitiesSection copy={capabilitiesCopy} />
</MotionFade>
<MotionFade delay={0.08}>
<AppCard padding="lg">
<div className="space-y-5">
<div className="space-y-2">
<p className="eyebrow text-caption font-medium text-brand-primary">
{t("tools.eyebrow")}
</p>
<h3 className="text-title font-semibold text-foreground">{t("tools.title")}</h3>
</div>
<div className="flex flex-wrap gap-2">
{toolItems.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))}
</div>
</div>
</AppCard> </AppCard>
</MotionFade>
<MotionFade delay={0.1}>
<ProcessSection copy={processCopy} />
</MotionFade>
<MotionFade delay={0.12}>
<p className="mx-auto max-w-2xl text-center text-small text-muted-foreground">
<span className="text-brand-primary">{t("personalNote.eyebrow")}</span>
{" — "}
{t("personalNote.text")}
</p>
</MotionFade>
<MotionFade delay={0.14}>
<ContactCtaSection copy={contactCtaCopy} contactHref={contactHref} />
</MotionFade>
</Container> </Container>
</> </>
); );
+6 -75
View File
@@ -1,17 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { unstable_noStore as noStore } from "next/cache"; import { unstable_noStore as noStore } from "next/cache";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Mail } from "lucide-react";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { FloatingPreferences } from "@/components/layout/floating-preferences"; import { FloatingPreferences } from "@/components/layout/floating-preferences";
import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero"; import { HeroContentMotion, HeroMotionItem, HeroShell, HeroTitle } from "@/components/layout/site-hero";
import { LaunchCountdown } from "@/components/site/launch-countdown"; import { LaunchCountdown } from "@/components/site/launch-countdown";
import { Button } from "@/components/ui/button";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { resolveLocale } from "@/lib/locale";
import { cn } from "@/lib/utils";
// Target launch date for the countdown. Edit this single line to change it. // Target launch date for the countdown. Edit this single line to change it.
const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z"; const LAUNCH_DATE_ISO = "2026-08-28T12:00:00Z";
@@ -48,7 +44,6 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale); const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" }); const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
const isArabic = localeKey === "ar"; const isArabic = localeKey === "ar";
const DirectionIcon = isArabic ? ArrowLeft : ArrowRight;
const lines = [ const lines = [
{ {
@@ -68,63 +63,16 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
}, },
]; ];
const trackedEyebrow = isArabic ? "tracking-normal" : "uppercase tracking-[0.28em]";
const trackedLabel = isArabic ? "tracking-normal" : "uppercase tracking-[0.24em]";
const trackedPill = isArabic ? "tracking-normal" : "uppercase tracking-[0.12em]";
return ( return (
<div className="relative min-h-screen overflow-hidden"> <div className="relative h-screen overflow-hidden">
<FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} /> <FloatingPreferences locale={localeKey} defaultLocale={siteSettings.defaultLocale} />
<HeroShell className="min-h-screen" showBridges> <HeroShell className="h-screen" showBridges>
<HeroContentMotion className="relative z-10 w-full"> <HeroContentMotion className="relative z-10 w-full">
<div className="mx-auto w-full max-w-[60rem]"> <div className="mx-auto flex w-full max-w-[60rem] flex-col items-center text-center">
<div className="relative overflow-hidden rounded-[calc(var(--radius-surface)+10px)] border border-foreground/10 bg-background/45 px-6 py-12 shadow-panel backdrop-blur-[26px] dark:border-foreground/12 dark:bg-background/25 sm:px-14 sm:py-16"> <HeroTitle locale={localeKey} lines={lines} className="mx-auto tracking-normal" />
{/* top hairline highlight */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-foreground/25 to-transparent"
/>
{/* soft brand glow */}
<span
aria-hidden
className="pointer-events-none absolute -top-28 left-1/2 h-64 w-[38rem] max-w-[120%] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,hsl(var(--brand-primary)/0.18),transparent)] blur-2xl"
/>
<div className="relative flex flex-col items-center text-center"> <HeroMotionItem className="mt-12">
<HeroMotionItem>
<span
className={cn(
"inline-flex items-center gap-2.5 rounded-pill border border-foreground/10 bg-background/55 px-4 py-2 text-xs font-medium text-[hsl(var(--hero-ink)/0.82)] shadow-[var(--shadow-sm)] backdrop-blur-[18px] dark:border-foreground/12 dark:bg-background/20 dark:text-foreground/82",
trackedPill,
)}
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-secondary opacity-70" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand-secondary" />
</span>
{t("status")}
</span>
</HeroMotionItem>
<HeroMotionItem>
<p className={cn("mt-7 text-xs font-medium text-foreground/45", trackedEyebrow)}>
{t("kicker")}
</p>
</HeroMotionItem>
<HeroTitle locale={localeKey} lines={lines} className="mx-auto mt-4 tracking-normal" />
<HeroMotionItem>
<p className="mx-auto mt-8 max-w-[36rem] text-sm leading-7 text-foreground/68 sm:text-base">
{t("description")}
</p>
</HeroMotionItem>
<HeroMotionItem className="mt-11 flex flex-col items-center gap-4">
<span className={cn("text-xs font-medium text-foreground/45", trackedLabel)}>
{t("countdownLabel")}
</span>
<LaunchCountdown <LaunchCountdown
targetIso={LAUNCH_DATE_ISO} targetIso={LAUNCH_DATE_ISO}
arabic={isArabic} arabic={isArabic}
@@ -137,23 +85,6 @@ export default async function ComingSoonPage({ params }: ComingSoonPageProps) {
}} }}
/> />
</HeroMotionItem> </HeroMotionItem>
<HeroMotionItem className="mt-12 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
<Button asChild size="lg" className="w-full sm:w-auto">
<Link href={getLocalizedPath(localeKey, "/contact", siteSettings.defaultLocale)}>
<Mail className="h-4 w-4" />
{t("primaryCta")}
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full sm:w-auto">
<Link href={getLocalizedPath(localeKey, "/", siteSettings.defaultLocale)}>
{t("secondaryCta")}
<DirectionIcon className="h-4 w-4" />
</Link>
</Button>
</HeroMotionItem>
</div>
</div>
</div> </div>
</HeroContentMotion> </HeroContentMotion>
</HeroShell> </HeroShell>
+3 -2
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { MediaKind } from "@/lib/db/enums";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -11,9 +11,10 @@ import { withFlash } from "@/lib/admin-feedback";
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media"; import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service"; import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
import { isManagedMediaFilePath } from "@/lib/media-storage"; import { isManagedMediaFilePath } from "@/lib/media-storage";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema"; import { mediaAsset } from "@/lib/db/schema";
import { MediaKind } from "@/lib/db/enums";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
+18 -26
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { and, count, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -90,14 +90,18 @@ function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validierung fehlgeschlagen."; return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
} }
// Postgres unique-violation SQLSTATE (was Prisma's P2002). // Postgres unique-violation (code 23505, was Prisma's "P2002"). The error shape
// differs between drivers (postgres.js exposes `.code`; PGlite in tests nests it
// or only in the message), so check code, cause.code, and the message text.
function isUniqueViolation(error: unknown): boolean { function isUniqueViolation(error: unknown): boolean {
return ( if (typeof error !== "object" || error === null) {
typeof error === "object" && return false;
error !== null && }
"code" in error && const e = error as { code?: string; cause?: { code?: string }; message?: string };
(error as { code?: string }).code === "23505" if (e.code === "23505" || e.cause?.code === "23505") {
); return true;
}
return typeof e.message === "string" && /23505|duplicate key|unique constraint/i.test(e.message);
} }
async function revalidatePortfolioPages() { async function revalidatePortfolioPages() {
@@ -139,15 +143,10 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"), isActive: normalizeCheckboxValue(formData, "isActive"),
}); });
const { id: categoryId, ...categoryValues } = parsed; if (parsed.id) {
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
if (categoryId) {
await db
.update(category)
.set({ ...categoryValues, updatedAt: new Date() })
.where(eq(category.id, categoryId));
} else { } else {
await db.insert(category).values(categoryValues); await db.insert(category).values(parsed);
} }
await revalidatePortfolioPages(); await revalidatePortfolioPages();
@@ -175,12 +174,9 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? ""); const id = String(formData.get("id") ?? "");
try { try {
const [projectCountRow] = await db const projectCount = await db.$count(portfolioProject, eq(portfolioProject.categoryId, id));
.select({ value: count() })
.from(portfolioProject)
.where(eq(portfolioProject.categoryId, id));
if ((projectCountRow?.value ?? 0) > 0) { if (projectCount > 0) {
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." })); redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
} }
@@ -405,16 +401,12 @@ export async function saveProjectAction(formData: FormData) {
? new Date() ? new Date()
: existingProject?.publishedAt ?? new Date() : existingProject?.publishedAt ?? new Date()
: null, : null,
updatedAt: new Date(),
}) })
.where(eq(portfolioProject.id, parsed.id)) .where(eq(portfolioProject.id, parsed.id))
.returning() .returning()
: await tx : await tx
.insert(portfolioProject) .insert(portfolioProject)
.values({ .values({ ...projectValues, publishedAt: parsed.isPublished ? new Date() : null })
...projectValues,
publishedAt: parsed.isPublished ? new Date() : null,
})
.returning(); .returning();
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id)); await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
+3 -2
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { inArray } from "drizzle-orm"; import { MediaUsageType } from "@/lib/db/enums";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,9 +30,10 @@ import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage"; import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation"; import { mediaFieldInputSchema } from "@/lib/media-validation";
import { inArray } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema"; import { mediaAsset } from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
+23 -3
View File
@@ -12,8 +12,9 @@ services:
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://mohfarawati.de} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://mohfarawati.de}
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de} NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de}
ADMIN_HOST: ${ADMIN_HOST:-root.mohfarawati.de}
SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000} SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000}
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env}
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)} ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)}
ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env} ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env}
@@ -27,11 +28,25 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
start_period: 20s start_period: 20s
ports: labels:
- "${PORT:-3014}:3000" - traefik.enable=true
- traefik.docker.network=${TRAEFIK_NETWORK:-proxy}
# HTTPS router — public site + www + admin subdomain all hit this one app;
# the app's middleware routes root.mohfarawati.de to the admin internally.
- "traefik.http.routers.sass.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)"
- traefik.http.routers.sass.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.sass.tls=true
- traefik.http.routers.sass.tls.certresolver=${TRAEFIK_CERTRESOLVER:-cf}
- traefik.http.services.sass.loadbalancer.server.port=3000
# HTTP router → redirect to HTTPS
- "traefik.http.routers.sass-http.rule=Host(`mohfarawati.de`) || Host(`www.mohfarawati.de`) || Host(`root.mohfarawati.de`)"
- traefik.http.routers.sass-http.entrypoints=web
- traefik.http.routers.sass-http.middlewares=sass-redirect
- traefik.http.middlewares.sass-redirect.redirectscheme.scheme=https
volumes: volumes:
- media_uploads:/app/public/uploads/media - media_uploads:/app/public/uploads/media
networks: networks:
- proxy
- appnet - appnet
db: db:
@@ -57,3 +72,8 @@ volumes:
networks: networks:
appnet: appnet:
# Shared external Traefik network (same one the other projects use).
# Override the name per server with TRAEFIK_NETWORK in .env.
proxy:
external: true
name: ${TRAEFIK_NETWORK:-proxy}
+6 -7
View File
@@ -1,13 +1,12 @@
import type { Config } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
const rawConnectionString = export default defineConfig({
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
export default {
schema: "./lib/db/schema.ts", schema: "./lib/db/schema.ts",
out: "./lib/db/migrations", out: "./lib/db/migrations",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: rawConnectionString.split("?")[0], url:
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass",
}, },
} satisfies Config; });
+12 -11
View File
@@ -1,8 +1,9 @@
import { createHash, createHmac, timingSafeEqual } from "crypto"; import { createHash, createHmac, timingSafeEqual } from "crypto";
import { and, eq, like, lt } from "drizzle-orm";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { appConfig } from "./db/schema"; import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing"; import { getAdminAppPath } from "./admin-routing";
@@ -141,9 +142,7 @@ async function cleanupExpiredLockouts(): Promise<void> {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000); const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await db await db
.delete(appConfig) .delete(appConfig)
.where( .where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)));
and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)),
);
} catch { } catch {
// Non-critical — ignore cleanup errors. // Non-critical — ignore cleanup errors.
} }
@@ -218,12 +217,12 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const key = getLockoutKey(ip); const key = getLockoutKey(ip);
const rows = await db const [config] = await db
.select({ value: appConfig.value }) .select({ value: appConfig.value })
.from(appConfig) .from(appConfig)
.where(eq(appConfig.key, key)) .where(eq(appConfig.key, key))
.limit(1); .limit(1);
const state = parseFailState(rows[0]?.value); const state = parseFailState(config?.value);
const now = Date.now(); const now = Date.now();
if (state.lockUntil > now) { if (state.lockUntil > now) {
@@ -247,24 +246,26 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts(); await cleanupExpiredLockouts();
const rows = await db const [config] = await db
.select({ value: appConfig.value }) .select({ value: appConfig.value })
.from(appConfig) .from(appConfig)
.where(eq(appConfig.key, key)) .where(eq(appConfig.key, key))
.limit(1); .limit(1);
const current = parseFailState(rows[0]?.value); const current = parseFailState(config?.value);
// If a previous lockout has expired, reset the counter. // If a previous lockout has expired, reset the counter.
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts; const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
const attempts = baseAttempts + 1; const attempts = baseAttempts + 1;
const locked = attempts >= MAX_FAILED_ATTEMPTS; const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0; const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
const value = JSON.stringify({ attempts, lockUntil });
await db await db
.insert(appConfig) .insert(appConfig)
.values({ key, value }) .values({ key, value: JSON.stringify({ attempts, lockUntil }) })
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } }); .onConflictDoUpdate({
target: appConfig.key,
set: { value: JSON.stringify({ attempts, lockUntil }) },
});
return { return {
locked, locked,
+37 -55
View File
@@ -1,7 +1,7 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { appConfig, mediaUsage } from "./db/schema"; import { appConfig, mediaAsset, mediaUsage } from "./db/schema";
export const MAINTENANCE_MODE_KEY = "maintenance_mode"; export const MAINTENANCE_MODE_KEY = "maintenance_mode";
export { export {
SITE_NAME_KEY, SITE_NAME_KEY,
@@ -68,36 +68,34 @@ import {
type MarqueeSettings, type MarqueeSettings,
} from "./marquee-settings"; } from "./marquee-settings";
async function getAppConfigValue(key: string): Promise<string | undefined> { // Small helpers over the app_config key/value table (Drizzle).
const rows = await db async function readConfigValue(key: string): Promise<string | undefined> {
const [row] = await db
.select({ value: appConfig.value }) .select({ value: appConfig.value })
.from(appConfig) .from(appConfig)
.where(eq(appConfig.key, key)) .where(eq(appConfig.key, key))
.limit(1); .limit(1);
return rows[0]?.value; return row?.value;
} }
async function upsertAppConfigValue(key: string, value: string): Promise<void> { async function upsertConfig(key: string, value: string): Promise<void> {
await db await db
.insert(appConfig) .insert(appConfig)
.values({ key, value }) .values({ key, value })
.onConflictDoUpdate({ .onConflictDoUpdate({ target: appConfig.key, set: { value } });
target: appConfig.key,
set: { value, updatedAt: new Date() },
});
} }
export async function getMaintenanceMode(): Promise<boolean> { export async function getMaintenanceMode(): Promise<boolean> {
try { try {
return (await getAppConfigValue(MAINTENANCE_MODE_KEY)) === "true"; return (await readConfigValue(MAINTENANCE_MODE_KEY)) === "true";
} catch { } catch {
return false; return false;
} }
} }
export async function setMaintenanceMode(enabled: boolean): Promise<void> { export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await upsertAppConfigValue(MAINTENANCE_MODE_KEY, enabled ? "true" : "false"); await upsertConfig(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
} }
export async function getSiteSettings(): Promise<SiteSettings> { export async function getSiteSettings(): Promise<SiteSettings> {
@@ -117,12 +115,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
} }
export async function updateSiteSettings(settings: SiteSettings): Promise<void> { export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await upsertAppConfigValue(SITE_SETTINGS_KEY, JSON.stringify(settings)); await upsertConfig(SITE_SETTINGS_KEY, JSON.stringify(settings));
} }
export async function getMailSettings(): Promise<MailSettings> { export async function getMailSettings(): Promise<MailSettings> {
try { try {
return parseMailSettingsValue(await getAppConfigValue(MAIL_SETTINGS_KEY)); return parseMailSettingsValue(await readConfigValue(MAIL_SETTINGS_KEY));
} catch { } catch {
return buildDefaultMailSettings(); return buildDefaultMailSettings();
} }
@@ -135,12 +133,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
} }
export async function updateMailSettings(settings: MailSettings): Promise<void> { export async function updateMailSettings(settings: MailSettings): Promise<void> {
await upsertAppConfigValue(MAIL_SETTINGS_KEY, JSON.stringify(settings)); await upsertConfig(MAIL_SETTINGS_KEY, JSON.stringify(settings));
} }
export async function getMarqueeSettings(): Promise<MarqueeSettings> { export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try { try {
return parseMarqueeSettingsValue(await getAppConfigValue(MARQUEE_SETTINGS_KEY)); return parseMarqueeSettingsValue(await readConfigValue(MARQUEE_SETTINGS_KEY));
} catch { } catch {
return buildDefaultMarqueeSettings(); return buildDefaultMarqueeSettings();
} }
@@ -149,68 +147,52 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> { export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings); const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await upsertAppConfigValue(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings)); await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
} }
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> { export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try { try {
const usages = await db.query.mediaUsage.findMany({ const usages = await db
where: and( .select({
fieldKey: mediaUsage.fieldKey,
updatedAt: mediaUsage.updatedAt,
assetId: mediaAsset.id,
assetUrl: mediaAsset.url,
})
.from(mediaUsage)
.innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId))
.where(
and(
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE), eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID), eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
), ),
columns: { );
fieldKey: true,
updatedAt: true,
},
with: {
asset: {
columns: {
id: true,
url: true,
},
},
},
});
return usages.reduce<SiteSettingsMediaBindings>( return usages.reduce<SiteSettingsMediaBindings>((result, usage) => {
(result, usage) => { const binding = {
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) { assetId: usage.assetId,
result.siteLogoLight = { url: usage.assetUrl,
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(), version: usage.updatedAt.toISOString(),
}; };
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoLight = binding;
} }
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
result.siteLogoDark = { result.siteLogoDark = binding;
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
} }
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.favicon = { result.favicon = binding;
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
} }
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
result.defaultOgImage = { result.defaultOgImage = binding;
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
} }
return result; return result;
}, }, getDefaultSiteSettingsMediaBindings());
getDefaultSiteSettingsMediaBindings(),
);
} catch { } catch {
return getDefaultSiteSettingsMediaBindings(); return getDefaultSiteSettingsMediaBindings();
} }
+33 -54
View File
@@ -1,56 +1,35 @@
// Shared enum values + types. NO server/ORM imports here — this file is safe to import {
// import from client components (replaces the old `@prisma/client` enum imports). mediaKind,
// mediaSource,
// Defined as `const object + union type` (the same shape Prisma generated) rather mediaUsageType,
// than a TS `enum`, so bare string literals like "IMAGE" stay assignable and portfolioAssetKind,
// `z.nativeEnum(...)` keeps working. portfolioProjectViewMode,
portfolioSectionType,
} from "./schema";
export const PortfolioSectionType = { /**
RICH_TEXT: "RICH_TEXT", * Prisma-compatible enum objects + types, derived from the Drizzle pgEnums, so
GALLERY: "GALLERY", * existing consumers can keep writing `MediaKind.IMAGE` (value) and `: MediaKind`
STATS: "STATS", * (type) — only the import path changes from `@prisma/client` to `@/lib/db/enums`.
DELIVERABLES: "DELIVERABLES", */
LINK: "LINK", function asEnum<T extends string>(values: readonly T[]): { [K in T]: K } {
} as const; return Object.fromEntries(values.map((v) => [v, v])) as { [K in T]: K };
export type PortfolioSectionType = (typeof PortfolioSectionType)[keyof typeof PortfolioSectionType];
export const PortfolioAssetKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type PortfolioAssetKind = (typeof PortfolioAssetKind)[keyof typeof PortfolioAssetKind];
export const PortfolioProjectViewMode = {
GRID: "GRID",
STORY: "STORY",
CASE_STUDY: "CASE_STUDY",
} as const;
export type PortfolioProjectViewMode =
(typeof PortfolioProjectViewMode)[keyof typeof PortfolioProjectViewMode];
export const MediaSource = {
UPLOAD: "UPLOAD",
EXTERNAL: "EXTERNAL",
} as const;
export type MediaSource = (typeof MediaSource)[keyof typeof MediaSource];
export const MediaKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type MediaKind = (typeof MediaKind)[keyof typeof MediaKind];
export const MediaUsageType = {
PORTFOLIO_COVER: "PORTFOLIO_COVER",
PORTFOLIO_SECTION: "PORTFOLIO_SECTION",
PORTFOLIO_ASSET: "PORTFOLIO_ASSET",
GENERIC: "GENERIC",
} as const;
export type MediaUsageType = (typeof MediaUsageType)[keyof typeof MediaUsageType];
// Helper: enum-object -> tuple of its string values, for Drizzle pgEnum(...).
// Preserves the literal union (not widened to `string`) so pgEnum columns infer
// as the exact union type.
export function enumValues<T extends Record<string, string>>(e: T): [T[keyof T], ...T[keyof T][]] {
return Object.values(e) as [T[keyof T], ...T[keyof T][]];
} }
export const MediaKind = asEnum(mediaKind.enumValues);
export type MediaKind = (typeof mediaKind.enumValues)[number];
export const MediaSource = asEnum(mediaSource.enumValues);
export type MediaSource = (typeof mediaSource.enumValues)[number];
export const MediaUsageType = asEnum(mediaUsageType.enumValues);
export type MediaUsageType = (typeof mediaUsageType.enumValues)[number];
export const PortfolioAssetKind = asEnum(portfolioAssetKind.enumValues);
export type PortfolioAssetKind = (typeof portfolioAssetKind.enumValues)[number];
export const PortfolioProjectViewMode = asEnum(portfolioProjectViewMode.enumValues);
export type PortfolioProjectViewMode = (typeof portfolioProjectViewMode.enumValues)[number];
export const PortfolioSectionType = asEnum(portfolioSectionType.enumValues);
export type PortfolioSectionType = (typeof portfolioSectionType.enumValues)[number];
+14 -9
View File
@@ -3,21 +3,26 @@ import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
const rawConnectionString = /**
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"; * The Drizzle database client (postgres.js driver), matching the house standard
* used by the other projects. Replaces the old Prisma client (`lib/prisma.ts`).
// Prisma allowed a `?schema=public` query param that postgres.js does not * A single connection is reused across hot reloads in dev.
// understand — strip any unknown query string; `public` is the default schema. */
const connectionString = rawConnectionString.split("?")[0]; // Strip any query string (e.g. a leftover Prisma `?schema=public`) — postgres.js
// forwards unknown params to the server as startup options and Postgres rejects
// them ("unrecognized configuration parameter"). `public` is the default schema.
const connectionString = (
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
).split("?")[0];
const globalForDb = globalThis as unknown as { const globalForDb = globalThis as unknown as {
pgClient: ReturnType<typeof postgres> | undefined; dbClient: ReturnType<typeof postgres> | undefined;
}; };
const client = globalForDb.pgClient ?? postgres(connectionString, { max: 10 }); const client = globalForDb.dbClient ?? postgres(connectionString);
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
globalForDb.pgClient = client; globalForDb.dbClient = client;
} }
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });
@@ -1,125 +0,0 @@
CREATE TYPE "public"."MediaKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."MediaSource" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
CREATE TYPE "public"."MediaUsageType" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
CREATE TYPE "public"."PortfolioAssetKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."PortfolioProjectViewMode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
CREATE TYPE "public"."PortfolioSectionType" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
CREATE TABLE "AppConfig" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "AppConfig_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "Category" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nameAr" text NOT NULL,
"nameEn" text NOT NULL,
"nameDe" text NOT NULL,
"descriptionAr" text NOT NULL,
"descriptionEn" text NOT NULL,
"descriptionDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"isActive" boolean DEFAULT true NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "Category_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "MediaAsset" (
"id" text PRIMARY KEY NOT NULL,
"source" "MediaSource" NOT NULL,
"kind" "MediaKind" NOT NULL,
"url" text NOT NULL,
"fileName" text NOT NULL,
"label" text NOT NULL,
"altText" text,
"mimeType" text,
"size" integer,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "MediaUsage" (
"id" text PRIMARY KEY NOT NULL,
"assetId" text NOT NULL,
"usageType" "MediaUsageType" NOT NULL,
"entityType" text NOT NULL,
"entityId" text NOT NULL,
"fieldKey" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioAsset" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"kind" "PortfolioAssetKind" NOT NULL,
"filePath" text NOT NULL,
"altAr" text NOT NULL,
"altEn" text NOT NULL,
"altDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioProject" (
"id" text PRIMARY KEY NOT NULL,
"categoryId" text NOT NULL,
"slug" text NOT NULL,
"viewMode" "PortfolioProjectViewMode" DEFAULT 'GRID' NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"summaryAr" text NOT NULL,
"summaryEn" text NOT NULL,
"summaryDe" text NOT NULL,
"clientName" text NOT NULL,
"projectYear" integer NOT NULL,
"serviceLabelAr" text NOT NULL,
"serviceLabelEn" text NOT NULL,
"serviceLabelDe" text NOT NULL,
"previewUrl" text,
"coverImagePath" text,
"isFeatured" boolean DEFAULT false NOT NULL,
"isPublished" boolean DEFAULT false NOT NULL,
"publishedAt" timestamp (3),
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "PortfolioProject_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "PortfolioSection" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"type" "PortfolioSectionType" NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"bodyAr" text NOT NULL,
"bodyEn" text NOT NULL,
"bodyDe" text NOT NULL,
"imagePath" text,
"linkUrl" text,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_MediaAsset_id_fk" FOREIGN KEY ("assetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioProject" ADD CONSTRAINT "PortfolioProject_categoryId_Category_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."Category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioSection" ADD CONSTRAINT "PortfolioSection_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset" USING btree ("kind","createdAt");--> statement-breakpoint
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage" USING btree ("usageType","entityType","entityId","fieldKey");--> statement-breakpoint
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage" USING btree ("assetId");--> statement-breakpoint
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage" USING btree ("entityType","entityId");--> statement-breakpoint
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset" USING btree ("projectId","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("categoryId","isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection" USING btree ("projectId","sortOrder");
+125
View File
@@ -0,0 +1,125 @@
CREATE TYPE "public"."media_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."media_source" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
CREATE TYPE "public"."media_usage_type" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
CREATE TYPE "public"."portfolio_asset_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."portfolio_project_view_mode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
CREATE TYPE "public"."portfolio_section_type" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
CREATE TABLE "app_config" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "app_config_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "category" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"name_ar" text NOT NULL,
"name_en" text NOT NULL,
"name_de" text NOT NULL,
"description_ar" text NOT NULL,
"description_en" text NOT NULL,
"description_de" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "category_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "media_asset" (
"id" text PRIMARY KEY NOT NULL,
"source" "media_source" NOT NULL,
"kind" "media_kind" NOT NULL,
"url" text NOT NULL,
"file_name" text NOT NULL,
"label" text NOT NULL,
"alt_text" text,
"mime_type" text,
"size" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "media_usage" (
"id" text PRIMARY KEY NOT NULL,
"asset_id" text NOT NULL,
"usage_type" "media_usage_type" NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"field_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "portfolio_asset" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"kind" "portfolio_asset_kind" NOT NULL,
"file_path" text NOT NULL,
"alt_ar" text NOT NULL,
"alt_en" text NOT NULL,
"alt_de" text NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "portfolio_project" (
"id" text PRIMARY KEY NOT NULL,
"category_id" text NOT NULL,
"slug" text NOT NULL,
"view_mode" "portfolio_project_view_mode" DEFAULT 'GRID' NOT NULL,
"title_ar" text NOT NULL,
"title_en" text NOT NULL,
"title_de" text NOT NULL,
"summary_ar" text NOT NULL,
"summary_en" text NOT NULL,
"summary_de" text NOT NULL,
"client_name" text NOT NULL,
"project_year" integer NOT NULL,
"service_label_ar" text NOT NULL,
"service_label_en" text NOT NULL,
"service_label_de" text NOT NULL,
"preview_url" text,
"cover_image_path" text,
"is_featured" boolean DEFAULT false NOT NULL,
"is_published" boolean DEFAULT false NOT NULL,
"published_at" timestamp with time zone,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "portfolio_project_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "portfolio_section" (
"id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"type" "portfolio_section_type" NOT NULL,
"title_ar" text NOT NULL,
"title_en" text NOT NULL,
"title_de" text NOT NULL,
"body_ar" text NOT NULL,
"body_en" text NOT NULL,
"body_de" text NOT NULL,
"image_path" text,
"link_url" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "media_usage" ADD CONSTRAINT "media_usage_asset_id_media_asset_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."media_asset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_asset" ADD CONSTRAINT "portfolio_asset_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_project" ADD CONSTRAINT "portfolio_project_category_id_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "portfolio_section" ADD CONSTRAINT "portfolio_section_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "media_asset_kind_created_idx" ON "media_asset" USING btree ("kind","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "media_usage_unique_slot" ON "media_usage" USING btree ("usage_type","entity_type","entity_id","field_key");--> statement-breakpoint
CREATE INDEX "media_usage_asset_idx" ON "media_usage" USING btree ("asset_id");--> statement-breakpoint
CREATE INDEX "media_usage_entity_idx" ON "media_usage" USING btree ("entity_type","entity_id");--> statement-breakpoint
CREATE INDEX "portfolio_asset_project_sort_idx" ON "portfolio_asset" USING btree ("project_id","sort_order");--> statement-breakpoint
CREATE INDEX "portfolio_project_category_published_sort_idx" ON "portfolio_project" USING btree ("category_id","is_published","sort_order");--> statement-breakpoint
CREATE INDEX "portfolio_project_published_sort_idx" ON "portfolio_project" USING btree ("is_published","sort_order");--> statement-breakpoint
CREATE INDEX "portfolio_section_project_sort_idx" ON "portfolio_section" USING btree ("project_id","sort_order");
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1786146995564, "when": 1786049545718,
"tag": "0000_absurd_rawhide_kid", "tag": "0000_fixed_venom",
"breakpoints": true "breakpoints": true
} }
] ]
+135 -142
View File
@@ -1,4 +1,3 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
boolean, boolean,
@@ -11,186 +10,198 @@ import {
uniqueIndex, uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { /**
MediaKind, * Drizzle schema — the single source of truth for the database, replacing the
MediaSource, * old Prisma schema (see docs). The database is Postgres; migrations are
MediaUsageType, * generated with `drizzle-kit generate`. IDs are app-generated opaque strings
PortfolioAssetKind, * (was Prisma `cuid()`), timestamps default in the DB and bump on update.
PortfolioProjectViewMode, */
PortfolioSectionType,
enumValues,
} from "./enums";
// Postgres enum types — names match the ones Prisma created, so no DB migration // `crypto.randomUUID()` is a global in Node 20+ and browsers (no node: import),
// is needed for the ORM swap. // so the schema stays safe to pull into a client bundle via lib/db/enums.
export const portfolioSectionTypeEnum = pgEnum("PortfolioSectionType", enumValues(PortfolioSectionType)); const id = () =>
export const portfolioAssetKindEnum = pgEnum("PortfolioAssetKind", enumValues(PortfolioAssetKind)); text("id")
export const portfolioProjectViewModeEnum = pgEnum("PortfolioProjectViewMode", enumValues(PortfolioProjectViewMode)); .primaryKey()
export const mediaSourceEnum = pgEnum("MediaSource", enumValues(MediaSource)); .$defaultFn(() => crypto.randomUUID());
export const mediaKindEnum = pgEnum("MediaKind", enumValues(MediaKind));
export const mediaUsageTypeEnum = pgEnum("MediaUsageType", enumValues(MediaUsageType));
// Shared column builders (Prisma parity): cuid ids, precision-3 timestamps. const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
const id = () => text("id").primaryKey().$defaultFn(() => createId()); const updatedAt = timestamp("updated_at", { withTimezone: true })
const createdAt = () => timestamp("createdAt", { precision: 3, mode: "date" }).defaultNow().notNull();
const updatedAt = () =>
timestamp("updatedAt", { precision: 3, mode: "date" })
.defaultNow()
.notNull() .notNull()
.defaultNow()
.$onUpdate(() => new Date()); .$onUpdate(() => new Date());
export const appConfig = pgTable("AppConfig", { // --- Enums ------------------------------------------------------------------
export const portfolioSectionType = pgEnum("portfolio_section_type", [
"RICH_TEXT",
"GALLERY",
"STATS",
"DELIVERABLES",
"LINK",
]);
export const portfolioAssetKind = pgEnum("portfolio_asset_kind", ["IMAGE", "DOCUMENT"]);
export const portfolioProjectViewMode = pgEnum("portfolio_project_view_mode", [
"GRID",
"STORY",
"CASE_STUDY",
]);
export const mediaSource = pgEnum("media_source", ["UPLOAD", "EXTERNAL"]);
export const mediaKind = pgEnum("media_kind", ["IMAGE", "DOCUMENT"]);
export const mediaUsageType = pgEnum("media_usage_type", [
"PORTFOLIO_COVER",
"PORTFOLIO_SECTION",
"PORTFOLIO_ASSET",
"GENERIC",
]);
// --- Tables -----------------------------------------------------------------
export const appConfig = pgTable("app_config", {
id: id(), id: id(),
key: text("key").notNull().unique(), key: text("key").notNull().unique(),
value: text("value").notNull(), value: text("value").notNull(),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}); });
export const category = pgTable( export const category = pgTable("category", {
"Category",
{
id: id(), id: id(),
slug: text("slug").notNull().unique(), slug: text("slug").notNull().unique(),
nameAr: text("nameAr").notNull(), nameAr: text("name_ar").notNull(),
nameEn: text("nameEn").notNull(), nameEn: text("name_en").notNull(),
nameDe: text("nameDe").notNull(), nameDe: text("name_de").notNull(),
descriptionAr: text("descriptionAr").notNull(), descriptionAr: text("description_ar").notNull(),
descriptionEn: text("descriptionEn").notNull(), descriptionEn: text("description_en").notNull(),
descriptionDe: text("descriptionDe").notNull(), descriptionDe: text("description_de").notNull(),
sortOrder: integer("sortOrder").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
isActive: boolean("isActive").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}, });
);
export const portfolioProject = pgTable( export const portfolioProject = pgTable(
"PortfolioProject", "portfolio_project",
{ {
id: id(), id: id(),
categoryId: text("categoryId") categoryId: text("category_id")
.notNull() .notNull()
.references(() => category.id, { onDelete: "restrict" }), .references(() => category.id, { onDelete: "restrict" }),
slug: text("slug").notNull().unique(), slug: text("slug").notNull().unique(),
viewMode: portfolioProjectViewModeEnum("viewMode").notNull().default("GRID"), viewMode: portfolioProjectViewMode("view_mode").notNull().default("GRID"),
titleAr: text("titleAr").notNull(), titleAr: text("title_ar").notNull(),
titleEn: text("titleEn").notNull(), titleEn: text("title_en").notNull(),
titleDe: text("titleDe").notNull(), titleDe: text("title_de").notNull(),
summaryAr: text("summaryAr").notNull(), summaryAr: text("summary_ar").notNull(),
summaryEn: text("summaryEn").notNull(), summaryEn: text("summary_en").notNull(),
summaryDe: text("summaryDe").notNull(), summaryDe: text("summary_de").notNull(),
clientName: text("clientName").notNull(), clientName: text("client_name").notNull(),
projectYear: integer("projectYear").notNull(), projectYear: integer("project_year").notNull(),
serviceLabelAr: text("serviceLabelAr").notNull(), serviceLabelAr: text("service_label_ar").notNull(),
serviceLabelEn: text("serviceLabelEn").notNull(), serviceLabelEn: text("service_label_en").notNull(),
serviceLabelDe: text("serviceLabelDe").notNull(), serviceLabelDe: text("service_label_de").notNull(),
previewUrl: text("previewUrl"), previewUrl: text("preview_url"),
coverImagePath: text("coverImagePath"), coverImagePath: text("cover_image_path"),
isFeatured: boolean("isFeatured").notNull().default(false), isFeatured: boolean("is_featured").notNull().default(false),
isPublished: boolean("isPublished").notNull().default(false), isPublished: boolean("is_published").notNull().default(false),
publishedAt: timestamp("publishedAt", { precision: 3, mode: "date" }), publishedAt: timestamp("published_at", { withTimezone: true }),
sortOrder: integer("sortOrder").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}, },
(table) => [ (t) => [
index("PortfolioProject_categoryId_isPublished_sortOrder_idx").on( index("portfolio_project_category_published_sort_idx").on(t.categoryId, t.isPublished, t.sortOrder),
table.categoryId, index("portfolio_project_published_sort_idx").on(t.isPublished, t.sortOrder),
table.isPublished,
table.sortOrder,
),
index("PortfolioProject_isPublished_sortOrder_idx").on(table.isPublished, table.sortOrder),
], ],
); );
export const portfolioSection = pgTable( export const portfolioSection = pgTable(
"PortfolioSection", "portfolio_section",
{ {
id: id(), id: id(),
projectId: text("projectId") projectId: text("project_id")
.notNull() .notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }), .references(() => portfolioProject.id, { onDelete: "cascade" }),
type: portfolioSectionTypeEnum("type").notNull(), type: portfolioSectionType("type").notNull(),
titleAr: text("titleAr").notNull(), titleAr: text("title_ar").notNull(),
titleEn: text("titleEn").notNull(), titleEn: text("title_en").notNull(),
titleDe: text("titleDe").notNull(), titleDe: text("title_de").notNull(),
bodyAr: text("bodyAr").notNull(), bodyAr: text("body_ar").notNull(),
bodyEn: text("bodyEn").notNull(), bodyEn: text("body_en").notNull(),
bodyDe: text("bodyDe").notNull(), bodyDe: text("body_de").notNull(),
imagePath: text("imagePath"), imagePath: text("image_path"),
linkUrl: text("linkUrl"), linkUrl: text("link_url"),
sortOrder: integer("sortOrder").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}, },
(table) => [index("PortfolioSection_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)], (t) => [index("portfolio_section_project_sort_idx").on(t.projectId, t.sortOrder)],
); );
export const portfolioAsset = pgTable( export const portfolioAsset = pgTable(
"PortfolioAsset", "portfolio_asset",
{ {
id: id(), id: id(),
projectId: text("projectId") projectId: text("project_id")
.notNull() .notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }), .references(() => portfolioProject.id, { onDelete: "cascade" }),
kind: portfolioAssetKindEnum("kind").notNull(), kind: portfolioAssetKind("kind").notNull(),
filePath: text("filePath").notNull(), filePath: text("file_path").notNull(),
altAr: text("altAr").notNull(), altAr: text("alt_ar").notNull(),
altEn: text("altEn").notNull(), altEn: text("alt_en").notNull(),
altDe: text("altDe").notNull(), altDe: text("alt_de").notNull(),
sortOrder: integer("sortOrder").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}, },
(table) => [index("PortfolioAsset_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)], (t) => [index("portfolio_asset_project_sort_idx").on(t.projectId, t.sortOrder)],
); );
export const mediaAsset = pgTable( export const mediaAsset = pgTable(
"MediaAsset", "media_asset",
{ {
id: id(), id: id(),
source: mediaSourceEnum("source").notNull(), source: mediaSource("source").notNull(),
kind: mediaKindEnum("kind").notNull(), kind: mediaKind("kind").notNull(),
url: text("url").notNull(), url: text("url").notNull(),
fileName: text("fileName").notNull(), fileName: text("file_name").notNull(),
label: text("label").notNull(), label: text("label").notNull(),
altText: text("altText"), altText: text("alt_text"),
mimeType: text("mimeType"), mimeType: text("mime_type"),
size: integer("size"), size: integer("size"),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}, },
(table) => [index("MediaAsset_kind_createdAt_idx").on(table.kind, table.createdAt)], (t) => [index("media_asset_kind_created_idx").on(t.kind, t.createdAt)],
); );
export const mediaUsage = pgTable( export const mediaUsage = pgTable(
"MediaUsage", "media_usage",
{ {
id: id(), id: id(),
assetId: text("assetId") assetId: text("asset_id")
.notNull() .notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }), .references(() => mediaAsset.id, { onDelete: "cascade" }),
usageType: mediaUsageTypeEnum("usageType").notNull(), usageType: mediaUsageType("usage_type").notNull(),
entityType: text("entityType").notNull(), entityType: text("entity_type").notNull(),
entityId: text("entityId").notNull(), entityId: text("entity_id").notNull(),
fieldKey: text("fieldKey").notNull(), fieldKey: text("field_key").notNull(),
createdAt: createdAt(), createdAt,
updatedAt: updatedAt(), updatedAt,
}, },
(table) => [ (t) => [
uniqueIndex("MediaUsage_usageType_entityType_entityId_fieldKey_key").on( uniqueIndex("media_usage_unique_slot").on(t.usageType, t.entityType, t.entityId, t.fieldKey),
table.usageType, index("media_usage_asset_idx").on(t.assetId),
table.entityType, index("media_usage_entity_idx").on(t.entityType, t.entityId),
table.entityId,
table.fieldKey,
),
index("MediaUsage_assetId_idx").on(table.assetId),
index("MediaUsage_entityType_entityId_idx").on(table.entityType, table.entityId),
], ],
); );
// Relations (enable db.query.* `with:` includes). // --- Relations (for the relational query API: db.query.*.findMany({ with })) --
export const categoryRelations = relations(category, ({ many }) => ({ export const categoryRelations = relations(category, ({ many }) => ({
projects: many(portfolioProject), projects: many(portfolioProject),
})); }));
@@ -228,21 +239,3 @@ export const mediaUsageRelations = relations(mediaUsage, ({ one }) => ({
references: [mediaAsset.id], references: [mediaAsset.id],
}), }),
})); }));
// Inferred row types (replace the old `@prisma/client` model type imports).
export type AppConfig = typeof appConfig.$inferSelect;
export type Category = typeof category.$inferSelect;
export type PortfolioProject = typeof portfolioProject.$inferSelect;
export type PortfolioSection = typeof portfolioSection.$inferSelect;
export type PortfolioAsset = typeof portfolioAsset.$inferSelect;
export type MediaAsset = typeof mediaAsset.$inferSelect;
export type MediaUsage = typeof mediaUsage.$inferSelect;
export {
MediaKind,
MediaSource,
MediaUsageType,
PortfolioAssetKind,
PortfolioProjectViewMode,
PortfolioSectionType,
} from "./enums";
-565
View File
@@ -1,565 +0,0 @@
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import {
appConfig,
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "./schema";
const connectionString = (
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
).split("?")[0];
const client = postgres(connectionString, { max: 1 });
const db = drizzle(client, { schema });
type MediaAssetInput = {
source: "UPLOAD" | "EXTERNAL";
kind: "IMAGE" | "DOCUMENT";
url: string;
fileName: string;
label: string;
altText: string | null;
mimeType: string | null;
size: number | null;
};
async function upsertMediaAsset(input: MediaAssetInput) {
const [existing] = await db
.select()
.from(mediaAsset)
.where(and(eq(mediaAsset.label, input.label), eq(mediaAsset.url, input.url)))
.limit(1);
if (existing) {
const [updated] = await db
.update(mediaAsset)
.set({ ...input, updatedAt: new Date() })
.where(eq(mediaAsset.id, existing.id))
.returning();
return updated;
}
const [created] = await db.insert(mediaAsset).values(input).returning();
return created;
}
async function upsertAppConfig(key: string, value: string) {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
}
async function upsertCategory(values: typeof category.$inferInsert) {
const [row] = await db
.insert(category)
.values(values)
.onConflictDoUpdate({
target: category.slug,
set: {
nameAr: values.nameAr,
nameEn: values.nameEn,
nameDe: values.nameDe,
descriptionAr: values.descriptionAr,
descriptionEn: values.descriptionEn,
descriptionDe: values.descriptionDe,
sortOrder: values.sortOrder,
isActive: values.isActive,
updatedAt: new Date(),
},
})
.returning();
return row;
}
async function syncProjectContent(
projectId: string,
sections: Array<Omit<typeof portfolioSection.$inferInsert, "projectId">>,
assets: Array<Omit<typeof portfolioAsset.$inferInsert, "projectId">>,
) {
await db.delete(portfolioSection).where(eq(portfolioSection.projectId, projectId));
await db.delete(portfolioAsset).where(eq(portfolioAsset.projectId, projectId));
const createdSections = [];
for (const section of sections) {
const [row] = await db
.insert(portfolioSection)
.values({ projectId, ...section })
.returning();
createdSections.push(row);
}
const createdAssets = [];
for (const asset of assets) {
const [row] = await db
.insert(portfolioAsset)
.values({ projectId, ...asset })
.returning();
createdAssets.push(row);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(
projectId: string,
mediaMap: {
coverAssetId: string | null | undefined;
sectionUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
assetUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
},
) {
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
const usages: (typeof mediaUsage.$inferInsert)[] = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
if (!sectionUsage.assetId) continue;
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
if (!assetUsage.assetId) continue;
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await db.insert(mediaUsage).values(usages);
}
}
const SITE_SETTINGS_VALUE = JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
});
async function main() {
await db
.delete(mediaUsage)
.where(eq(mediaUsage.entityType, "portfolio-project"));
await db.delete(portfolioSection);
await db.delete(portfolioAsset);
await db.delete(portfolioProject);
await db.delete(category);
await upsertAppConfig("siteName", "moh-sass");
await upsertAppConfig("site_settings", SITE_SETTINGS_VALUE);
const brandCategory = await upsertCategory({
slug: "branding",
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
});
const webCategory = await upsertCategory({
slug: "web-experiences",
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
});
const commerceCategory = await upsertCategory({
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID" as const,
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY" as const,
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK" as const,
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY" as const,
titleAr: "إعادة تصميم الهوية",
titleEn: "Brand Redesign",
titleDe: "Brand Redesign",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
clientName: "Studio Client",
projectYear: 2025,
serviceLabelAr: "هوية بصرية",
serviceLabelEn: "Brand Identity",
serviceLabelDe: "Brand Identity",
previewUrl: "https://example.com/preview/brand-redesign",
coverImagePath: caseStudyCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "التحدي",
titleEn: "Challenge",
titleDe: "Herausforderung",
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "RICH_TEXT" as const,
titleAr: "الحل",
titleEn: "Solution",
titleDe: "Loesung",
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
bodyEn: "A clearer visual system with scalable usage rules was created.",
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
for (const projectConfig of projects) {
const { sections, assets, coverAssetId, ...projectValues } = projectConfig;
const [project] = await db
.insert(portfolioProject)
.values(projectValues)
.onConflictDoUpdate({
target: portfolioProject.slug,
set: {
categoryId: projectValues.categoryId,
viewMode: projectValues.viewMode,
titleAr: projectValues.titleAr,
titleEn: projectValues.titleEn,
titleDe: projectValues.titleDe,
summaryAr: projectValues.summaryAr,
summaryEn: projectValues.summaryEn,
summaryDe: projectValues.summaryDe,
clientName: projectValues.clientName,
projectYear: projectValues.projectYear,
serviceLabelAr: projectValues.serviceLabelAr,
serviceLabelEn: projectValues.serviceLabelEn,
serviceLabelDe: projectValues.serviceLabelDe,
previewUrl: projectValues.previewUrl,
coverImagePath: projectValues.coverImagePath,
isFeatured: projectValues.isFeatured,
isPublished: projectValues.isPublished,
publishedAt: projectValues.publishedAt,
sortOrder: projectValues.sortOrder,
updatedAt: new Date(),
},
})
.returning();
const created = await syncProjectContent(
project.id,
sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})),
assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})),
);
await syncProjectMediaUsages(project.id, {
coverAssetId,
sectionUsages: created.createdSections.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: sections[index]?.mediaAssetId,
})),
assetUsages: created.createdAssets.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: assets[index]?.mediaAssetId,
})),
});
}
}
main()
.then(async () => {
await client.end();
})
.catch(async (error) => {
console.error("Seed failed:", error);
await client.end();
process.exit(1);
});
+17 -41
View File
@@ -1,17 +1,17 @@
import { and, count, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { mediaAsset, mediaUsage } from "@/lib/db/schema"; import { mediaAsset, mediaUsage } from "@/lib/db/schema";
import type { MediaAsset, MediaUsage } from "@/lib/db/schema";
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums"; import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
type MediaAssetRow = typeof mediaAsset.$inferSelect;
type MediaUsageRow = typeof mediaUsage.$inferSelect;
export type MediaAssetView = Pick< export type MediaAssetView = Pick<
MediaAsset, MediaAssetRow,
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt" "id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
> & { > & {
usages: Array< usages: Array<Pick<MediaUsageRow, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">>;
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
>;
}; };
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">; export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
@@ -22,11 +22,7 @@ export type PortfolioMediaBindings = {
assetIds: Record<string, string>; assetIds: Record<string, string>;
}; };
function mapMediaAsset( function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): MediaAssetView {
asset: MediaAsset & {
usages: MediaUsage[];
},
): MediaAssetView {
return { return {
id: asset.id, id: asset.id,
source: asset.source, source: asset.source,
@@ -50,19 +46,15 @@ function mapMediaAsset(
export async function getAdminMediaAssets() { export async function getAdminMediaAssets() {
const assets = await db.query.mediaAsset.findMany({ const assets = await db.query.mediaAsset.findMany({
with: { with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } },
usages: { orderBy: [desc(mediaAsset.createdAt)],
orderBy: (usage, { desc: descOrder }) => [descOrder(usage.createdAt)],
},
},
orderBy: (asset, { desc: descOrder }) => [descOrder(asset.createdAt)],
}); });
return assets.map(mapMediaAsset); return assets.map(mapMediaAsset);
} }
export async function getMediaOptions(filters?: { kind?: MediaKind }) { export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<MediaOption[]> {
const assets = await db return db
.select({ .select({
id: mediaAsset.id, id: mediaAsset.id,
kind: mediaAsset.kind, kind: mediaAsset.kind,
@@ -73,16 +65,12 @@ export async function getMediaOptions(filters?: { kind?: MediaKind }) {
.from(mediaAsset) .from(mediaAsset)
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined) .where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
.orderBy(desc(mediaAsset.createdAt)); .orderBy(desc(mediaAsset.createdAt));
return assets;
} }
export async function getMediaAssetById(id: string) { export async function getMediaAssetById(id: string) {
const asset = await db.query.mediaAsset.findFirst({ const asset = await db.query.mediaAsset.findFirst({
where: eq(mediaAsset.id, id), where: eq(mediaAsset.id, id),
with: { with: { usages: true },
usages: true,
},
}); });
return asset ? mapMediaAsset(asset) : null; return asset ? mapMediaAsset(asset) : null;
@@ -98,7 +86,7 @@ export async function createMediaAsset(input: {
mimeType?: string | null; mimeType?: string | null;
size?: number | null; size?: number | null;
}) { }) {
const [asset] = await db const [created] = await db
.insert(mediaAsset) .insert(mediaAsset)
.values({ .values({
source: input.source, source: input.source,
@@ -112,7 +100,7 @@ export async function createMediaAsset(input: {
}) })
.returning(); .returning();
return asset; return created;
} }
export async function replaceEntityMediaUsages(input: { export async function replaceEntityMediaUsages(input: {
@@ -127,12 +115,7 @@ export async function replaceEntityMediaUsages(input: {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.delete(mediaUsage) .delete(mediaUsage)
.where( .where(and(eq(mediaUsage.entityType, input.entityType), eq(mediaUsage.entityId, input.entityId)));
and(
eq(mediaUsage.entityType, input.entityType),
eq(mediaUsage.entityId, input.entityId),
),
);
if (input.usages.length === 0) { if (input.usages.length === 0) {
return; return;
@@ -164,9 +147,7 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
fieldKey: mediaUsage.fieldKey, fieldKey: mediaUsage.fieldKey,
}) })
.from(mediaUsage) .from(mediaUsage)
.where( .where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)),
);
return usages.reduce<PortfolioMediaBindings>( return usages.reduce<PortfolioMediaBindings>(
(result, usage) => { (result, usage) => {
@@ -193,10 +174,5 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
} }
export async function countMediaUsageReferences(assetId: string) { export async function countMediaUsageReferences(assetId: string) {
const [row] = await db return db.$count(mediaUsage, eq(mediaUsage.assetId, assetId));
.select({ value: count() })
.from(mediaUsage)
.where(eq(mediaUsage.assetId, assetId));
return row?.value ?? 0;
} }
+38 -105
View File
@@ -1,71 +1,22 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { cache } from "react"; import { cache } from "react";
import { and, asc, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { category, portfolioProject } from "@/lib/db/schema"; import {
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@/lib/db/schema"; category as categoryTable,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import type { PortfolioProjectViewMode } from "@/lib/db/enums"; import type { PortfolioProjectViewMode } from "@/lib/db/enums";
import { getPortfolioMediaBindings } from "@/lib/media"; import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale"; import type { AppLocale } from "@/lib/locale";
type CategoryRecord = Pick< type CategoryRecord = typeof categoryTable.$inferSelect;
Category, type SectionRecord = typeof portfolioSection.$inferSelect;
| "id" type AssetRecord = typeof portfolioAsset.$inferSelect;
| "slug" type ProjectRecord = typeof portfolioProject.$inferSelect;
| "nameAr"
| "nameEn"
| "nameDe"
| "descriptionAr"
| "descriptionEn"
| "descriptionDe"
| "sortOrder"
| "isActive"
>;
type SectionRecord = Pick<
PortfolioSection,
| "id"
| "type"
| "titleAr"
| "titleEn"
| "titleDe"
| "bodyAr"
| "bodyEn"
| "bodyDe"
| "imagePath"
| "linkUrl"
| "sortOrder"
>;
type AssetRecord = Pick<
PortfolioAsset,
"id" | "kind" | "filePath" | "altAr" | "altEn" | "altDe" | "sortOrder"
>;
type ProjectRecord = Pick<
PortfolioProject,
| "id"
| "slug"
| "viewMode"
| "titleAr"
| "titleEn"
| "titleDe"
| "summaryAr"
| "summaryEn"
| "summaryDe"
| "clientName"
| "projectYear"
| "serviceLabelAr"
| "serviceLabelEn"
| "serviceLabelDe"
| "previewUrl"
| "coverImagePath"
| "isFeatured"
| "isPublished"
| "publishedAt"
| "sortOrder"
>;
export type LocalizedContent = { export type LocalizedContent = {
ar: string; ar: string;
@@ -238,35 +189,31 @@ export function getLocalizedValue(
export async function getAdminPortfolioCategories() { export async function getAdminPortfolioCategories() {
const categories = await db.query.category.findMany({ const categories = await db.query.category.findMany({
with: { orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
projects: { with: { projects: { columns: { id: true } } },
columns: { id: true },
},
},
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
}); });
return categories.map((record) => ({ return categories.map((category) => ({
...mapCategory(record), ...mapCategory(category),
projectCount: record.projects.length, projectCount: category.projects.length,
})); }));
} }
export async function getActivePortfolioCategories() { export async function getActivePortfolioCategories() {
const categories = await db.query.category.findMany({ const categories = await db.query.category.findMany({
where: eq(category.isActive, true), where: eq(categoryTable.isActive, true),
orderBy: [asc(category.sortOrder), asc(category.createdAt)], orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
}); });
return categories.map(mapCategory); return categories.map(mapCategory);
} }
export async function getActivePortfolioCategoryBySlug(slug: string) { export async function getActivePortfolioCategoryBySlug(slug: string) {
const record = await db.query.category.findFirst({ const category = await db.query.category.findFirst({
where: and(eq(category.slug, slug), eq(category.isActive, true)), where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)),
}); });
return record ? mapCategory(record) : null; return category ? mapCategory(category) : null;
} }
export async function getAdminPortfolioProjects(filters?: { export async function getAdminPortfolioProjects(filters?: {
@@ -274,24 +221,20 @@ export async function getAdminPortfolioProjects(filters?: {
status?: "all" | "draft" | "published"; status?: "all" | "draft" | "published";
}) { }) {
const conditions = [ const conditions = [
filters?.categoryId ? eq(portfolioProject.categoryId, filters.categoryId) : undefined, ...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []),
filters?.status === "draft" ...(filters?.status === "draft"
? eq(portfolioProject.isPublished, false) ? [eq(portfolioProject.isPublished, false)]
: filters?.status === "published" : filters?.status === "published"
? eq(portfolioProject.isPublished, true) ? [eq(portfolioProject.isPublished, true)]
: undefined, : []),
].filter(Boolean); ];
const projects = await db.query.portfolioProject.findMany({ const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined, where: conditions.length ? and(...conditions) : undefined,
with: { with: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)], orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
}); });
@@ -304,12 +247,8 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
where: eq(portfolioProject.isPublished, true), where: eq(portfolioProject.isPublished, true),
with: { with: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
orderBy: [ orderBy: [
asc(portfolioProject.sortOrder), asc(portfolioProject.sortOrder),
@@ -318,6 +257,8 @@ export async function getPublishedPortfolioProjects(filters?: { categorySlug?: s
], ],
}); });
// Prisma filtered on the related category (active + optional slug); the
// relational query filters the main table only, so narrow here.
return projects return projects
.filter( .filter(
(project) => (project) =>
@@ -332,12 +273,8 @@ export const getPublishedPortfolioProjectBySlug = cache(async function (slug: st
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)), where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: { with: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
}); });
@@ -353,12 +290,8 @@ export async function getAdminPortfolioProjectById(id: string) {
where: eq(portfolioProject.id, id), where: eq(portfolioProject.id, id),
with: { with: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
}, },
}); });
+84 -8
View File
@@ -21,16 +21,10 @@
} }
}, },
"comingSoon": { "comingSoon": {
"badge": "تحديث مهني",
"kicker": "الموقع قيد إعادة البناء",
"titleLineOne": "الموقع", "titleLineOne": "الموقع",
"titleLineTwo": "قيد التطوير", "titleLineTwo": "قيد التطوير",
"titleLineThree": "وسيعود قريباً", "titleLineThree": "وسيعود قريباً",
"description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.", "description": "أعيد بناء الموقع ليعرض الخدمات، الأعمال المختارة، وطريقة التعاون بشكل مباشر ومنظم.",
"primaryCta": "ابدأ مشروعاً",
"secondaryCta": "العودة للرئيسية",
"status": "قيد التطوير · يعود قريباً",
"countdownLabel": "الإطلاق خلال",
"unitDays": "أيام", "unitDays": "أيام",
"unitHours": "ساعات", "unitHours": "ساعات",
"unitMinutes": "دقائق", "unitMinutes": "دقائق",
@@ -270,9 +264,91 @@
}, },
"aboutPage": { "aboutPage": {
"title": "من أنا", "title": "من أنا",
"description": "نظرة مركزة على دراستي، دوري الحالي، ونوع شغل الواجهات الذي أقدمه.", "description": "أنا مطور Full-Stack ومصمم جرافيك مقيم في برلين. بتنقل من الفكرة للكود - هوية بصرية وتصميم واجهات والهندسة يلي بتطلعهم عالنور.",
"heroEyebrow": "من أنا", "heroEyebrow": "من أنا",
"placeholder": "محتوى صفحة من أنا سينضاف هون قريباً." "story": {
"eyebrow": "مين أنا",
"title": "بابني منتجات وبصمم كيف بتبين وكيف حاسس فيها المستخدم.",
"paragraphs": [
"أغلب المطورين بسلموا التصميم لغيرهم، وأغلب المصممين بسلموا الكود لغيرهم. أنا بعمل الاثنين - يعني فجوة أقل بين شكل المنتج وطريقة اشتغاله فعلياً.",
"خلفيتي بتغطي الهوية البصرية والتصميم بقد ما بتغطي TypeScript وأنظمة Backend، فبصمم وأنا حاسب حساب التنفيذ، وببني وأنا محافظ على الحس البصري."
]
},
"capabilities": {
"eyebrow": "شو بعمل",
"title": "تطوير وتصميم، بشخص واحد.",
"description": "اختصاصين، جهة تواصل وحدة - من الهوية البصرية لحتى الكود الجاهز للإنتاج.",
"items": [
{
"title": "تطوير الواجهات",
"description": "واجهات Next.js وReact وTypeScript مبنية للسرعة والوضوح وقابلية الصيانة على المدى الطويل."
},
{
"title": "Backend والبيانات",
"description": "Node.js وPostgreSQL وPrisma - أنظمة موثوقة شغالة وراء الواجهة."
},
{
"title": "تصميم بصري وهوية",
"description": "هوية وتصميم UI وأنظمة تخطيط مصممة بـ Figma وIllustrator."
},
{
"title": "أتمتة وأدوات",
"description": "سير عمل داخلي وأدوات بتشيل الشغل اليدوي المتكرر."
}
]
},
"tools": {
"eyebrow": "الأدوات",
"title": "الأدوات اليومية",
"items": [
"Figma",
"Adobe Illustrator",
"Photoshop",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Docker"
]
},
"process": {
"eyebrow": "كيف بشتغل",
"title": "آلية عمل بتخلي التصميم والتطوير ماشيين مع بعض.",
"description": "أربع خطوات، بلا فجوة بين شكل المنتج وطريقة بناءه.",
"steps": [
{
"title": "Discover",
"description": "بفهم المشكلة والجمهور والقيود قبل ما افتح أي أداة."
},
{
"title": "Design",
"description": "الاتجاه البصري وبنية الـ UX بيتحددوا مع بعض، مش الواحد بعد التاني."
},
{
"title": "Build",
"description": "بنفذ بنفس العناية يلي كانت بالتصميم - كود نظيف وجاهز للإنتاج."
},
{
"title": "Refine",
"description": "بصقل التفاصيل، بجرب النتيجة، وبطلق المنتج بثقة."
}
]
},
"personalNote": {
"eyebrow": "برا الشغل",
"text": "مقيم ببرلين. لما ما كون مصمم أو مبرمج، غالباً عم بحاول افهم الفرق بين الشغلتين."
},
"contactCta": {
"eyebrow": "ابدأ الحديث",
"title": "عندك مشروع محتاج تصميم وكود مع بعض؟",
"description": "هات الملخص أو السكتش الأولي أو بس المشكلة. أقدر أساعد أشكل الاتجاه وأصمم النظام وأبنيه.",
"contactCta": "تواصل معي",
"githubCta": "GitHub",
"emailLabel": "البريد الإلكتروني",
"emailValue": "hello@moh-sass.dev",
"availabilityLabel": "التوفر",
"availabilityValue": "مفتوح لشغل منتجات مركز",
"githubHref": "https://github.com/mohfarawati"
}
}, },
"contactPage": { "contactPage": {
"title": "تواصل", "title": "تواصل",
+84 -8
View File
@@ -21,16 +21,10 @@
} }
}, },
"comingSoon": { "comingSoon": {
"badge": "Professionelles Update",
"kicker": "Die Website wird neu aufgebaut",
"titleLineOne": "Website", "titleLineOne": "Website",
"titleLineTwo": "in Bearbeitung", "titleLineTwo": "in Bearbeitung",
"titleLineThree": "bald wieder online", "titleLineThree": "bald wieder online",
"description": "Ich baue die Website neu auf, damit Leistungen, ausgewählte Arbeiten und Zusammenarbeit klarer, direkter und ohne Wiederholungen sichtbar werden.", "description": "Ich baue die Website neu auf, damit Leistungen, ausgewählte Arbeiten und Zusammenarbeit klarer, direkter und ohne Wiederholungen sichtbar werden.",
"primaryCta": "Projekt starten",
"secondaryCta": "Zur Startseite",
"status": "In Entwicklung · bald zurück",
"countdownLabel": "Start in",
"unitDays": "Tage", "unitDays": "Tage",
"unitHours": "Stunden", "unitHours": "Stunden",
"unitMinutes": "Minuten", "unitMinutes": "Minuten",
@@ -270,9 +264,91 @@
}, },
"aboutPage": { "aboutPage": {
"title": "Über mich", "title": "Über mich",
"description": "Ein fokussierter Überblick über meine Ausbildung, meine aktuelle Rolle und die Art von Frontend Arbeit, die ich liefere.", "description": "Ich bin Full-Stack-Entwickler und Grafikdesigner mit Sitz in Berlin. Ich bewege mich von der Idee bis zum Code — visuelle Identität, Interface-Design und die Technik, die es live bringt.",
"heroEyebrow": "Über mich", "heroEyebrow": "Über mich",
"placeholder": "Der Inhalt der About Seite kommt bald hier hin." "story": {
"eyebrow": "Wer ich bin",
"title": "Ich baue Produkte und gestalte, wie sie aussehen und sich anfühlen.",
"paragraphs": [
"Die meisten Entwickler geben Design ab. Die meisten Designer geben Code ab. Ich mache beides — dadurch gibt es weniger Lücken zwischen dem, wie ein Produkt aussieht, und dem, wie es tatsächlich funktioniert.",
"Mein Hintergrund reicht genauso weit in Marken- und visuelles Design wie in TypeScript und Backend-Systeme, deshalb gestalte ich mit Blick auf die Umsetzung und baue mit erhaltenem visuellem Urteilsvermögen."
]
},
"capabilities": {
"eyebrow": "Was ich mache",
"title": "Entwicklung und Design, aus einer Hand.",
"description": "Zwei Disziplinen, ein Ansprechpartner — von der visuellen Identität bis zum produktionsreifen Code.",
"items": [
{
"title": "Frontend-Entwicklung",
"description": "Next.js-, React- und TypeScript-Interfaces, gebaut für Geschwindigkeit, Klarheit und langfristige Wartbarkeit."
},
{
"title": "Backend & Daten",
"description": "Node.js, PostgreSQL und Prisma — verlässliche Systeme hinter dem Interface."
},
{
"title": "Visuelles & Marken-Design",
"description": "Identität, UI-Design und Layout-Systeme, gestaltet in Figma und Illustrator."
},
{
"title": "Automatisierung & Tooling",
"description": "Interne Workflows und Tools, die manuelle, sich wiederholende Arbeit entfernen."
}
]
},
"tools": {
"eyebrow": "Tools",
"title": "Täglicher Stack",
"items": [
"Figma",
"Adobe Illustrator",
"Photoshop",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Docker"
]
},
"process": {
"eyebrow": "Wie ich arbeite",
"title": "Ein Prozess, der Design und Entwicklung im Takt hält.",
"description": "Vier Schritte, keine Übergabe-Lücke zwischen Aussehen und Umsetzung.",
"steps": [
{
"title": "Discover",
"description": "Problem, Zielgruppe und Rahmenbedingungen klären, bevor überhaupt ein Tool geöffnet wird."
},
{
"title": "Design",
"description": "Visuelle Richtung und UX-Struktur werden gemeinsam erarbeitet, nicht nacheinander."
},
{
"title": "Build",
"description": "Umsetzung mit derselben Sorgfalt wie im Design — sauberer, produktionsreifer Code."
},
{
"title": "Refine",
"description": "Details verfeinern, das Ergebnis testen und mit Zuversicht ausliefern."
}
]
},
"personalNote": {
"eyebrow": "Feierabend",
"text": "Ansässig in Berlin. Wenn ich nicht gerade designe oder code, untersuche ich wahrscheinlich den Unterschied zwischen beidem."
},
"contactCta": {
"eyebrow": "Gespräch starten",
"title": "Ein Projekt, das Design und Code gleichzeitig braucht?",
"description": "Bring das Briefing, die grobe Skizze oder einfach das Problem mit. Ich kann die Richtung formen, das System entwerfen und es bauen.",
"contactCta": "Kontakt aufnehmen",
"githubCta": "GitHub",
"emailLabel": "E-Mail",
"emailValue": "hello@moh-sass.dev",
"availabilityLabel": "Verfügbarkeit",
"availabilityValue": "Offen für fokussierte Produktarbeit",
"githubHref": "https://github.com/mohfarawati"
}
}, },
"contactPage": { "contactPage": {
"title": "Kontakt", "title": "Kontakt",
+84 -8
View File
@@ -21,16 +21,10 @@
} }
}, },
"comingSoon": { "comingSoon": {
"badge": "Professional update",
"kicker": "The site is being rebuilt",
"titleLineOne": "Website", "titleLineOne": "Website",
"titleLineTwo": "under development", "titleLineTwo": "under development",
"titleLineThree": "returning soon", "titleLineThree": "returning soon",
"description": "I am rebuilding the site to present services, selected work, and collaboration details with sharper structure and less noise.", "description": "I am rebuilding the site to present services, selected work, and collaboration details with sharper structure and less noise.",
"primaryCta": "Start a project",
"secondaryCta": "Back to homepage",
"status": "In development · back soon",
"countdownLabel": "Launching in",
"unitDays": "Days", "unitDays": "Days",
"unitHours": "Hours", "unitHours": "Hours",
"unitMinutes": "Minutes", "unitMinutes": "Minutes",
@@ -270,9 +264,91 @@
}, },
"aboutPage": { "aboutPage": {
"title": "About", "title": "About",
"description": "A focused overview of my education, current role, and the kind of frontend work I deliver.", "description": "I'm a full-stack developer and graphic designer based in Berlin. I move from concept to code — visual identity, interface design, and the engineering that ships it.",
"heroEyebrow": "About", "heroEyebrow": "About",
"placeholder": "About page content will be added here soon." "story": {
"eyebrow": "Who I am",
"title": "I build products and design the way they look and feel.",
"paragraphs": [
"Most developers hand off design. Most designers hand off code. I do both — which means fewer gaps between how a product looks and how it actually works.",
"My background spans brand and visual design as much as it spans TypeScript and backend systems, so I design with implementation in mind and build with visual judgment intact."
]
},
"capabilities": {
"eyebrow": "What I do",
"title": "Development and design, handled by the same person.",
"description": "Two disciplines, one point of contact — from visual identity to production code.",
"items": [
{
"title": "Frontend Development",
"description": "Next.js, React, and TypeScript interfaces built for speed, clarity, and long-term maintainability."
},
{
"title": "Backend & Data",
"description": "Node.js, PostgreSQL, and Prisma — reliable systems working behind the interface."
},
{
"title": "Visual & Brand Design",
"description": "Identity, UI design, and layout systems crafted in Figma and Illustrator."
},
{
"title": "Automation & Tooling",
"description": "Internal workflows and tools that remove manual, repetitive work."
}
]
},
"tools": {
"eyebrow": "Tools",
"title": "Daily stack",
"items": [
"Figma",
"Adobe Illustrator",
"Photoshop",
"Next.js",
"TypeScript",
"Tailwind CSS",
"Docker"
]
},
"process": {
"eyebrow": "How I work",
"title": "A process that keeps design and engineering in sync.",
"description": "Four steps, no handoff gap between how it looks and how it's built.",
"steps": [
{
"title": "Discover",
"description": "Understand the problem, audience, and constraints before opening any tool."
},
{
"title": "Design",
"description": "Visual direction and UX structure worked out together, not in sequence."
},
{
"title": "Build",
"description": "Implement with the same care the design had — clean, production-ready code."
},
{
"title": "Refine",
"description": "Polish detail, test the result, and ship with confidence."
}
]
},
"personalNote": {
"eyebrow": "Off the clock",
"text": "Based in Berlin. When I'm not designing or coding, I'm probably studying the difference between the two."
},
"contactCta": {
"eyebrow": "Start the conversation",
"title": "Have a project that needs both design and code?",
"description": "Bring the brief, the rough sketch, or just the problem. I can shape the direction, design the system, and build it.",
"contactCta": "Contact me",
"githubCta": "GitHub",
"emailLabel": "Email",
"emailValue": "hello@moh-sass.dev",
"availabilityLabel": "Availability",
"availabilityValue": "Open for focused product work",
"githubHref": "https://github.com/mohfarawati"
}
}, },
"contactPage": { "contactPage": {
"title": "Contact", "title": "Contact",
+1988 -35
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -3,19 +3,18 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev -p 3014",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio"
"db:seed": "tsx lib/db/seed.ts"
}, },
"dependencies": { "dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -25,7 +24,7 @@
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.45.2",
"framer-motion": "^12.35.0", "framer-motion": "^12.35.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@@ -33,7 +32,8 @@
"next-intl": "^4.8.3", "next-intl": "^4.8.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"postgres": "^3.4.5", "pg": "^8.20.0",
"postgres": "^3.4.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
@@ -41,16 +41,22 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "^0.5.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.10",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jsdom": "^30.0.1",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "^5", "typescript": "^5",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
+605
View File
@@ -0,0 +1,605 @@
const { PrismaPg } = require("@prisma/adapter-pg");
const { PrismaClient } = require("@prisma/client");
const { Pool } = require("pg");
const connectionString =
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const pool = new Pool({ connectionString });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
async function upsertMediaAsset(input) {
const existing = await prisma.mediaAsset.findFirst({
where: {
label: input.label,
url: input.url,
},
});
if (existing) {
return prisma.mediaAsset.update({
where: { id: existing.id },
data: input,
});
}
return prisma.mediaAsset.create({
data: input,
});
}
async function syncProjectContent(projectId, sections, assets) {
await prisma.portfolioSection.deleteMany({
where: { projectId },
});
await prisma.portfolioAsset.deleteMany({
where: { projectId },
});
const createdSections = [];
for (const section of sections) {
const createdSection = await prisma.portfolioSection.create({
data: {
projectId,
...section,
},
});
createdSections.push(createdSection);
}
const createdAssets = [];
for (const asset of assets) {
const createdAsset = await prisma.portfolioAsset.create({
data: {
projectId,
...asset,
},
});
createdAssets.push(createdAsset);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(projectId, mediaMap) {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
});
const usages = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await prisma.mediaUsage.createMany({ data: usages });
}
}
async function main() {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
},
});
await prisma.portfolioSection.deleteMany();
await prisma.portfolioAsset.deleteMany();
await prisma.portfolioProject.deleteMany();
await prisma.category.deleteMany();
await prisma.appConfig.upsert({
where: { key: "siteName" },
update: { value: "moh-sass" },
create: { key: "siteName", value: "moh-sass" },
});
await prisma.appConfig.upsert({
where: { key: "site_settings" },
update: {
value: JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
}),
},
create: {
key: "site_settings",
value: JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
}),
},
});
const brandCategory = await prisma.category.upsert({
where: { slug: "branding" },
update: {
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
},
create: {
slug: "branding",
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
},
});
const webCategory = await prisma.category.upsert({
where: { slug: "web-experiences" },
update: {
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
},
create: {
slug: "web-experiences",
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
},
});
const commerceCategory = await prisma.category.upsert({
where: { slug: "commerce" },
update: {
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
create: {
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID",
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY",
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK",
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE",
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY",
titleAr: "إعادة تصميم الهوية",
titleEn: "Brand Redesign",
titleDe: "Brand Redesign",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
clientName: "Studio Client",
projectYear: 2025,
serviceLabelAr: "هوية بصرية",
serviceLabelEn: "Brand Identity",
serviceLabelDe: "Brand Identity",
previewUrl: "https://example.com/preview/brand-redesign",
coverImagePath: caseStudyCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "التحدي",
titleEn: "Challenge",
titleDe: "Herausforderung",
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "RICH_TEXT",
titleAr: "الحل",
titleEn: "Solution",
titleDe: "Loesung",
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
bodyEn: "A clearer visual system with scalable usage rules was created.",
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
for (const projectConfig of projects) {
const project = await prisma.portfolioProject.upsert({
where: { slug: projectConfig.slug },
update: {
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
create: {
slug: projectConfig.slug,
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
});
const created = await syncProjectContent(project.id, projectConfig.sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})), projectConfig.assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})));
await syncProjectMediaUsages(project.id, {
coverAssetId: projectConfig.coverAssetId,
sectionUsages: created.createdSections
.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: projectConfig.sections[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
assetUsages: created.createdAssets
.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: projectConfig.assets[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
});
}
}
main()
.catch((error) => {
console.error("Seed failed:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Runs the test suite with Vitest's JSON reporter (plus the normal live output)
* and prints ONE compact, copy-pasteable summary at the end — pass/fail counts
* and every failing test with a one-line reason. Paste the block between the
* ===== markers to hand off the full picture without a wall of logs.
*
* Exits with the suite's own status, so `make test` / the pre-push hook still
* block on failure. This file is the same across all my projects; only RUNS
* differs (a project with several vitest configs lists one entry per config).
*/
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// The npm script(s) that together make up "the whole suite". Run through npm (not
// `npx vitest` directly) so nested tooling in a globalSetup — e.g. `npx drizzle-kit
// migrate` — resolves with the right PATH. A project split across several vitest
// configs lists one entry per config.
const RUNS = [{ label: "all", args: ["test"] }];
const reporterArgs = (out) => ["--", "--reporter=default", "--reporter=json", `--outputFile.json=${out}`];
const projectName = (() => {
try {
return JSON.parse(readFileSync("package.json", "utf8")).name ?? "project";
} catch {
return "project";
}
})();
const workDir = mkdtempSync(join(tmpdir(), "test-summary-"));
const started = Date.now();
let status = 0;
const reports = [];
for (const run of RUNS) {
const out = join(workDir, `${run.label}.json`);
const res = spawnSync("npm", ["run", ...run.args, ...reporterArgs(out)], {
stdio: "inherit",
shell: process.platform === "win32",
});
if (res.status !== 0) status = res.status ?? 1;
try {
reports.push(JSON.parse(readFileSync(out, "utf8")));
} catch {
/* a crash before the report was written — status already non-zero */
}
}
let total = 0;
let passed = 0;
let failed = 0;
let skipped = 0;
let files = 0;
let filesFailed = 0;
const failures = [];
const cwd = process.cwd();
for (const r of reports) {
total += r.numTotalTests ?? 0;
passed += r.numPassedTests ?? 0;
failed += r.numFailedTests ?? 0;
skipped += (r.numPendingTests ?? 0) + (r.numTodoTests ?? 0);
for (const tr of r.testResults ?? []) {
files += 1;
const fileFailed = tr.status === "failed" || (tr.assertionResults ?? []).some((a) => a.status === "failed");
if (fileFailed) filesFailed += 1;
for (const a of tr.assertionResults ?? []) {
if (a.status !== "failed") continue;
const file = (tr.name ?? "").replace(`${cwd}/`, "");
const name = a.fullName || [...(a.ancestorTitles ?? []), a.title].filter(Boolean).join(" ");
const reason =
(a.failureMessages ?? [])
.join("\n")
.split("\n")
.map((l) => l.trim())
.find((l) => l && !l.startsWith("at ")) ?? "";
failures.push({ file, name, reason: reason.slice(0, 200) });
}
}
}
const elapsed = ((Date.now() - started) / 1000).toFixed(1);
const ok = status === 0 && failed === 0 && reports.length > 0;
const L = "=".repeat(38);
const lines = [];
lines.push(L);
lines.push(`TEST SUMMARY — ${projectName} (${new Date().toISOString().slice(0, 16).replace("T", " ")})`);
if (reports.length === 0) {
lines.push("❌ CRASH — the test run failed before producing a report (see output above).");
} else {
lines.push(
`${ok ? "✅ PASS" : "❌ FAIL"}${passed}/${total} tests passed` +
(failed ? `, ${failed} failed` : "") +
(skipped ? `, ${skipped} skipped` : "") +
` · ${files} files (${filesFailed} failed) · ${elapsed}s`,
);
if (failures.length) {
lines.push("");
lines.push(`FAILED (${failures.length}):`);
for (const f of failures) {
lines.push(`${f.file} ${f.name}`);
if (f.reason) lines.push(` ${f.reason}`);
}
}
}
lines.push(L);
console.log(`\n${lines.join("\n")}`);
rmSync(workDir, { recursive: true, force: true });
process.exit(status);
+154
View File
@@ -0,0 +1,154 @@
# Test Plan — mohfarawati.de
Comprehensive automated test coverage for the multilingual Next.js portfolio + admin
workspace. Focus: **code correctness**. Scope excludes Playwright / browser E2E (per request).
## 1. Goals & principles
- Cover every application module: pure helpers, validation/schemas, data layer (Prisma),
server actions, API routes, middleware, forms, components, and architecture rules.
- **Do not change production behaviour.** Tests observe the code as-is. If a test reveals a
real bug or an architecture-rule violation, it is flagged for the owner — production code is
not changed without approval.
- Deterministic and self-contained: no external network, no reliance on a running app server.
- Real Postgres for the data layer (not mocks). See §3.
## 2. Test taxonomy & runner layout
Vitest with three **projects** (isolated environments), selected by file location:
| Project | Env | Location | Parallel | Purpose |
|---|---|---|---|---|
| `unit` | node | `tests/unit/**` | yes | Pure functions, schemas, formatting, mapping logic |
| `integration` | node | `tests/integration/**` | serial | Prisma data layer, server actions, API routes, middleware |
| `component` | jsdom | `tests/component/**` | yes | React components & forms (RTL) |
Legacy flat `tests/*.test.ts` files are folded into the new structure (kept passing).
### Tooling added (dev-only)
- `@testing-library/react`, `@testing-library/dom`, `@testing-library/jest-dom`,
`@testing-library/user-event`, `jsdom`, `@vitejs/plugin-react` — component tests.
- `@electric-sql/pglite`, `pglite-prisma-adapter` — an **embedded real Postgres**
(Postgres compiled to WASM) that runs the project's actual migrations and Prisma queries
in-process, via a Prisma driver adapter.
### Database strategy (real Postgres)
`tests/helpers/integration-setup.ts` + `tests/helpers/global-db-setup.ts`:
- If `TEST_DATABASE_URL` is set → the real `lib/prisma` singleton is used unchanged, pointed
at that Postgres (e.g. the Docker instance). The global setup resets the schema and applies
every `prisma/migrations/*/migration.sql` once before the run.
- Otherwise → `lib/prisma` is mocked (test-only) with a Prisma client backed by an in-process
PGlite database. Each worker gets its own isolated database with the migrations applied —
real Postgres semantics, no external server, no shared-state races. **Production code is
never modified.**
- `resetDb()` (TRUNCATE all tables, restart identities) runs in `beforeEach`.
- The integration project runs **serially** (`fileParallelism: false`); each file gets a fresh
database connection.
### Next.js runtime mocks (integration)
Server actions/middleware depend on the Next runtime. `tests/helpers/next-mocks.ts` provides:
- `next/navigation``redirect()` throws a catchable `NEXT_REDIRECT` carrying the URL.
- `next/dist/client/components/redirect-error``isRedirectError()` recognises the above.
- `next/cache``revalidatePath()` spy (no-op, asserted).
- `next/headers` → controllable `cookies()` / `headers()` stores.
- `@/lib/admin-auth` `isAdminAuthenticated` → toggled per test (auth guard tests).
- `nodemailer` → captured transport (no real SMTP).
## 3. Coverage matrix
### 3.1 Unit — pure lib
| Module | Cases |
|---|---|
| `admin-routing` | host resolution (forwarded/comma/port), `isAdminHost`, `hasDedicatedAdminHost`, legacy/dev/internal path predicates, `toInternalAdminPath`, `fromDevelopmentAdminPath`, `getAdminAppPath` dev vs prod, `buildAdminUrl`/`buildSiteUrl`, env overrides, normalization edge cases |
| `admin-feedback` | `withFlash` (success/error/both/none, encoding), `readFlash` |
| `admin-navigation` | tree shape, `active`/`expanded` flags for each section, portfolio child mapping, href de-dup filter |
| `form-data` | `isCheckedFormValue` truthy/falsey set |
| `locale` | `isSupportedLocale`, `resolveLocale`, `getDirection` (rtl for ar), `stripLocalePrefix`, `getLocalizedPath(WithDefault)` incl. prefix stripping/rebuilding |
| `utils` | `cn` merge/dedupe/conditional |
| `site-theme` | `buildSiteThemeTokens` (hex→hsl, derive dark/secondary, clamps), `buildSiteThemeStyleText` structure, invalid hex fallback |
| `marquee-settings` | defaults, `parseMarqueeSettingsValue` (invalid json, partial, trims), `syncMarqueeSettingsToGermanSource`, `splitMarqueeRowItems` (newline/comma/blank) |
| `site-settings` | `normalizeSiteDefaultLocale`, `normalizeSitePrimaryColor`, `buildDefaultSiteSettings`, `parseSiteSettingsValue` (merge, invalid json, legacy title, invalid locale/color) |
| `site-icons` | `buildSiteIconUrls` (version, relative vs absolute favicon url, name fallback), `buildSiteIconResponse` transparent fallback for non-managed paths |
| `media-storage` (pure) | `sanitizeBaseName`, `getExtensionForMimeType` (all mimes + unknown), `isManagedMediaFilePath`, `resolveMediaUploadPath` (root confinement + traversal guard) |
| `media-validation` | `mediaFieldInputSchema`: library needs assetId, external needs url, url format rule, upload mode, kind enum, trimming |
| `media-service` (pure) | `inferMediaKindFromMimeType`, `inferMediaKindFromFileName`, `getKindFromUploadFile` |
| `portfolio` (pure) | `resolvePortfolioProjectViewMode`, `getLocalizedValue` (direct/fallback/any) |
| `portfolio-form-progress` | slug/year/sortOrder validators, section readiness per type, asset readiness, wizard progress, first incomplete step |
| `portfolio-validation` | category/section/asset/project schemas: required fields, slug regex, coercions, view modes, section superRefine per type, media refinements, url rules |
| `metadata` | `applyTitleTemplateFn`, `buildLocaleAlternates`, `buildAppMetadataFromConfig`, `buildLocalizedMetadataFromConfig` (title template skip, description fallback, og/twitter, icons) |
| `mail` | `createSmtpTransport` (required host/user/pass errors, port/secure), `sendMail` (from with/without name), `sendContactMessage` (recipient + fallback, body fields), `sendTestEmail` (recipient fallback, transport reject) |
### 3.2 Integration — data layer (real DB)
| Module | Cases |
|---|---|
| `app-config` | maintenance get/set, site settings get (fallback name from `siteName` key) / update roundtrip, mail settings get/update, marquee get/update (german sync), `getSiteSettingsMediaBindings` (mediaUsage → bindings per field) |
| `media` | create asset, get by id (+usages), list, `getMediaOptions` kind filter, `replaceEntityMediaUsages` (transactional replace, unique constraint), `deleteEntityMediaUsages`, `getPortfolioMediaBindings` routing by usageType, `countMediaUsageReferences` |
| `portfolio` (queries) | admin categories (+project counts), active categories/by-slug, admin projects (status/category filters + ordering), published projects/by-slug, by-id with media bindings, localized mapping, `onDelete` Restrict/Cascade behaviour |
| `media-service.resolveMediaSelection` | library (found/missing), external (creates asset, filename from url), missing+required error, not-required empty |
| `admin-auth` (lockout) | `registerFailedAdminAttempt` increments & locks at threshold, `getAdminLockState`, `resetAdminFailedAttempts`, IP hashing via mocked headers; token `isPasswordValid`/verify with env |
### 3.3 Integration — API routes & middleware
| Target | Cases |
|---|---|
| `GET /api/health` | 200 + `database: up`; 503 + `database: down` when query throws |
| `GET /api/site/default-locale` | returns runtime `defaultLocale` + `maintenanceEnabled`, no-store header |
| `proxy` (middleware) | runtime default-locale passthrough, safe fallback on fetch failure, maintenance redirect, `SITE_RUNTIME_ORIGIN`, admin host rewrite → internal, dev `/root` handling, legacy 404 in prod, internal path 404 for non-admin in prod, basic-auth challenge/valid |
### 3.4 Integration — server actions
| Action file | Cases |
|---|---|
| `contact/actions` | valid → sends mail + redirect `/success`; invalid (short name/bad email/short message) → redirect with error; locale resolution |
| `maintenance/actions` | unauth → redirect to admin root; enable/disable toggles config + revalidates + success flash |
| `marquee/actions` | unauth guard; empty german rows throw per-row error → error flash; valid → saves (german-synced) + success flash |
| `smtp/actions` | `parseMailSettingsFormData` (port parse error, password retention when blank), save → success; `sendTestEmailAction` success + failure |
| `site-settings/actions` | brand save (primary color normalize, media selection + usage wiring, cleanup on error), localization save (siteName required, title template must contain `{pageTitle}`), `parseJsonObject` guard |
| `media/actions` | create (kind image/document, missing file error), delete (not found, in-use guard, managed-file removal) |
| `portfolio/actions` | `upsertCategoryAction` create/update + P2002 unique message; `deleteCategoryAction` blocks when projects exist; `saveProjectAction` create + update, sections/assets replace, `publishedAt` first-publish logic, media usage wiring, validation + error cleanup of created media; `deleteProjectAction` not-found + cascade + usage cleanup |
### 3.5 Component (jsdom + RTL)
Global mocks: `framer-motion`, `gsap`, `next/link`, `next/image`, `next-intl`, `next/navigation`.
| Component | Cases |
|---|---|
| `ui/badge` | variant classes, custom className merge, passthrough props |
| `ui/input` | renders, forwardRef, type/placeholder/disabled, className merge |
| `ui/textarea`, `ui/label`, `ui/card`*, `ui/app-card`, `ui/separator`, `ui/table`* | render, props, ref, composition |
| `admin/admin-flash` | null when empty, success `role=status`, error `role=alert`, both, className |
| `admin/marquee-settings-form` | renders 4 rows, default values, submit wiring to action |
| `site/portfolio-category-filter` | "all" link + per-category links, active state, localized labels, hrefs |
| `dashboard/dashboard-card`, `dashboard/stats-card` | presentational render, props |
| `layout/container`, `layout/hero-badge`, `home/section-heading`, `home/bento-card` | presentational render, children, className |
(*multi-part primitives tested for sub-component composition.)
### 3.6 Architecture-rule tests (`tests/integration/architecture`)
Enforced from `CLAUDE.md`:
1. **No Prisma in client components** — no file containing `"use client"` imports `lib/prisma`.
2. **Business logic out of UI** — components don't import server-only data modules directly
(allow-list of pure `lib/*` view/format helpers).
3. **Server actions are guarded** — every exported action in `app/**/actions.ts` calls an auth
guard (`ensureAdmin`/`requireAdminAuth`) except the public contact action.
4. **`"use server"` directive** — every `actions.ts` begins with `"use server"`.
5. **`lib/*` does not import from `app/`** — dependency direction.
6. **Admin mirror parity** — every `page.tsx` under `app/_admin/**` has matching re-export
stubs under `app/root/**` and `app/admin-internal/**` pointing back to `_admin`.
7. **No browser storage in components** — no `localStorage`/`sessionStorage` usage.
## 4. Deliverables & running
```bash
npm test # all projects
npx vitest run --project unit
npx vitest run --project integration
npx vitest run --project component
TEST_DATABASE_URL=postgres://… # optional: run integration against real Postgres
```
Findings (real bugs / rule violations) are reported to the owner; production code is only
changed after approval.
+11
View File
@@ -0,0 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "@/components/ui/badge";
describe("component harness smoke test", () => {
it("renders a component into jsdom", () => {
render(<Badge>Hello</Badge>);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AdminFlash } from "@/components/admin/admin-flash";
describe("AdminFlash", () => {
it("renders nothing when there are no messages", () => {
const { container } = render(<AdminFlash />);
expect(container.firstChild).toBeNull();
});
it("renders a success message with a status role", () => {
render(<AdminFlash success="Saved." />);
const status = screen.getByRole("status");
expect(status).toHaveTextContent("Saved.");
expect(screen.queryByRole("alert")).toBeNull();
});
it("renders an error message with an alert role", () => {
render(<AdminFlash error="Failed." />);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Failed.");
expect(screen.queryByRole("status")).toBeNull();
});
it("renders both a success and error message together", () => {
render(<AdminFlash success="Yes" error="No" />);
expect(screen.getByRole("status")).toHaveTextContent("Yes");
expect(screen.getByRole("alert")).toHaveTextContent("No");
});
it("applies a custom className to the wrapper", () => {
const { container } = render(<AdminFlash success="Yes" className="mb-4" />);
expect(container.firstChild).toHaveClass("mb-4");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { Activity } from "lucide-react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DashboardCard } from "@/components/dashboard/dashboard-card";
import { StatsCard } from "@/components/dashboard/stats-card";
describe("StatsCard", () => {
it("renders the title and value", () => {
render(<StatsCard title="Visitors" value="1,234" icon={Activity} />);
expect(screen.getByText("Visitors")).toBeInTheDocument();
expect(screen.getByText("1,234")).toBeInTheDocument();
});
it("renders an optional description and footer", () => {
render(
<StatsCard title="T" value="V" description="Up 5%" footer={<span>footer</span>} />,
);
expect(screen.getByText("Up 5%")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
});
it("omits the icon container when no icon is provided", () => {
const { container } = render(<StatsCard title="T" value="V" />);
expect(container.querySelector("svg")).toBeNull();
});
});
describe("DashboardCard", () => {
it("passes its props through to a StatsCard", () => {
render(<DashboardCard title="Sales" value="42" description="today" icon={Activity} />);
expect(screen.getByText("Sales")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("today")).toBeInTheDocument();
});
});
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MarqueeSettingsForm } from "@/components/admin/marquee-settings-form";
import { buildDefaultMarqueeSettings } from "@/lib/marquee-settings";
describe("MarqueeSettingsForm", () => {
it("renders a textarea per row with german default values", () => {
const settings = buildDefaultMarqueeSettings();
settings.locales.de.row1 = "First row value";
render(<MarqueeSettingsForm action={vi.fn()} initialSettings={settings} />);
for (const name of ["row1-de", "row2-de", "row3-de", "row4-de"]) {
const field = document.querySelector(`textarea[name="${name}"]`);
expect(field).not.toBeNull();
}
expect((document.querySelector('textarea[name="row1-de"]') as HTMLTextAreaElement).value).toBe(
"First row value",
);
});
it("wires the server action onto the form and renders a submit button", () => {
const action = vi.fn();
render(<MarqueeSettingsForm action={action} initialSettings={buildDefaultMarqueeSettings()} />);
expect(document.querySelector("form#marquee-settings-form")).not.toBeNull();
expect(screen.getByRole("button", { name: /save marquee/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PortfolioCategoryFilter } from "@/components/site/portfolio-category-filter";
import type { PortfolioCategoryView } from "@/lib/portfolio";
function category(overrides: Partial<PortfolioCategoryView> = {}): PortfolioCategoryView {
return {
id: overrides.id ?? "c1",
slug: overrides.slug ?? "branding",
name: overrides.name ?? { ar: "الهوية", en: "Branding", de: "Branding" },
description: overrides.description ?? { ar: "", en: "", de: "" },
sortOrder: overrides.sortOrder ?? 0,
isActive: overrides.isActive ?? true,
};
}
describe("PortfolioCategoryFilter", () => {
const categories = [
category({ id: "c1", slug: "branding", name: { ar: "الهوية", en: "Branding", de: "Marke" } }),
category({ id: "c2", slug: "web", name: { ar: "ويب", en: "Web", de: "Web" } }),
];
it("renders the all-projects link and one link per category", () => {
render(
<PortfolioCategoryFilter
locale="en"
defaultLocale="de"
categories={categories}
allLabel="All"
/>,
);
const links = screen.getAllByRole("link");
expect(links).toHaveLength(3);
expect(screen.getByRole("link", { name: "All" })).toHaveAttribute("href", "/en/portfolio");
expect(screen.getByRole("link", { name: "Branding" })).toHaveAttribute(
"href",
"/en/portfolio/category/branding",
);
});
it("uses localized labels for the active locale", () => {
render(
<PortfolioCategoryFilter
locale="de"
defaultLocale="de"
categories={categories}
allLabel="Alle"
/>,
);
// German locale on the default locale -> unprefixed paths and German labels
expect(screen.getByRole("link", { name: "Marke" })).toHaveAttribute(
"href",
"/portfolio/category/branding",
);
expect(screen.getByRole("link", { name: "Alle" })).toHaveAttribute("href", "/portfolio");
});
it("highlights the active category", () => {
render(
<PortfolioCategoryFilter
locale="en"
defaultLocale="de"
categories={categories}
allLabel="All"
activeCategorySlug="web"
/>,
);
const active = screen.getByRole("link", { name: "Web" });
expect(active).toHaveClass("bg-primary");
});
});
+149
View File
@@ -0,0 +1,149 @@
import { createRef } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Container } from "@/components/layout/container";
import { HeroBadge } from "@/components/layout/hero-badge";
import { SectionHeading } from "@/components/home/section-heading";
describe("Badge", () => {
it("renders children and default variant classes", () => {
render(<Badge>New</Badge>);
const badge = screen.getByText("New");
expect(badge).toHaveClass("bg-primary");
});
it("applies a chosen variant and merges custom classNames", () => {
render(<Badge variant="success" className="custom-class">OK</Badge>);
const badge = screen.getByText("OK");
expect(badge).toHaveClass("bg-status-success-soft");
expect(badge).toHaveClass("custom-class");
});
it("passes through arbitrary props", () => {
render(<Badge data-testid="b" title="tip">X</Badge>);
expect(screen.getByTestId("b")).toHaveAttribute("title", "tip");
});
});
describe("Input", () => {
it("forwards the ref and renders attributes", () => {
const ref = createRef<HTMLInputElement>();
render(<Input ref={ref} type="email" placeholder="you@example.com" disabled />);
const input = screen.getByPlaceholderText("you@example.com");
expect(ref.current).toBe(input);
expect(input).toHaveAttribute("type", "email");
expect(input).toBeDisabled();
});
it("merges custom classes", () => {
render(<Input className="w-20" aria-label="field" />);
expect(screen.getByLabelText("field")).toHaveClass("w-20");
});
});
describe("Textarea", () => {
it("forwards the ref and renders a default value", () => {
const ref = createRef<HTMLTextAreaElement>();
render(<Textarea ref={ref} defaultValue="hello" aria-label="msg" />);
const textarea = screen.getByLabelText("msg");
expect(ref.current).toBe(textarea);
expect(textarea).toHaveValue("hello");
});
});
describe("Label", () => {
it("associates with a control via htmlFor", () => {
render(<Label htmlFor="name">Name</Label>);
expect(screen.getByText("Name")).toHaveAttribute("for", "name");
});
});
describe("Separator", () => {
it("defaults to a horizontal separator", () => {
const { container } = render(<Separator />);
expect(container.firstChild).toHaveClass("h-px", "w-full");
});
it("supports a vertical orientation", () => {
const { container } = render(<Separator orientation="vertical" />);
expect(container.firstChild).toHaveClass("h-full", "w-px");
});
});
describe("Card family", () => {
it("composes header, title, description and content", () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Desc</CardDescription>
</CardHeader>
<CardContent>Body</CardContent>
</Card>,
);
expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument();
expect(screen.getByText("Desc")).toBeInTheDocument();
expect(screen.getByText("Body")).toBeInTheDocument();
});
});
describe("AppCard", () => {
it("renders a single-layer card with its children", () => {
render(<AppCard layer="single">Single</AppCard>);
expect(screen.getByText("Single")).toBeInTheDocument();
});
it("renders a double-layer card (default) with its children", () => {
render(<AppCard>Double</AppCard>);
expect(screen.getByText("Double")).toBeInTheDocument();
});
});
describe("Container", () => {
it("applies the size variant classes", () => {
const { container } = render(<Container size="narrow">Body</Container>);
expect(container.firstChild).toHaveClass("max-w-narrow");
});
it("renders as a child element when asChild is set", () => {
render(
<Container asChild>
<section data-testid="as-child">X</section>
</Container>,
);
expect(screen.getByTestId("as-child").tagName).toBe("SECTION");
});
});
describe("HeroBadge", () => {
it("renders children and optional trailing content", () => {
render(<HeroBadge trailing={<span></span>}>Available</HeroBadge>);
expect(screen.getByText("Available")).toBeInTheDocument();
expect(screen.getByText("→")).toBeInTheDocument();
});
});
describe("SectionHeading", () => {
it("renders eyebrow, title and description", () => {
render(<SectionHeading eyebrow="Work" title="Projects" description="What I build" />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Projects" })).toBeInTheDocument();
expect(screen.getByText("What I build")).toBeInTheDocument();
});
it("centers content when align is center", () => {
const { container } = render(
<SectionHeading eyebrow="E" title="T" description="D" align="center" />,
);
expect(container.firstChild).toHaveClass("text-center");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import "@testing-library/jest-dom/vitest";
import React from "react";
afterEach(() => {
cleanup();
});
// ---------------------------------------------------------------------------
// Global mocks for heavy / browser-only libraries so components render in jsdom.
// (vi.mock in a setup file applies to every test file in the component project.)
// ---------------------------------------------------------------------------
vi.mock("framer-motion", () => {
const passthrough = (tag: string) =>
React.forwardRef(function MotionMock(
{ children, ...props }: Record<string, unknown> & { children?: React.ReactNode },
ref: React.Ref<unknown>,
) {
const domProps: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
// Drop motion-only props that would warn as unknown DOM attributes.
if (
/^(initial|animate|exit|transition|variants|whileHover|whileTap|whileInView|whileFocus|whileDrag|drag|layout|layoutId|viewport|custom|onAnimationComplete|style)$/.test(
key,
)
) {
if (key === "style") domProps.style = value;
continue;
}
domProps[key] = value;
}
return React.createElement(tag, { ...domProps, ref }, children as React.ReactNode);
});
const motion = new Proxy(
{},
{
get: (_target, tag: string) => passthrough(tag),
},
);
return {
motion,
AnimatePresence: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
useReducedMotion: () => true,
useInView: () => true,
useScroll: () => ({ scrollYProgress: { on: () => () => {}, get: () => 0 } }),
useTransform: () => 0,
useMotionValue: (value: unknown) => ({ get: () => value, set: () => {}, on: () => () => {} }),
useAnimate: () => [React.useRef(null), () => Promise.resolve()],
};
});
vi.mock("gsap", () => {
const tween = { kill: () => {}, play: () => {}, pause: () => {}, progress: () => {} };
const gsap = {
to: () => tween,
from: () => tween,
fromTo: () => tween,
set: () => tween,
timeline: () => ({
to: () => ({}),
from: () => ({}),
fromTo: () => ({}),
add: () => ({}),
kill: () => {},
}),
registerPlugin: () => {},
context: (fn: () => void) => {
if (typeof fn === "function") fn();
return { revert: () => {}, kill: () => {} };
},
matchMedia: () => ({ add: () => {}, revert: () => {} }),
utils: { toArray: (v: unknown) => (Array.isArray(v) ? v : [v]) },
};
return { gsap, default: gsap };
});
vi.mock("@gsap/react", () => ({
useGSAP: () => ({ context: {}, contextSafe: (fn: unknown) => fn }),
}));
vi.mock("next/link", () => ({
default: React.forwardRef(function LinkMock(
{ href, children, ...props }: Record<string, unknown> & { href?: unknown; children?: React.ReactNode },
ref: React.Ref<HTMLAnchorElement>,
) {
return React.createElement("a", { href: String(href ?? ""), ref, ...props }, children as React.ReactNode);
}),
}));
vi.mock("next/image", () => ({
default: ({ src, alt, ...props }: Record<string, unknown> & { src?: unknown; alt?: string }) =>
React.createElement("img", { src: String(src ?? ""), alt: alt ?? "", ...props }),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
redirect: vi.fn(),
notFound: vi.fn(),
}));
vi.mock("next-intl", () => ({
useTranslations: () => {
const t = (key: string) => key;
t.rich = (key: string) => key;
t.markup = (key: string) => key;
t.raw = (key: string) => key;
return t;
},
useLocale: () => "de",
useFormatter: () => ({
dateTime: (v: Date) => v.toISOString(),
number: (v: number) => String(v),
relativeTime: (v: unknown) => String(v),
}),
useMessages: () => ({}),
NextIntlClientProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
+138
View File
@@ -0,0 +1,138 @@
import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaKind, MediaSource, MediaUsageType, PortfolioSectionType } from "@/lib/db/enums";
let counter = 0;
function uniq(prefix: string) {
counter += 1;
return `${prefix}-${counter}`;
}
export async function createCategory(overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(category)
.values({
slug: (overrides.slug as string) ?? uniq("cat"),
nameAr: "الاسم",
nameEn: "Name",
nameDe: "Name",
descriptionAr: "وصف",
descriptionEn: "Description",
descriptionDe: "Beschreibung",
sortOrder: 0,
isActive: true,
...overrides,
} as typeof category.$inferInsert)
.returning();
return row;
}
export async function createProject(overrides: Record<string, unknown> = {}) {
const categoryId = (overrides.categoryId as string) ?? (await createCategory()).id;
const isPublished = (overrides.isPublished as boolean) ?? true;
const slug = (overrides.slug as string) ?? uniq("proj");
const [row] = await db
.insert(portfolioProject)
.values({
categoryId,
slug,
viewMode: "GRID",
titleAr: "عنوان",
titleEn: "Title",
titleDe: "Titel",
summaryAr: "ملخص",
summaryEn: "Summary",
summaryDe: "Zusammenfassung",
clientName: "Client",
projectYear: 2025,
serviceLabelAr: "خدمة",
serviceLabelEn: "Service",
serviceLabelDe: "Service",
isFeatured: false,
sortOrder: 0,
...overrides,
isPublished,
publishedAt: isPublished ? new Date() : null,
} as typeof portfolioProject.$inferInsert)
.returning();
return row;
}
export async function createSection(projectId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(portfolioSection)
.values({
projectId,
type: PortfolioSectionType.RICH_TEXT,
titleAr: "ع",
titleEn: "t",
titleDe: "t",
bodyAr: "ب",
bodyEn: "b",
bodyDe: "b",
sortOrder: 0,
...overrides,
} as typeof portfolioSection.$inferInsert)
.returning();
return row;
}
export async function createAsset(projectId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(portfolioAsset)
.values({
projectId,
kind: "IMAGE",
filePath: "/uploads/media/assets/x.svg",
altAr: "ع",
altEn: "a",
altDe: "a",
sortOrder: 0,
...overrides,
} as typeof portfolioAsset.$inferInsert)
.returning();
return row;
}
export async function createMediaAsset(overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(mediaAsset)
.values({
source: MediaSource.EXTERNAL,
kind: MediaKind.IMAGE,
url: (overrides.url as string) ?? `https://cdn.example.com/${uniq("img")}.png`,
fileName: "img.png",
label: "Image",
...overrides,
} as typeof mediaAsset.$inferInsert)
.returning();
return row;
}
export async function createMediaUsage(assetId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(mediaUsage)
.values({
assetId,
usageType: MediaUsageType.GENERIC,
entityType: "test-entity",
entityId: "e1",
fieldKey: uniq("field"),
...overrides,
} as typeof mediaUsage.$inferInsert)
.returning();
return row;
}
+23
View File
@@ -0,0 +1,23 @@
import { mkdirSync, unlinkSync, writeFileSync } from "fs";
import path from "path";
import { MEDIA_UPLOAD_ROOT } from "@/lib/media-storage";
/**
* Some sandboxed / read-only-mount environments allow writes but forbid `unlink`.
* Filesystem round-trip tests that create AND delete managed media files self-skip
* when deletion isn't permitted, so the suite stays green there while still running
* fully on a normal filesystem (developer machine / CI).
*/
export const canManageUploads: boolean = (() => {
try {
const dir = path.join(MEDIA_UPLOAD_ROOT, "tests");
mkdirSync(dir, { recursive: true });
const probe = path.join(dir, `.cap-probe-${process.pid}-${Date.now()}`);
writeFileSync(probe, "probe");
unlinkSync(probe);
return true;
} catch {
return false;
}
})();
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync, readdirSync } from "fs";
import path from "path";
/**
* Global integration setup.
*
* Only needed when running against a real Postgres via TEST_DATABASE_URL: reset the
* schema and apply every Drizzle migration once before the workers start. When
* TEST_DATABASE_URL is not set, each worker spins up its own in-process PGlite
* database (see tests/helpers/integration-setup.ts) and this is a no-op.
*/
const MIGRATIONS_DIR = path.resolve(process.cwd(), "lib", "db", "migrations");
export default async function setup() {
const connectionString = process.env.TEST_DATABASE_URL?.trim();
if (!connectionString) {
return;
}
const pg = (await import("pg")).default;
const client = new pg.Client({ connectionString });
await client.connect();
try {
await client.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;");
const files = readdirSync(MIGRATIONS_DIR)
.filter((entry) => entry.endsWith(".sql"))
.sort();
for (const file of files) {
await client.query(readFileSync(path.join(MIGRATIONS_DIR, file), "utf8"));
}
} finally {
await client.end();
}
}
+74
View File
@@ -0,0 +1,74 @@
import { readFileSync, readdirSync } from "fs";
import path from "path";
import { sql } from "drizzle-orm";
import { afterAll, beforeEach, vi } from "vitest";
import * as schema from "@/lib/db/schema";
/**
* Integration database wiring (Drizzle).
*
* - If TEST_DATABASE_URL is set, the real `@/lib/db` singleton is used unchanged,
* pointed at that Postgres. Migrations are applied once by the global setup;
* files run serially and truncate between tests.
*
* - Otherwise, `@/lib/db` is mocked with a Drizzle client backed by an in-process
* PGlite database (Postgres compiled to WASM) — real Postgres semantics, fully
* isolated per worker, no external server. Production code is never modified.
*/
const realDbUrl = process.env.TEST_DATABASE_URL?.trim();
if (realDbUrl) {
process.env.DATABASE_URL = realDbUrl;
}
vi.mock("@/lib/db", async () => {
if (process.env.TEST_DATABASE_URL?.trim()) {
return await vi.importActual<typeof import("@/lib/db")>("@/lib/db");
}
const { PGlite } = await import("@electric-sql/pglite");
const { drizzle } = await import("drizzle-orm/pglite");
const client = new PGlite();
const migrationsDir = path.resolve(process.cwd(), "lib", "db", "migrations");
const files = readdirSync(migrationsDir)
.filter((entry) => entry.endsWith(".sql"))
.sort();
for (const file of files) {
await client.exec(readFileSync(path.join(migrationsDir, file), "utf8"));
}
const db = drizzle(client, { schema });
return { db, schema };
});
const { db } = await import("@/lib/db");
export { db };
// Truncated in dependency order (children first) between every test for isolation.
const TABLES = [
"media_usage",
"media_asset",
"portfolio_asset",
"portfolio_section",
"portfolio_project",
"category",
"app_config",
];
export async function resetDb() {
const list = TABLES.map((table) => `"${table}"`).join(", ");
await db.execute(sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`));
}
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
// PGlite is in-process and torn down with the worker; the real postgres.js
// client is a shared singleton and is left open on purpose.
});
+67
View File
@@ -0,0 +1,67 @@
import { vi } from "vitest";
/**
* Shared mocks for the Next.js runtime pieces that server actions depend on.
* Action tests wire these in with `vi.mock(...)` at the top of each file, e.g.:
*
* vi.mock("next/navigation", async () => ({
* redirect: (await import("@/tests/helpers/next-mocks")).redirect,
* }));
*/
export class RedirectError extends Error {
readonly digest = "NEXT_REDIRECT";
readonly __isRedirect = true;
constructor(public url: string) {
super(`NEXT_REDIRECT:${url}`);
}
}
export function redirect(url: string): never {
throw new RedirectError(url);
}
export function isRedirectError(error: unknown): error is RedirectError {
return (
error instanceof RedirectError ||
(typeof error === "object" && error !== null && (error as { __isRedirect?: boolean }).__isRedirect === true)
);
}
export const revalidatePath = vi.fn();
export const adminAuth = { authenticated: true };
export const clearAdminSessionCookie = vi.fn(async () => {});
export async function isAdminAuthenticated(): Promise<boolean> {
return adminAuth.authenticated;
}
/** Run an action and return the URL it redirected to (or throw if it didn't). */
export async function captureRedirect(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
if (isRedirectError(error)) {
return error.url;
}
throw error;
}
throw new Error("Expected the action to redirect, but it returned normally.");
}
export function resetNextMocks() {
revalidatePath.mockClear();
clearAdminSessionCookie.mockClear();
adminAuth.authenticated = true;
}
/** Build a FormData from a flat record (strings and Files). */
export function formDataFrom(fields: Record<string, string | File | undefined>): FormData {
const formData = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) {
formData.set(key, value);
}
}
return formData;
}
+42
View File
@@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { db } from "@/lib/db";
import { appConfig, category } from "@/lib/db/schema";
describe("integration harness smoke test", () => {
it("connects to the migrated test database and performs CRUD", async () => {
const [created] = await db
.insert(category)
.values({
slug: "smoke",
nameAr: "a",
nameEn: "b",
nameDe: "c",
descriptionAr: "a",
descriptionEn: "b",
descriptionDe: "c",
})
.returning();
expect(created.id).toBeTruthy();
expect(created.isActive).toBe(true);
const found = await db.query.category.findFirst({ where: eq(category.slug, "smoke") });
expect(found?.nameEn).toBe("b");
});
it("resets the database between tests", async () => {
const count = await db.$count(category);
expect(count).toBe(0);
});
it("supports enums and appconfig upsert", async () => {
await db
.insert(appConfig)
.values({ key: "k", value: "v1" })
.onConflictDoUpdate({ target: appConfig.key, set: { value: "v2" } });
const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, "k") });
expect(row?.value).toBe("v1");
});
});
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", async () => ({
redirect: (await import("@/tests/helpers/next-mocks")).redirect,
}));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
const { sendContactMessage } = vi.hoisted(() => ({ sendContactMessage: vi.fn(async () => {}) }));
vi.mock("@/lib/mail", () => ({ sendContactMessage }));
import { submitContactFormAction } from "@/app/[locale]/(site)/contact/actions";
import { captureRedirect, formDataFrom } from "@/tests/helpers/next-mocks";
beforeEach(() => {
sendContactMessage.mockClear();
});
describe("submitContactFormAction", () => {
it("sends a valid message and redirects to the localized success page", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "en",
name: "Jane Doe",
email: "jane@example.com",
message: "Hello, I would like to work together on a project.",
}),
),
);
expect(url).toBe("/en/success");
expect(sendContactMessage).toHaveBeenCalledTimes(1);
expect(sendContactMessage).toHaveBeenCalledWith(
expect.objectContaining({ locale: "en", name: "Jane Doe", email: "jane@example.com" }),
);
});
it("uses the default locale (de) and its bare success path", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "de",
name: "Max Mustermann",
email: "max@example.com",
message: "Ich interessiere mich fuer eine Zusammenarbeit.",
}),
),
);
expect(url).toBe("/success");
});
it("redirects back with an error for an invalid email and does not send", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "en",
name: "Jane",
email: "not-an-email",
message: "This is a long enough message body.",
}),
),
);
expect(url).toContain("/en/contact?error=");
expect(sendContactMessage).not.toHaveBeenCalled();
});
it("rejects a too-short message", async () => {
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({ locale: "de", name: "Jane Doe", email: "jane@example.com", message: "short" }),
),
);
expect(url).toContain("/contact?error=");
expect(sendContactMessage).not.toHaveBeenCalled();
});
it("redirects with an error when delivery fails", async () => {
sendContactMessage.mockRejectedValueOnce(new Error("smtp down"));
const url = await captureRedirect(() =>
submitContactFormAction(
formDataFrom({
locale: "en",
name: "Jane Doe",
email: "jane@example.com",
message: "A perfectly valid message body here.",
}),
),
);
expect(url).toContain("/en/contact?error=");
});
});
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
import { getMaintenanceMode } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
describe("updateMaintenanceModeAction", () => {
it("enables maintenance mode and redirects with a success flash", async () => {
const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
expect(url).toContain("success=");
expect(await getMaintenanceMode()).toBe(true);
});
it("disables maintenance mode", async () => {
await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "false" })));
expect(await getMaintenanceMode()).toBe(false);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => updateMaintenanceModeAction(formDataFrom({ enabled: "true" })));
expect(url).toBe("/");
// state unchanged
expect(await getMaintenanceMode()).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { saveMarqueeSettingsAction } from "@/app/_admin/marquee/actions";
import { getMarqueeSettings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
const validRows = {
"row1-de": "A\nB",
"row2-de": "C\nD",
"row3-de": "E\nF",
"row4-de": "G\nH",
};
describe("saveMarqueeSettingsAction", () => {
it("saves german rows and mirrors them across locales", async () => {
const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
expect(url).toContain("success=");
const settings = await getMarqueeSettings();
expect(settings.locales.de.row1).toBe("A\nB");
expect(settings.locales.en.row1).toBe("A\nB");
expect(settings.locales.ar.row4).toBe("G\nH");
});
it("redirects with an error when a required row is empty", async () => {
const url = await captureRedirect(() =>
saveMarqueeSettingsAction(formDataFrom({ ...validRows, "row2-de": " " })),
);
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveMarqueeSettingsAction(formDataFrom(validRows)));
expect(url).toBe("/");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { eq } from "drizzle-orm";
import { createMediaAssetAction, deleteMediaAssetAction } from "@/app/_admin/media/actions";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
describe("createMediaAssetAction", () => {
it("errors when no file is provided", async () => {
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
expect(url).toContain("error=");
expect(await db.$count(mediaAsset)).toBe(0);
});
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "pic.png", { type: "image/png" });
const url = await captureRedirect(() =>
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
);
expect(url).toContain("success=");
const assets = await db.select().from(mediaAsset);
expect(assets.length).toBe(1);
expect(assets[0].source).toBe("UPLOAD");
await removeManagedMediaFile(assets[0].url);
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
expect(url).toBe("/");
});
});
describe("deleteMediaAssetAction", () => {
it("errors when the asset does not exist", async () => {
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "missing" })));
expect(url).toContain("error=");
});
it("refuses to delete an asset that is still in use", async () => {
const asset = await createMediaAsset();
await createMediaUsage(asset.id);
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
expect(url).toContain("error=");
expect((await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, asset.id) })) ?? null).not.toBeNull();
});
it("deletes an unused external asset", async () => {
const asset = await createMediaAsset({ url: "https://cdn/external.png" });
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
expect(url).toContain("success=");
expect((await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, asset.id) })) ?? null).toBeNull();
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: "x" })));
expect(url).toBe("/");
});
});
+228
View File
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import {
deleteCategoryAction,
deleteProjectAction,
saveProjectAction,
upsertCategoryAction,
} from "@/app/_admin/portfolio/actions";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import {
category as categoryTable,
mediaUsage as mediaUsageTable,
portfolioAsset as portfolioAssetTable,
portfolioProject as portfolioProjectTable,
} from "@/lib/db/schema";
import { createCategory, createProject } from "@/tests/helpers/factories";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
function categoryForm(overrides: Record<string, string> = {}) {
return formDataFrom({
slug: "branding",
nameAr: "الهوية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "وصف",
descriptionEn: "Description",
descriptionDe: "Beschreibung",
sortOrder: "1",
isActive: "on",
...overrides,
});
}
function projectForm(categoryId: string, overrides: Record<string, string> = {}) {
const assets = JSON.stringify([
{
kind: "IMAGE",
altAr: "ع",
altEn: "Alt",
altDe: "Alt",
sortOrder: 0,
media: { mode: "external", url: "https://cdn/asset.png", kind: "IMAGE", label: "Asset" },
},
]);
const coverMedia = JSON.stringify({ mode: "external", url: "https://cdn/cover.png", kind: "IMAGE", label: "Cover" });
return formDataFrom({
categoryId,
slug: "case-study",
viewMode: "GRID",
titleAr: "عنوان",
titleEn: "Title",
titleDe: "Titel",
summaryAr: "ملخص",
summaryEn: "Summary",
summaryDe: "Zusammenfassung",
clientName: "Client",
projectYear: "2025",
serviceLabelAr: "خدمة",
serviceLabelEn: "Service",
serviceLabelDe: "Service",
previewUrl: "https://example.com",
sortOrder: "0",
isFeatured: "",
isPublished: "on",
sections: "[]",
assets,
coverMedia,
...overrides,
});
}
describe("upsertCategoryAction", () => {
it("creates a category", async () => {
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
expect(url).toContain("success=");
const category = await db.query.category.findFirst({ where: eq(categoryTable.slug, "branding") });
expect(category?.nameEn).toBe("Branding");
expect(category?.isActive).toBe(true);
});
it("updates an existing category", async () => {
const existing = await createCategory({ slug: "old", nameEn: "Old" });
const url = await captureRedirect(() =>
upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })),
);
expect(url).toContain("success=");
const category = await db.query.category.findFirst({ where: eq(categoryTable.id, existing.id) });
expect(category?.nameEn).toBe("Renamed");
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
await createCategory({ slug: "branding" });
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "branding" })));
expect(url).toContain("error=");
expect(decodeURIComponent(url)).toContain("eindeutig");
});
it("reports validation errors for an invalid slug", async () => {
const url = await captureRedirect(() => upsertCategoryAction(categoryForm({ slug: "Not Valid" })));
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
expect(url).toBe("/");
});
});
describe("deleteCategoryAction", () => {
it("refuses to delete a category that has projects", async () => {
const category = await createCategory();
await createProject({ categoryId: category.id });
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
expect(url).toContain("error=");
expect((await db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).not.toBeNull();
});
it("deletes an empty category", async () => {
const category = await createCategory();
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
expect(url).toContain("success=");
expect((await db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).toBeNull();
});
});
describe("saveProjectAction", () => {
it("creates a published project with cover and asset media usages", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
expect(url).toContain("success=");
const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
expect(project).not.toBeNull();
expect(project?.isPublished).toBe(true);
expect(project?.publishedAt).not.toBeNull();
expect(project?.coverImagePath).toBe("https://cdn/cover.png");
expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
const usages = await db.select().from(mediaUsageTable).where(and(eq(mediaUsageTable.entityType, "portfolio-project"), eq(mediaUsageTable.entityId, project!.id)));
const usageTypes = usages.map((u) => u.usageType).sort();
expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]);
});
it("updates an existing project and replaces its assets", async () => {
const category = await createCategory();
const created = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
void created;
const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
const url = await captureRedirect(() =>
saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
);
expect(url).toContain("success=");
const updated = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project!.id) });
expect(updated?.titleEn).toBe("Updated Title");
// assets are replaced, not duplicated
expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
});
it("keeps the original publishedAt when re-saving an already published project", async () => {
const category = await createCategory();
await captureRedirect(() => saveProjectAction(projectForm(category.id)));
const first = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
const originalPublishedAt = first!.publishedAt;
await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id })));
const second = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, first!.id) });
expect(second?.publishedAt?.toISOString()).toBe(originalPublishedAt?.toISOString());
});
it("reports validation errors and creates nothing", async () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" })));
expect(url).toContain("error=");
expect(await db.$count(portfolioProjectTable)).toBe(0);
});
it("reports a unique-constraint violation on duplicate slugs", async () => {
const category = await createCategory();
await createProject({ categoryId: category.id, slug: "case-study" });
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
expect(url).toContain("error=");
expect(decodeURIComponent(url)).toContain("eindeutig");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveProjectAction(projectForm("cat")));
expect(url).toBe("/");
});
});
describe("deleteProjectAction", () => {
it("deletes a project and its media usages", async () => {
const project = await createProject();
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id })));
expect(url).toContain("success=");
expect((await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project.id) })) ?? null).toBeNull();
});
it("errors when the project does not exist", async () => {
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "missing" })));
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: "x" })));
expect(url).toBe("/");
});
});
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import {
saveSiteBrandSettingsAction,
saveSiteLocalizationSettingsAction,
} from "@/app/_admin/site-settings/actions";
import { getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
});
describe("saveSiteBrandSettingsAction", () => {
it("saves a normalized primary color", async () => {
const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
expect(url).toContain("success=");
expect((await getSiteSettings()).brand.primaryColor).toBe("#123456");
});
it("falls back to the default color for invalid input", async () => {
await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "not-a-color" })));
expect((await getSiteSettings()).brand.primaryColor).toBe("#dc5a35");
});
it("wires an external favicon into media bindings", async () => {
const faviconMedia = JSON.stringify({ mode: "external", url: "https://cdn/f.svg", kind: "IMAGE", label: "F" });
const url = await captureRedirect(() =>
saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia })),
);
expect(url).toContain("success=");
const bindings = await getSiteSettingsMediaBindings();
expect(bindings.favicon?.url).toBe("https://cdn/f.svg");
});
it("errors on an invalid media json payload", async () => {
const url = await captureRedirect(() =>
saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#222222", faviconMedia: "{not json" })),
);
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSiteBrandSettingsAction(formDataFrom({ primaryColor: "#123456" })));
expect(url).toBe("/");
});
});
const localizationForm = {
defaultLocale: "en",
siteNameAr: "الموقع",
siteNameEn: "The Site",
siteNameDe: "Die Seite",
titleTemplateAr: "{pageTitle} | {siteName}",
titleTemplateEn: "{pageTitle} | {siteName}",
titleTemplateDe: "{pageTitle} | {siteName}",
siteDescriptionAr: "وصف",
siteDescriptionEn: "Description",
siteDescriptionDe: "Beschreibung",
subheadAr: "",
subheadEn: "",
subheadDe: "",
};
describe("saveSiteLocalizationSettingsAction", () => {
it("saves valid localization settings and the default locale", async () => {
const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
expect(url).toContain("success=");
const settings = await getSiteSettings();
expect(settings.defaultLocale).toBe("en");
expect(settings.locales.en.siteName).toBe("The Site");
});
it("requires a site name for every locale", async () => {
const url = await captureRedirect(() =>
saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, siteNameEn: "" })),
);
expect(url).toContain("error=");
});
it("requires the {pageTitle} token in every title template", async () => {
const url = await captureRedirect(() =>
saveSiteLocalizationSettingsAction(formDataFrom({ ...localizationForm, titleTemplateDe: "{siteName} only" })),
);
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveSiteLocalizationSettingsAction(formDataFrom(localizationForm)));
expect(url).toBe("/");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", async () => ({ revalidatePath: (await import("@/tests/helpers/next-mocks")).revalidatePath }));
vi.mock("next/navigation", async () => ({ redirect: (await import("@/tests/helpers/next-mocks")).redirect }));
vi.mock("next/dist/client/components/redirect-error", async () => ({
isRedirectError: (await import("@/tests/helpers/next-mocks")).isRedirectError,
}));
vi.mock("@/lib/admin-auth", async () => {
const m = await import("@/tests/helpers/next-mocks");
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
const { sendTestEmail } = vi.hoisted(() => ({ sendTestEmail: vi.fn(async () => {}) }));
vi.mock("@/lib/mail", () => ({ sendTestEmail }));
import { saveMailSettingsAction, sendTestEmailAction } from "@/app/_admin/smtp/actions";
import { getMailSettings } from "@/lib/app-config";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
beforeEach(() => {
resetNextMocks();
sendTestEmail.mockClear();
});
const validForm = {
smtpHost: "smtp.example.com",
smtpPort: "465",
smtpUsername: "mailer",
smtpPassword: "secret",
smtpSecure: "on",
mailFromEmail: "from@example.com",
mailFromName: "Studio",
mailContactRecipient: "contact@example.com",
mailTestRecipient: "test@example.com",
};
describe("saveMailSettingsAction", () => {
it("persists valid settings and redirects with success", async () => {
const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
expect(url).toContain("success=");
const settings = await getMailSettings();
expect(settings.smtp.host).toBe("smtp.example.com");
expect(settings.smtp.port).toBe(465);
expect(settings.smtp.secure).toBe(true);
expect(settings.recipients.contact).toBe("contact@example.com");
});
it("rejects an invalid port with an error flash", async () => {
const url = await captureRedirect(() =>
saveMailSettingsAction(formDataFrom({ ...validForm, smtpPort: "not-a-number" })),
);
expect(url).toContain("error=");
});
it("retains the existing password when the field is left blank", async () => {
await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
await captureRedirect(() =>
saveMailSettingsAction(formDataFrom({ ...validForm, smtpPassword: "" })),
);
const settings = await getMailSettings();
expect(settings.smtp.password).toBe("secret");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => saveMailSettingsAction(formDataFrom(validForm)));
expect(url).toBe("/");
});
});
describe("sendTestEmailAction", () => {
it("sends a test email and redirects with success", async () => {
const url = await captureRedirect(() => sendTestEmailAction());
expect(sendTestEmail).toHaveBeenCalledTimes(1);
expect(url).toContain("success=");
});
it("redirects with an error when sending fails", async () => {
sendTestEmail.mockRejectedValueOnce(new Error("SMTP host is required."));
const url = await captureRedirect(() => sendTestEmailAction());
expect(url).toContain("error=");
});
it("redirects unauthenticated callers to the admin root", async () => {
adminAuth.authenticated = false;
const url = await captureRedirect(() => sendTestEmailAction());
expect(url).toBe("/");
expect(sendTestEmail).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/headers", () => ({
headers: async () => new Headers({ "x-forwarded-for": "203.0.113.7" }),
cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }),
}));
import {
getAdminLockState,
isAdminAuthConfigured,
isPasswordValid,
registerFailedAdminAttempt,
resetAdminFailedAttempts,
} from "@/lib/admin-auth";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("admin auth configuration", () => {
it("is configured only when both password and secret are set", () => {
vi.stubEnv("ADMIN_PASSWORD", "");
vi.stubEnv("ADMIN_AUTH_SECRET", "");
expect(isAdminAuthConfigured()).toBe(false);
vi.stubEnv("ADMIN_PASSWORD", "pw");
vi.stubEnv("ADMIN_AUTH_SECRET", "");
expect(isAdminAuthConfigured()).toBe(false);
vi.stubEnv("ADMIN_PASSWORD", "pw");
vi.stubEnv("ADMIN_AUTH_SECRET", "secret");
expect(isAdminAuthConfigured()).toBe(true);
});
});
describe("isPasswordValid", () => {
it("returns false when auth is not configured", () => {
vi.stubEnv("ADMIN_PASSWORD", "");
vi.stubEnv("ADMIN_AUTH_SECRET", "");
expect(isPasswordValid("anything")).toBe(false);
});
it("accepts the correct password and rejects wrong ones", () => {
vi.stubEnv("ADMIN_PASSWORD", "s3cret-password");
vi.stubEnv("ADMIN_AUTH_SECRET", "hmac-secret");
expect(isPasswordValid("s3cret-password")).toBe(true);
expect(isPasswordValid("wrong")).toBe(false);
expect(isPasswordValid("s3cret-passwordX")).toBe(false); // length mismatch
});
});
describe("login lockout", () => {
it("locks the IP after the failed-attempt threshold", async () => {
expect((await getAdminLockState()).locked).toBe(false);
for (let i = 0; i < 4; i += 1) {
const state = await registerFailedAdminAttempt();
expect(state.locked).toBe(false);
}
expect((await getAdminLockState()).locked).toBe(false);
const fifth = await registerFailedAdminAttempt();
expect(fifth.locked).toBe(true);
expect(fifth.remainingSeconds).toBeGreaterThan(0);
const lockState = await getAdminLockState();
expect(lockState.locked).toBe(true);
expect(lockState.remainingSeconds).toBeGreaterThan(0);
expect(lockState.remainingSeconds).toBeLessThanOrEqual(15 * 60);
});
it("clears the lock on reset", async () => {
for (let i = 0; i < 5; i += 1) {
await registerFailedAdminAttempt();
}
expect((await getAdminLockState()).locked).toBe(true);
await resetAdminFailedAttempts();
expect((await getAdminLockState()).locked).toBe(false);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GET as healthGet } from "@/app/api/health/route";
import { GET as defaultLocaleGet } from "@/app/api/site/default-locale/route";
import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config";
import { db } from "@/lib/db";
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/health", () => {
it("reports ok when the database responds", async () => {
const response = await healthGet();
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe("ok");
expect(body.checks.database).toBe("up");
expect(typeof body.timestamp).toBe("string");
});
it("reports degraded (503) when the database query throws", async () => {
vi.spyOn(db, "execute").mockRejectedValueOnce(new Error("db down"));
const response = await healthGet();
expect(response.status).toBe(503);
const body = await response.json();
expect(body.status).toBe("degraded");
expect(body.checks.database).toBe("down");
});
});
describe("GET /api/site/default-locale", () => {
it("returns the runtime default locale and maintenance flag with no-store", async () => {
const response = await defaultLocaleGet();
expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
const body = await response.json();
expect(body.defaultLocale).toBe("de");
expect(body.maintenanceEnabled).toBe(false);
});
it("reflects updated settings and maintenance state", async () => {
const settings = await getSiteSettings();
settings.defaultLocale = "ar";
await updateSiteSettings(settings);
await setMaintenanceMode(true);
const response = await defaultLocaleGet();
const body = await response.json();
expect(body.defaultLocale).toBe("ar");
expect(body.maintenanceEnabled).toBe(true);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SITE_NAME,
MAINTENANCE_MODE_KEY,
SITE_NAME_KEY,
SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
buildDefaultMailSettings,
buildDefaultMarqueeSettings,
getMailSettings,
getMaintenanceMode,
getMarqueeSettings,
getSiteSettings,
getSiteSettingsMediaBindings,
setMaintenanceMode,
updateMailSettings,
updateMarqueeSettings,
updateSiteSettings,
} from "@/lib/app-config";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { appConfig, mediaUsage } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories";
describe("maintenance mode", () => {
it("defaults to false when unset", async () => {
expect(await getMaintenanceMode()).toBe(false);
});
it("persists and reads back the enabled flag", async () => {
await setMaintenanceMode(true);
expect(await getMaintenanceMode()).toBe(true);
const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, MAINTENANCE_MODE_KEY) });
expect(row?.value).toBe("true");
await setMaintenanceMode(false);
expect(await getMaintenanceMode()).toBe(false);
});
});
describe("site settings", () => {
it("returns defaults (with fallback name) when nothing stored", async () => {
const settings = await getSiteSettings();
expect(settings.defaultLocale).toBe("de");
expect(settings.locales.en.siteName).toBe(DEFAULT_SITE_NAME);
});
it("uses the stored siteName key as the fallback name", async () => {
await db.insert(appConfig).values({ key: SITE_NAME_KEY, value: "My Studio" });
const settings = await getSiteSettings();
expect(settings.locales.ar.siteName).toBe("My Studio");
});
it("round-trips an updated settings object", async () => {
const next = await getSiteSettings();
next.defaultLocale = "ar";
next.brand.primaryColor = "#123456";
next.locales.en.siteName = "Updated EN";
await updateSiteSettings(next);
const reloaded = await getSiteSettings();
expect(reloaded.defaultLocale).toBe("ar");
expect(reloaded.brand.primaryColor).toBe("#123456");
expect(reloaded.locales.en.siteName).toBe("Updated EN");
});
});
describe("mail settings", () => {
it("returns defaults when unset", async () => {
expect(await getMailSettings()).toEqual(buildDefaultMailSettings());
});
it("round-trips stored mail settings", async () => {
const next = buildDefaultMailSettings();
next.smtp.host = "smtp.test";
next.smtp.port = 465;
next.sender.email = "from@test";
next.recipients.contact = "c@test";
await updateMailSettings(next);
const reloaded = await getMailSettings();
expect(reloaded.smtp.host).toBe("smtp.test");
expect(reloaded.smtp.port).toBe(465);
expect(reloaded.recipients.contact).toBe("c@test");
});
});
describe("marquee settings", () => {
it("returns defaults when unset", async () => {
const settings = await getMarqueeSettings();
expect(settings.locales.de.row1).toBe(buildDefaultMarqueeSettings().locales.de.row1);
});
it("stores german-synced values", async () => {
const next = buildDefaultMarqueeSettings();
next.locales.de.row1 = "GERMAN ROW";
next.locales.en.row1 = "will be overwritten";
await updateMarqueeSettings(next);
const reloaded = await getMarqueeSettings();
expect(reloaded.locales.de.row1).toBe("GERMAN ROW");
expect(reloaded.locales.en.row1).toBe("GERMAN ROW");
expect(reloaded.locales.ar.row1).toBe("GERMAN ROW");
});
});
describe("getSiteSettingsMediaBindings", () => {
it("returns nulls when there are no usages", async () => {
const bindings = await getSiteSettingsMediaBindings();
expect(bindings).toEqual({ siteLogoLight: null, siteLogoDark: null, favicon: null, defaultOgImage: null });
});
it("maps media usages to their field bindings", async () => {
const logo = await createMediaAsset({ url: "https://cdn/logo.png" });
const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" });
await db.insert(mediaUsage).values({
assetId: logo.id,
usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
});
await db.insert(mediaUsage).values({
assetId: favicon.id,
usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
});
const bindings = await getSiteSettingsMediaBindings();
expect(bindings.siteLogoLight?.assetId).toBe(logo.id);
expect(bindings.siteLogoLight?.url).toBe("https://cdn/logo.png");
expect(bindings.favicon?.assetId).toBe(favicon.id);
expect(bindings.favicon?.version).toMatch(/\d{4}-\d{2}-\d{2}T/); // updatedAt ISO string
expect(bindings.siteLogoDark).toBeNull();
});
});
+123
View File
@@ -0,0 +1,123 @@
import { readFile } from "fs/promises";
import { describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { resolveMediaSelection } from "@/lib/media-service";
import { resolveMediaUploadPath } from "@/lib/media-storage";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability";
describe("resolveMediaSelection — library mode", () => {
it("returns the referenced asset", async () => {
const asset = await createMediaAsset({ url: "https://cdn/lib.png" });
const result = await resolveMediaSelection({
media: { mode: "library", assetId: asset.id, url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: false,
});
expect(result.assetId).toBe(asset.id);
expect(result.url).toBe("https://cdn/lib.png");
});
it("throws when the referenced asset is missing", async () => {
await expect(
resolveMediaSelection({
media: { mode: "library", assetId: "nope", url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: true,
}),
).rejects.toThrow(/not found/i);
});
});
describe("resolveMediaSelection — external mode", () => {
it("creates a new external asset from the url", async () => {
const result = await resolveMediaSelection({
media: { mode: "external", assetId: "", url: "https://cdn/new/photo.png", label: "Photo", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: true,
});
expect(result.createdAssetId).toBeTruthy();
expect(result.url).toBe("https://cdn/new/photo.png");
const stored = await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, result.assetId!) });
expect(stored?.source).toBe("EXTERNAL");
expect(stored?.fileName).toBe("photo.png");
expect(stored?.label).toBe("Photo");
});
it("returns empty selection for a not-required empty url", async () => {
const result = await resolveMediaSelection({
media: { mode: "external", assetId: "", url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "Cover",
required: false,
});
expect(result.assetId).toBeNull();
expect(result.url).toBe("");
});
});
describe("resolveMediaSelection — missing configuration", () => {
it("throws when required and no media object is present", async () => {
await expect(
resolveMediaSelection({ media: undefined, uploadFile: null, folder: "covers", fallbackLabel: "L", required: true }),
).rejects.toThrow(/missing/i);
});
it("returns empty selection when not required and no media object is present", async () => {
const result = await resolveMediaSelection({
media: undefined,
uploadFile: null,
folder: "covers",
fallbackLabel: "L",
required: false,
});
expect(result.assetId).toBeNull();
});
it("throws for a required upload with no file", async () => {
await expect(
resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "", kind: "IMAGE" },
uploadFile: null,
folder: "covers",
fallbackLabel: "L",
required: true,
}),
).rejects.toThrow(/required/i);
});
});
describe("resolveMediaSelection — upload mode (filesystem)", () => {
it.skipIf(!canManageUploads)("saves the file and creates an UPLOAD asset", async () => {
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "shot.png", { type: "image/png" });
const result = await resolveMediaSelection({
media: { mode: "upload", assetId: "", url: "", label: "Shot", kind: "IMAGE" },
uploadFile: file,
folder: "tests",
fallbackLabel: "L",
required: true,
});
expect(result.uploadedUrl).toBeTruthy();
const stored = await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, result.assetId!) });
expect(stored?.source).toBe("UPLOAD");
// File actually written to disk
const bytes = await readFile(resolveMediaUploadPath(result.url));
expect(bytes.length).toBeGreaterThan(0);
// cleanup
const { removeManagedMediaFile } = await import("@/lib/media-storage");
await removeManagedMediaFile(result.url);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
countMediaUsageReferences,
createMediaAsset,
deleteEntityMediaUsages,
getAdminMediaAssets,
getMediaAssetById,
getMediaOptions,
getPortfolioMediaBindings,
replaceEntityMediaUsages,
} from "@/lib/media";
import { db } from "@/lib/db";
import { mediaUsage } from "@/lib/db/schema";
import { createMediaAsset as seedAsset } from "@/tests/helpers/factories";
describe("createMediaAsset / getMediaAssetById", () => {
it("creates and reads back an asset with usages", async () => {
const created = await createMediaAsset({
source: "EXTERNAL",
kind: "IMAGE",
url: "https://cdn/x.png",
fileName: "x.png",
label: "X",
});
const found = await getMediaAssetById(created.id);
expect(found?.url).toBe("https://cdn/x.png");
expect(found?.usages).toEqual([]);
});
it("returns null for a missing asset", async () => {
expect(await getMediaAssetById("nope")).toBeNull();
});
});
describe("getMediaOptions", () => {
it("filters by kind", async () => {
await seedAsset({ kind: "IMAGE" });
await seedAsset({ kind: "DOCUMENT" });
const images = await getMediaOptions({ kind: "IMAGE" });
expect(images.every((a) => a.kind === "IMAGE")).toBe(true);
const all = await getMediaOptions();
expect(all.length).toBe(2);
});
});
describe("getAdminMediaAssets", () => {
it("returns newest first with usage details", async () => {
const a = await seedAsset();
await createMediaUsageFor(a.id);
const list = await getAdminMediaAssets();
expect(list.length).toBe(1);
expect(list[0].usages.length).toBe(1);
});
});
describe("replaceEntityMediaUsages", () => {
it("replaces existing usages transactionally", async () => {
const a1 = await seedAsset();
const a2 = await seedAsset();
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "p1",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
});
expect(await countMediaUsageReferences(a1.id)).toBe(1);
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "p1",
usages: [{ assetId: a2.id, usageType: "PORTFOLIO_COVER", fieldKey: "cover" }],
});
expect(await countMediaUsageReferences(a1.id)).toBe(0);
expect(await countMediaUsageReferences(a2.id)).toBe(1);
});
it("clears usages when given an empty list", async () => {
const a1 = await seedAsset();
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "p2",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
});
await replaceEntityMediaUsages({ entityType: "portfolio-project", entityId: "p2", usages: [] });
expect(await countMediaUsageReferences(a1.id)).toBe(0);
});
});
describe("deleteEntityMediaUsages", () => {
it("removes only the target entity's usages", async () => {
const a1 = await seedAsset();
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "keep",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "a" }],
});
await replaceEntityMediaUsages({
entityType: "portfolio-project",
entityId: "drop",
usages: [{ assetId: a1.id, usageType: "PORTFOLIO_ASSET", fieldKey: "b" }],
});
await deleteEntityMediaUsages("portfolio-project", "drop");
expect(await countMediaUsageReferences(a1.id)).toBe(1);
});
});
describe("getPortfolioMediaBindings", () => {
it("routes usages into cover / section / asset buckets", async () => {
const cover = await seedAsset();
const section = await seedAsset();
const asset = await seedAsset();
await db.insert(mediaUsage).values([
{ assetId: cover.id, usageType: "PORTFOLIO_COVER", entityType: "portfolio-project", entityId: "proj", fieldKey: "cover" },
{ assetId: section.id, usageType: "PORTFOLIO_SECTION", entityType: "portfolio-project", entityId: "proj", fieldKey: "sec_1" },
{ assetId: asset.id, usageType: "PORTFOLIO_ASSET", entityType: "portfolio-project", entityId: "proj", fieldKey: "ast_1" },
]);
const bindings = await getPortfolioMediaBindings("proj");
expect(bindings.coverAssetId).toBe(cover.id);
expect(bindings.sectionAssetIds.sec_1).toBe(section.id);
expect(bindings.assetIds.ast_1).toBe(asset.id);
});
it("returns empty bindings for an unknown project", async () => {
const bindings = await getPortfolioMediaBindings("missing");
expect(bindings).toEqual({ coverAssetId: null, sectionAssetIds: {}, assetIds: {} });
});
});
async function createMediaUsageFor(assetId: string) {
await db.insert(mediaUsage).values({ assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" });
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
getActivePortfolioCategories,
getActivePortfolioCategoryBySlug,
getAdminPortfolioCategories,
getAdminPortfolioProjectById,
getAdminPortfolioProjects,
getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects,
} from "@/lib/portfolio";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { category, mediaUsage, portfolioAsset, portfolioProject, portfolioSection } from "@/lib/db/schema";
import {
createAsset,
createCategory,
createMediaAsset,
createProject,
createSection,
} from "@/tests/helpers/factories";
describe("categories", () => {
it("lists admin categories with project counts, ordered", async () => {
const a = await createCategory({ slug: "a", sortOrder: 2 });
await createCategory({ slug: "b", sortOrder: 1 });
await createProject({ categoryId: a.id });
const categories = await getAdminPortfolioCategories();
expect(categories.map((c) => c.slug)).toEqual(["b", "a"]); // sortOrder asc
expect(categories.find((c) => c.slug === "a")?.projectCount).toBe(1);
expect(categories.find((c) => c.slug === "b")?.projectCount).toBe(0);
});
it("returns only active categories publicly", async () => {
await createCategory({ slug: "on", isActive: true });
await createCategory({ slug: "off", isActive: false });
const active = await getActivePortfolioCategories();
expect(active.map((c) => c.slug)).toEqual(["on"]);
});
it("finds an active category by slug and ignores inactive ones", async () => {
await createCategory({ slug: "visible", isActive: true });
await createCategory({ slug: "hidden", isActive: false });
expect((await getActivePortfolioCategoryBySlug("visible"))?.slug).toBe("visible");
expect(await getActivePortfolioCategoryBySlug("hidden")).toBeNull();
});
});
describe("admin projects", () => {
it("filters by status and category", async () => {
const cat = await createCategory();
await createProject({ categoryId: cat.id, slug: "pub", isPublished: true });
await createProject({ categoryId: cat.id, slug: "draft", isPublished: false });
const published = await getAdminPortfolioProjects({ status: "published" });
expect(published.map((p) => p.slug)).toEqual(["pub"]);
const drafts = await getAdminPortfolioProjects({ status: "draft" });
expect(drafts.map((p) => p.slug)).toEqual(["draft"]);
const byCategory = await getAdminPortfolioProjects({ categoryId: cat.id });
expect(byCategory.length).toBe(2);
});
it("maps localized content and nested sections/assets", async () => {
const project = await createProject({ slug: "mapped" });
await createSection(project.id, { titleEn: "Intro" });
await createAsset(project.id, { altEn: "Cover" });
const detail = await getAdminPortfolioProjectById(project.id);
expect(detail?.title.en).toBe("Title");
expect(detail?.sections[0].title.en).toBe("Intro");
expect(detail?.assets[0].alt.en).toBe("Cover");
});
it("attaches media bindings to a project fetched by id", async () => {
const project = await createProject();
const cover = await createMediaAsset();
await db.insert(mediaUsage).values({
assetId: cover.id,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: project.id,
fieldKey: "cover",
});
const detail = await getAdminPortfolioProjectById(project.id);
expect(detail?.coverMediaAssetId).toBe(cover.id);
});
it("returns null for a missing project id", async () => {
expect(await getAdminPortfolioProjectById("missing")).toBeNull();
});
});
describe("published projects", () => {
it("returns only published projects in active categories", async () => {
const activeCat = await createCategory({ isActive: true });
const inactiveCat = await createCategory({ isActive: false });
await createProject({ categoryId: activeCat.id, slug: "shown", isPublished: true });
await createProject({ categoryId: activeCat.id, slug: "hidden-draft", isPublished: false });
await createProject({ categoryId: inactiveCat.id, slug: "hidden-cat", isPublished: true });
const projects = await getPublishedPortfolioProjects();
expect(projects.map((p) => p.slug)).toEqual(["shown"]);
});
it("filters published projects by category slug", async () => {
const catA = await createCategory({ slug: "cat-a", isActive: true });
const catB = await createCategory({ slug: "cat-b", isActive: true });
await createProject({ categoryId: catA.id, slug: "in-a", isPublished: true });
await createProject({ categoryId: catB.id, slug: "in-b", isPublished: true });
const projects = await getPublishedPortfolioProjects({ categorySlug: "cat-a" });
expect(projects.map((p) => p.slug)).toEqual(["in-a"]);
});
it("finds a published project by slug and hides drafts", async () => {
await createProject({ slug: "live", isPublished: true });
await createProject({ slug: "wip", isPublished: false });
expect((await getPublishedPortfolioProjectBySlug("live"))?.slug).toBe("live");
expect(await getPublishedPortfolioProjectBySlug("wip")).toBeNull();
});
});
describe("referential integrity", () => {
it("restricts deleting a category that still has projects", async () => {
const cat = await createCategory();
await createProject({ categoryId: cat.id });
await expect(db.delete(category).where(eq(category.id, cat.id))).rejects.toThrow();
});
it("cascades section and asset deletion when a project is removed", async () => {
const project = await createProject();
await createSection(project.id);
await createAsset(project.id);
await db.delete(portfolioProject).where(eq(portfolioProject.id, project.id));
expect(await db.$count(portfolioSection)).toBe(0);
expect(await db.$count(portfolioAsset)).toBe(0);
});
});
+2 -1
View File
@@ -39,7 +39,7 @@ function createMockRequest(url: string) {
describe("middleware locale runtime config", () => { describe("middleware locale runtime config", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
process.env.NODE_ENV = "production"; vi.stubEnv("NODE_ENV", "production");
delete process.env.SITE_RUNTIME_ORIGIN; delete process.env.SITE_RUNTIME_ORIGIN;
createMiddlewareMock.mockReset(); createMiddlewareMock.mockReset();
intlHandlerMock.mockReset(); intlHandlerMock.mockReset();
@@ -49,6 +49,7 @@ describe("middleware locale runtime config", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.unstubAllEnvs();
}); });
it("passes the runtime default locale into next-intl middleware", async () => { it("passes the runtime default locale into next-intl middleware", async () => {
+2 -1
View File
@@ -10,6 +10,7 @@ import {
resolveMediaUploadPath, resolveMediaUploadPath,
sanitizeBaseName, sanitizeBaseName,
} from "../lib/media-storage"; } from "../lib/media-storage";
import { canManageUploads } from "./helpers/fs-capability";
const createdFiles: string[] = []; const createdFiles: string[] = [];
@@ -39,7 +40,7 @@ describe("media storage helpers", () => {
expect(resolvedPath.endsWith(path.join("assets", "test.svg"))).toBe(true); expect(resolvedPath.endsWith(path.join("assets", "test.svg"))).toBe(true);
}); });
it("removes a managed file from disk", async () => { it.skipIf(!canManageUploads)("removes a managed file from disk", async () => {
const relativePath = `/uploads/media/tests/${Date.now()}-temp.txt`; const relativePath = `/uploads/media/tests/${Date.now()}-temp.txt`;
const absolutePath = resolveMediaUploadPath(relativePath); const absolutePath = resolveMediaUploadPath(relativePath);
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { readFlash, withFlash } from "@/lib/admin-feedback";
describe("withFlash", () => {
it("returns the plain path when there are no messages", () => {
expect(withFlash("/admin/smtp", {})).toBe("/admin/smtp");
});
it("appends a success message", () => {
expect(withFlash("/admin/smtp", { success: "Saved." })).toBe("/admin/smtp?success=Saved.");
});
it("appends an error message", () => {
expect(withFlash("/admin/smtp", { error: "Nope." })).toBe("/admin/smtp?error=Nope.");
});
it("appends both and url-encodes values", () => {
const result = withFlash("/admin/smtp", { success: "a b", error: "x&y" });
const params = new URL(result, "http://local").searchParams;
expect(params.get("success")).toBe("a b");
expect(params.get("error")).toBe("x&y");
});
});
describe("readFlash", () => {
it("reads success and error from resolved search params", () => {
expect(readFlash({ success: "ok", error: "bad" })).toEqual({ success: "ok", error: "bad" });
});
it("returns undefined fields when params are missing", () => {
expect(readFlash(undefined)).toEqual({ success: undefined, error: undefined });
expect(readFlash(null)).toEqual({ success: undefined, error: undefined });
expect(readFlash({})).toEqual({ success: undefined, error: undefined });
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { getAdminNavigation } from "@/lib/admin-navigation";
const copy = {
overview: "Overview",
maintenance: "Maintenance",
uiKit: "UI Kit",
portfolio: "Portfolio",
media: "Media",
siteSettings: "Site Settings",
brandSettings: "Brand",
localizationSettings: "Localization",
marquee: "Marquee",
smtp: "SMTP",
};
describe("getAdminNavigation", () => {
it("returns the full set of top-level sections", () => {
const nav = getAdminNavigation(copy, "overview");
const labels = nav.map((item) => item.label);
expect(labels).toEqual([
"Overview",
"Maintenance",
"UI Kit",
"Media",
"Site Settings",
"Marquee",
"SMTP",
"Portfolio",
]);
});
it("marks the active top-level section", () => {
const nav = getAdminNavigation(copy, "smtp");
expect(nav.find((item) => item.label === "SMTP")?.active).toBe(true);
expect(nav.find((item) => item.label === "Overview")?.active).toBe(false);
});
it("expands site-settings and marks the active child", () => {
const nav = getAdminNavigation(copy, "site-settings", undefined, "localization");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.expanded).toBe(true);
expect(siteSettings?.active).toBe(false); // has a child selected
const localization = siteSettings?.children?.find((c) => c.label === "Localization");
expect(localization?.active).toBe(true);
});
it("marks the parent active when no child is selected", () => {
const nav = getAdminNavigation(copy, "site-settings");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.active).toBe(true);
expect(siteSettings?.expanded).toBe(true);
});
it("maps portfolio children and de-duplicates hrefs", () => {
const nav = getAdminNavigation(copy, "portfolio", "projects");
const portfolio = nav.find((item) => item.label === "Portfolio");
expect(portfolio?.expanded).toBe(true);
const hrefs = portfolio?.children?.map((c) => c.href) ?? [];
expect(new Set(hrefs).size).toBe(hrefs.length); // unique
const projects = portfolio?.children?.find((c) => c.label === "Projects");
expect(projects?.active).toBe(true);
});
it("activates the new-project child", () => {
const nav = getAdminNavigation(copy, "portfolio", "new-project");
const portfolio = nav.find((item) => item.label === "Portfolio");
expect(portfolio?.children?.find((c) => c.label === "Add Project")?.active).toBe(true);
});
it("falls back to default child labels when copy omits them", () => {
const minimal = { ...copy, brandSettings: undefined, localizationSettings: undefined, marquee: undefined, smtp: undefined };
const nav = getAdminNavigation(minimal, "overview");
const siteSettings = nav.find((item) => item.label === "Site Settings");
expect(siteSettings?.children?.map((c) => c.label)).toEqual(["Brand", "Localization"]);
expect(nav.find((item) => item.href.endsWith("/marquee"))?.label).toBe("Marquee");
expect(nav.find((item) => item.href.endsWith("/smtp"))?.label).toBe("SMTP");
});
it("gives every item an icon and href", () => {
const nav = getAdminNavigation(copy, "overview");
for (const item of nav) {
expect(item.icon).toBeTruthy();
expect(typeof item.href).toBe("string");
}
});
});
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildAdminUrl,
buildSiteUrl,
fromDevelopmentAdminPath,
getAdminAppPath,
getAdminBaseUrl,
getAdminHost,
getRequestHostname,
getSiteBaseUrl,
getSiteHost,
hasDedicatedAdminHost,
INTERNAL_ADMIN_PREFIX,
isAdminHost,
isDevelopmentAdminPath,
isInternalAdminPath,
isLegacyAdminPath,
toInternalAdminPath,
} from "@/lib/admin-routing";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("getRequestHostname", () => {
it("prefers the first non-empty candidate", () => {
expect(getRequestHostname("root.mohfarawati.de", "internal")).toBe("root.mohfarawati.de");
});
it("strips ports and takes the first comma-separated proxy value", () => {
expect(getRequestHostname(undefined, "root.mohfarawati.de:443, proxy")).toBe("root.mohfarawati.de");
});
it("lowercases the hostname", () => {
expect(getRequestHostname("ROOT.MohFarawati.de")).toBe("root.mohfarawati.de");
});
it("falls back to empty string when nothing matches", () => {
expect(getRequestHostname(undefined, null, "")).toBe("");
});
});
describe("host configuration", () => {
it("defaults admin and site hosts", () => {
expect(getAdminHost()).toBe("root.mohfarawati.de");
expect(getSiteHost()).toBe("mohfarawati.de");
expect(hasDedicatedAdminHost()).toBe(true);
});
it("reads ADMIN_HOST override and normalizes case/whitespace", () => {
vi.stubEnv("ADMIN_HOST", " Admin.Example.COM ");
expect(getAdminHost()).toBe("admin.example.com");
});
it("derives the site host from NEXT_PUBLIC_SITE_URL", () => {
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.org/some/path");
expect(getSiteHost()).toBe("example.org");
});
it("hasDedicatedAdminHost is false when admin and site hosts match", () => {
vi.stubEnv("ADMIN_HOST", "example.com");
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
expect(hasDedicatedAdminHost()).toBe(false);
});
it("isAdminHost compares against the configured admin host", () => {
expect(isAdminHost("root.mohfarawati.de")).toBe(true);
expect(isAdminHost("mohfarawati.de")).toBe(false);
});
});
describe("path predicates", () => {
it("recognizes legacy /root paths", () => {
expect(isLegacyAdminPath("/root")).toBe(true);
expect(isLegacyAdminPath("/root/portfolio")).toBe(true);
expect(isLegacyAdminPath("/rooting")).toBe(false);
expect(isLegacyAdminPath("/")).toBe(false);
});
it("recognizes development /root paths", () => {
expect(isDevelopmentAdminPath("/root")).toBe(true);
expect(isDevelopmentAdminPath("/root/media")).toBe(true);
expect(isDevelopmentAdminPath("/rootx")).toBe(false);
});
it("recognizes internal admin paths", () => {
expect(isInternalAdminPath(INTERNAL_ADMIN_PREFIX)).toBe(true);
expect(isInternalAdminPath(`${INTERNAL_ADMIN_PREFIX}/smtp`)).toBe(true);
expect(isInternalAdminPath("/admin")).toBe(false);
});
});
describe("path translation", () => {
it("maps public paths to internal admin paths", () => {
expect(toInternalAdminPath("/")).toBe(INTERNAL_ADMIN_PREFIX);
expect(toInternalAdminPath("/portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
expect(toInternalAdminPath("portfolio")).toBe(`${INTERNAL_ADMIN_PREFIX}/portfolio`);
});
it("strips the dev /root prefix", () => {
expect(fromDevelopmentAdminPath("/root")).toBe("/");
expect(fromDevelopmentAdminPath("/root/portfolio")).toBe("/portfolio");
expect(fromDevelopmentAdminPath("/portfolio")).toBe("/portfolio");
});
});
describe("getAdminAppPath", () => {
it("returns bare paths when a dedicated admin host exists", () => {
// default env: admin host != site host -> dedicated host branch
expect(getAdminAppPath("/")).toBe("/");
expect(getAdminAppPath("/smtp")).toBe("/smtp");
});
it("uses the /root dev prefix when no dedicated host and not production", () => {
vi.stubEnv("ADMIN_HOST", "example.com");
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
vi.stubEnv("NODE_ENV", "development");
expect(getAdminAppPath("/")).toBe("/root");
expect(getAdminAppPath("/portfolio")).toBe("/root/portfolio");
});
it("returns bare paths in production even without a dedicated host", () => {
vi.stubEnv("ADMIN_HOST", "example.com");
vi.stubEnv("NEXT_PUBLIC_SITE_URL", "https://example.com");
vi.stubEnv("NODE_ENV", "production");
expect(getAdminAppPath("/portfolio")).toBe("/portfolio");
});
});
describe("url builders", () => {
it("builds admin urls from NEXT_PUBLIC_ADMIN_URL", () => {
expect(getAdminBaseUrl()).toBe("https://root.mohfarawati.de");
expect(buildAdminUrl("/smtp")).toBe("https://root.mohfarawati.de/smtp");
});
it("trims trailing slashes from configured base urls", () => {
vi.stubEnv("NEXT_PUBLIC_ADMIN_URL", "https://admin.example.com/");
expect(getAdminBaseUrl()).toBe("https://admin.example.com");
});
it("builds site urls from NEXT_PUBLIC_SITE_URL", () => {
expect(getSiteBaseUrl()).toBe("https://mohfarawati.de");
expect(buildSiteUrl("/about")).toBe("https://mohfarawati.de/about");
expect(buildSiteUrl("/")).toBe("https://mohfarawati.de/");
});
});
+122
View File
@@ -0,0 +1,122 @@
import { readFileSync, readdirSync, statSync } from "fs";
import path from "path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
function walk(dir: string, filter: (file: string) => boolean): string[] {
const absolute = path.join(ROOT, dir);
const results: string[] = [];
let entries: string[];
try {
entries = readdirSync(absolute);
} catch {
return results;
}
for (const entry of entries) {
if (entry === "node_modules" || entry === ".next") continue;
const full = path.join(absolute, entry);
const rel = path.relative(ROOT, full);
if (statSync(full).isDirectory()) {
results.push(...walk(rel, filter));
} else if (filter(full)) {
results.push(rel);
}
}
return results;
}
const read = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8");
const isSource = (file: string) => /\.(ts|tsx)$/.test(file) && !file.endsWith(".d.ts");
const componentFiles = walk("components", isSource);
const appFiles = walk("app", isSource);
const libFiles = walk("lib", isSource);
const actionFiles = [...appFiles].filter((file) => /(^|\/)actions\.tsx?$/.test(file));
describe("architecture: data access boundaries", () => {
it("no component imports the Prisma client", () => {
const offenders = componentFiles.filter((file) => /["']@\/lib\/prisma["']/.test(read(file)));
expect(offenders).toEqual([]);
});
it("no client component imports the Prisma client (defense in depth)", () => {
const offenders = [...componentFiles, ...appFiles].filter((file) => {
const source = read(file);
return /["']use client["']/.test(source) && /lib\/prisma/.test(source);
});
expect(offenders).toEqual([]);
});
it("code imports admin server actions from the canonical _admin source, never the mirrors", () => {
const offenders = [...componentFiles, ...appFiles, ...libFiles].filter((file) =>
/from\s+["']@\/app\/(root|admin-internal)\//.test(read(file)),
);
expect(offenders).toEqual([]);
});
it("lib modules never import from the app layer", () => {
const offenders = libFiles.filter((file) => {
const source = read(file);
return /from\s+["']@\/app\//.test(source) || /from\s+["']\.\.\/app\//.test(source);
});
expect(offenders).toEqual([]);
});
});
describe("architecture: server actions", () => {
it("finds the expected server action files", () => {
expect(actionFiles.length).toBeGreaterThan(0);
});
it('every actions file starts with the "use server" directive', () => {
for (const file of actionFiles) {
const firstMeaningfulLine = read(file)
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0);
expect(firstMeaningfulLine, file).toMatch(/^["']use server["'];?$/);
}
});
it("every admin action file enforces authentication", () => {
const adminActionFiles = actionFiles.filter((file) => file.includes(`${path.sep}_admin${path.sep}`));
expect(adminActionFiles.length).toBeGreaterThan(0);
for (const file of adminActionFiles) {
const source = read(file);
expect(
/ensureAdmin\s*\(/.test(source) ||
/requireAdminAuth\s*\(/.test(source) ||
/isAdminAuthenticated\s*\(/.test(source),
file,
).toBe(true);
}
});
});
describe("architecture: admin mirror parity", () => {
const canonicalPages = walk("app/_admin", (file) => /page\.tsx$/.test(file));
it("has admin pages to mirror", () => {
expect(canonicalPages.length).toBeGreaterThan(0);
});
for (const mirror of ["root", "admin-internal"]) {
it(`mirrors every _admin page under app/${mirror} via a re-export`, () => {
const missing: string[] = [];
for (const page of canonicalPages) {
const mirrored = page.replace(`app${path.sep}_admin${path.sep}`, `app${path.sep}${mirror}${path.sep}`);
try {
const source = read(mirrored);
if (!source.includes("_admin")) {
missing.push(`${mirrored} (does not re-export _admin)`);
}
} catch {
missing.push(`${mirrored} (missing)`);
}
}
expect(missing).toEqual([]);
});
}
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isCheckedFormValue } from "@/lib/form-data";
describe("isCheckedFormValue", () => {
it("treats standard checkbox values as checked", () => {
expect(isCheckedFormValue("on")).toBe(true);
expect(isCheckedFormValue("true")).toBe(true);
expect(isCheckedFormValue("1")).toBe(true);
});
it("treats other values as unchecked", () => {
expect(isCheckedFormValue("off")).toBe(false);
expect(isCheckedFormValue("false")).toBe(false);
expect(isCheckedFormValue("0")).toBe(false);
expect(isCheckedFormValue("")).toBe(false);
expect(isCheckedFormValue(null)).toBe(false);
expect(isCheckedFormValue("yes")).toBe(false);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
FALLBACK_LOCALE,
getDirection,
getLocalizedPath,
getLocalizedPathWithDefault,
isSupportedLocale,
resolveLocale,
stripLocalePrefix,
} from "@/lib/locale";
describe("isSupportedLocale", () => {
it("accepts the three app locales", () => {
expect(isSupportedLocale("de")).toBe(true);
expect(isSupportedLocale("en")).toBe(true);
expect(isSupportedLocale("ar")).toBe(true);
});
it("rejects everything else", () => {
expect(isSupportedLocale("fr")).toBe(false);
expect(isSupportedLocale("")).toBe(false);
expect(isSupportedLocale(undefined)).toBe(false);
expect(isSupportedLocale(null)).toBe(false);
});
});
describe("resolveLocale", () => {
it("keeps supported locales", () => {
expect(resolveLocale("ar", "de")).toBe("ar");
});
it("falls back for unsupported locales", () => {
expect(resolveLocale("fr", "en")).toBe("en");
expect(resolveLocale(undefined, FALLBACK_LOCALE)).toBe("de");
expect(resolveLocale(null, "ar")).toBe("ar");
});
});
describe("getDirection", () => {
it("is rtl for arabic only", () => {
expect(getDirection("ar")).toBe("rtl");
expect(getDirection("de")).toBe("ltr");
expect(getDirection("en")).toBe("ltr");
expect(getDirection("fr")).toBe("ltr");
});
});
describe("stripLocalePrefix", () => {
it("removes a bare locale prefix", () => {
expect(stripLocalePrefix("/de")).toBe("/");
expect(stripLocalePrefix("/ar")).toBe("/");
});
it("removes a nested locale prefix", () => {
expect(stripLocalePrefix("/en/about")).toBe("/about");
expect(stripLocalePrefix("/ar/portfolio/x")).toBe("/portfolio/x");
});
it("returns unprefixed paths unchanged", () => {
expect(stripLocalePrefix("/about")).toBe("/about");
expect(stripLocalePrefix("/")).toBe("/");
expect(stripLocalePrefix("")).toBe("/");
});
it("does not strip lookalike segments", () => {
expect(stripLocalePrefix("/design")).toBe("/design");
});
});
describe("getLocalizedPathWithDefault", () => {
it("keeps the default locale on the bare domain", () => {
expect(getLocalizedPathWithDefault("ar", "/", "ar")).toBe("/");
expect(getLocalizedPathWithDefault("de", "/", "ar")).toBe("/de");
expect(getLocalizedPathWithDefault("en", "/", "ar")).toBe("/en");
});
it("builds nested paths against the configured default", () => {
expect(getLocalizedPathWithDefault("ar", "/coming-soon", "ar")).toBe("/coming-soon");
expect(getLocalizedPathWithDefault("de", "/coming-soon", "ar")).toBe("/de/coming-soon");
expect(getLocalizedPathWithDefault("en", "/portfolio", "de")).toBe("/en/portfolio");
});
it("re-bases an already-prefixed path onto the requested locale", () => {
expect(getLocalizedPathWithDefault("en", "/ar/contact", "de")).toBe("/en/contact");
expect(getLocalizedPathWithDefault("ar", "/de/about", "ar")).toBe("/about");
});
it("normalizes an empty path to root", () => {
expect(getLocalizedPathWithDefault("de", "", "de")).toBe("/");
expect(getLocalizedPathWithDefault("en", "", "de")).toBe("/en");
});
it("falls back to the default locale for unsupported input", () => {
expect(getLocalizedPathWithDefault("fr", "/about", "de")).toBe("/about");
});
it("getLocalizedPath is an alias of getLocalizedPathWithDefault", () => {
expect(getLocalizedPath("en", "/about", "de")).toBe(
getLocalizedPathWithDefault("en", "/about", "de"),
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import type { MailSettings } from "@/lib/mail-settings";
import { createSmtpTransport, sendContactMessage, sendMail, sendTestEmail } from "@/lib/mail";
function settings(overrides: Partial<MailSettings> = {}): MailSettings {
return {
smtp: { host: "smtp.example.com", port: 587, secure: false, username: "mailer", password: "secret", ...overrides.smtp },
sender: { email: "hello@example.com", name: "Studio", ...overrides.sender },
recipients: { contact: "contact@example.com", test: "test@example.com", ...overrides.recipients },
};
}
function mockTransport() {
const sendMailMock = vi.fn().mockResolvedValue({});
const createTransport = vi.fn().mockReturnValue({ sendMail: sendMailMock });
return { sendMailMock, createTransport };
}
describe("createSmtpTransport", () => {
it("builds the transport with host, port, secure, and auth", () => {
const { createTransport } = mockTransport();
createSmtpTransport(settings({ smtp: { host: "h", port: 465, secure: true, username: "u", password: "p" } }), createTransport);
expect(createTransport).toHaveBeenCalledWith({
host: "h",
port: 465,
secure: true,
auth: { user: "u", pass: "p" },
});
});
it("requires host, username and password", () => {
const { createTransport } = mockTransport();
expect(() => createSmtpTransport(settings({ smtp: { host: "", port: 587, secure: false, username: "u", password: "p" } }), createTransport)).toThrow(/host/i);
expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "", password: "p" } }), createTransport)).toThrow(/username/i);
expect(() => createSmtpTransport(settings({ smtp: { host: "h", port: 587, secure: false, username: "u", password: "" } }), createTransport)).toThrow(/password/i);
});
});
describe("sendMail", () => {
it("formats the from header with the sender name", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings(), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "Studio <hello@example.com>", to: "x@y.z" }));
});
it("omits the display name when sender name is blank", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "hello@example.com", name: "" } }), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ from: "hello@example.com" }));
});
it("requires a from email", async () => {
const { createTransport } = mockTransport();
await expect(
sendMail({ to: "x@y.z", subject: "S", text: "T" }, { settings: settings({ sender: { email: "", name: "" } }), createTransport }),
).rejects.toThrow(/from email/i);
});
});
describe("sendContactMessage", () => {
it("targets the contact recipient with reply-to and full body", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendContactMessage(
{ locale: "en", name: "Jane", email: "jane@x.z", phone: "123", company: "Acme", message: "Hi there team." },
{ settings: settings(), createTransport },
);
const call = sendMailMock.mock.calls[0][0];
expect(call).toMatchObject({ to: "contact@example.com", subject: "New contact message", replyTo: "jane@x.z" });
expect(call.text).toContain("Name: Jane");
expect(call.text).toContain("Phone: 123");
expect(call.text).toContain("Company: Acme");
expect(call.text).toContain("Hi there team.");
});
it("falls back to the test recipient when contact is empty", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendContactMessage(
{ locale: "de", name: "Jane", email: "jane@x.z", message: "Fallback works fine." },
{ settings: settings({ recipients: { contact: "", test: "fallback@x.z" } }), createTransport },
);
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "fallback@x.z" }));
});
it("renders dashes for missing optional fields", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendContactMessage(
{ locale: "en", name: "Jane", email: "jane@x.z", message: "No phone or company." },
{ settings: settings(), createTransport },
);
const call = sendMailMock.mock.calls[0][0];
expect(call.text).toContain("Phone: -");
expect(call.text).toContain("Company: -");
});
});
describe("sendTestEmail", () => {
it("sends to the test recipient", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendTestEmail({ settings: settings(), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "test@example.com", subject: "SMTP test email" }));
});
it("falls back to the contact recipient when test is empty", async () => {
const { sendMailMock, createTransport } = mockTransport();
await sendTestEmail({ settings: settings({ recipients: { contact: "c@x.z", test: "" } }), createTransport });
expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({ to: "c@x.z" }));
});
it("propagates transport failures", async () => {
const createTransport = vi.fn().mockReturnValue({ sendMail: vi.fn().mockRejectedValue(new Error("Auth failed.")) });
await expect(sendTestEmail({ settings: settings(), createTransport })).rejects.toThrow("Auth failed.");
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultMarqueeSettings,
parseMarqueeSettingsValue,
splitMarqueeRowItems,
syncMarqueeSettingsToGermanSource,
} from "@/lib/marquee-settings";
describe("buildDefaultMarqueeSettings", () => {
it("provides all four rows for every locale", () => {
const settings = buildDefaultMarqueeSettings();
for (const locale of ["ar", "en", "de"] as const) {
expect(settings.locales[locale].row1).toContain("Next.js");
expect(settings.locales[locale].row2).toContain("TypeScript");
expect(settings.locales[locale].row3).toContain("JavaScript");
expect(settings.locales[locale].row4).toContain("Frontend Strategy");
}
});
});
describe("syncMarqueeSettingsToGermanSource", () => {
it("copies the german rows over english and arabic", () => {
const base = buildDefaultMarqueeSettings();
base.locales.de.row1 = "GERMAN";
base.locales.en.row1 = "english";
base.locales.ar.row1 = "arabic";
const synced = syncMarqueeSettingsToGermanSource(base);
expect(synced.locales.de.row1).toBe("GERMAN");
expect(synced.locales.en.row1).toBe("GERMAN");
expect(synced.locales.ar.row1).toBe("GERMAN");
});
it("returns independent copies (no shared references)", () => {
const synced = syncMarqueeSettingsToGermanSource(buildDefaultMarqueeSettings());
synced.locales.en.row1 = "changed";
expect(synced.locales.de.row1).not.toBe("changed");
});
});
describe("parseMarqueeSettingsValue", () => {
it("returns german-synced defaults for empty input", () => {
const settings = parseMarqueeSettingsValue(null);
expect(settings.locales.en.row1).toBe(settings.locales.de.row1);
expect(settings.locales.de.row1).toContain("Next.js");
});
it("returns defaults for invalid json", () => {
const settings = parseMarqueeSettingsValue("{not json");
expect(settings.locales.de.row1).toContain("Next.js");
});
it("normalizes stored values, trims, and syncs to german", () => {
const settings = parseMarqueeSettingsValue(
JSON.stringify({
locales: {
de: { row1: " Custom Row 1 ", row2: "R2", row3: "R3", row4: "R4" },
en: { row1: "IGNORED" },
},
}),
);
expect(settings.locales.de.row1).toBe("Custom Row 1");
// english is overwritten by the german source
expect(settings.locales.en.row1).toBe("Custom Row 1");
expect(settings.locales.ar.row2).toBe("R2");
});
it("falls back to per-row defaults when a row is blank", () => {
const settings = parseMarqueeSettingsValue(
JSON.stringify({ locales: { de: { row1: " ", row2: "", row3: "R3", row4: "R4" } } }),
);
expect(settings.locales.de.row1).toContain("Next.js");
expect(settings.locales.de.row3).toBe("R3");
});
});
describe("splitMarqueeRowItems", () => {
it("splits on newlines and commas and trims blanks", () => {
expect(splitMarqueeRowItems("A\nB, C\n\n , D")).toEqual(["A", "B", "C", "D"]);
});
it("returns an empty array for whitespace-only input", () => {
expect(splitMarqueeRowItems(" \n ")).toEqual([]);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
getKindFromUploadFile,
inferMediaKindFromFileName,
inferMediaKindFromMimeType,
} from "@/lib/media-service";
describe("inferMediaKindFromMimeType", () => {
it("classifies image mime types as IMAGE", () => {
expect(inferMediaKindFromMimeType("image/png")).toBe("IMAGE");
expect(inferMediaKindFromMimeType("image/svg+xml")).toBe("IMAGE");
expect(inferMediaKindFromMimeType("image/gif")).toBe("IMAGE");
});
it("classifies everything else as DOCUMENT", () => {
expect(inferMediaKindFromMimeType("application/pdf")).toBe("DOCUMENT");
expect(inferMediaKindFromMimeType(null)).toBe("DOCUMENT");
expect(inferMediaKindFromMimeType(undefined)).toBe("DOCUMENT");
expect(inferMediaKindFromMimeType("")).toBe("DOCUMENT");
});
});
describe("inferMediaKindFromFileName", () => {
it("treats known image extensions as IMAGE (case-insensitive)", () => {
for (const name of ["a.gif", "a.jpg", "a.jpeg", "a.PNG", "a.webp", "a.SVG"]) {
expect(inferMediaKindFromFileName(name)).toBe("IMAGE");
}
});
it("treats other extensions as DOCUMENT", () => {
expect(inferMediaKindFromFileName("report.pdf")).toBe("DOCUMENT");
expect(inferMediaKindFromFileName("noext")).toBe("DOCUMENT");
});
});
describe("getKindFromUploadFile", () => {
it("derives IMAGE from an image mime type", () => {
const file = new File([new Uint8Array([1])], "logo.png", { type: "image/png" });
expect(getKindFromUploadFile(file)).toBe("IMAGE");
});
it("derives DOCUMENT from a pdf mime type", () => {
const file = new File([new Uint8Array([1])], "doc.pdf", { type: "application/pdf" });
expect(getKindFromUploadFile(file)).toBe("DOCUMENT");
});
it("falls back to DOCUMENT for unknown mime types", () => {
const file = new File([new Uint8Array([1])], "thing.bin", { type: "application/octet-stream" });
expect(getKindFromUploadFile(file)).toBe("DOCUMENT");
});
});
+85
View File
@@ -0,0 +1,85 @@
import path from "path";
import { describe, expect, it } from "vitest";
import {
MAX_MEDIA_FILE_SIZE,
MEDIA_UPLOAD_ROOT,
getExtensionForMimeType,
isManagedMediaFilePath,
removeManagedMediaFile,
resolveMediaUploadPath,
sanitizeBaseName,
} from "@/lib/media-storage";
describe("sanitizeBaseName", () => {
it("lowercases, hyphenates, and strips symbols", () => {
expect(sanitizeBaseName("Brand Redesign 2026!.svg")).toBe("brand-redesign-2026-svg");
});
it("collapses repeated separators and trims edges", () => {
expect(sanitizeBaseName("--Hello___World--")).toBe("hello-world");
});
it("truncates to 60 characters", () => {
expect(sanitizeBaseName("a".repeat(100)).length).toBe(60);
});
});
describe("getExtensionForMimeType", () => {
it("maps known image and document mime types", () => {
expect(getExtensionForMimeType("image/gif")).toBe(".gif");
expect(getExtensionForMimeType("image/jpeg")).toBe(".jpg");
expect(getExtensionForMimeType("image/png")).toBe(".png");
expect(getExtensionForMimeType("image/webp")).toBe(".webp");
expect(getExtensionForMimeType("image/svg+xml")).toBe(".svg");
expect(getExtensionForMimeType("image/x-icon")).toBe(".ico");
expect(getExtensionForMimeType("image/vnd.microsoft.icon")).toBe(".ico");
expect(getExtensionForMimeType("application/pdf")).toBe(".pdf");
});
it("returns null for unknown mime types", () => {
expect(getExtensionForMimeType("application/zip")).toBeNull();
expect(getExtensionForMimeType("")).toBeNull();
});
});
describe("isManagedMediaFilePath", () => {
it("accepts managed upload paths only", () => {
expect(isManagedMediaFilePath("/uploads/media/covers/x.svg")).toBe(true);
expect(isManagedMediaFilePath("https://example.com/x.svg")).toBe(false);
expect(isManagedMediaFilePath("../x.svg")).toBe(false);
expect(isManagedMediaFilePath("/uploads/other/x.svg")).toBe(false);
expect(isManagedMediaFilePath(null)).toBe(false);
expect(isManagedMediaFilePath(undefined)).toBe(false);
});
});
describe("resolveMediaUploadPath", () => {
it("resolves managed paths inside the upload root", () => {
const resolved = resolveMediaUploadPath("/uploads/media/assets/test.svg");
expect(resolved.startsWith(MEDIA_UPLOAD_ROOT)).toBe(true);
expect(resolved.endsWith(path.join("assets", "test.svg"))).toBe(true);
});
it("throws for unmanaged paths", () => {
expect(() => resolveMediaUploadPath("https://example.com/x.svg")).toThrow(/managed/i);
});
it("throws when a traversal attempt escapes the root", () => {
expect(() => resolveMediaUploadPath("/uploads/media/../../etc/passwd")).toThrow(/escapes/i);
});
});
describe("removeManagedMediaFile", () => {
it("returns false without touching disk for unmanaged paths", async () => {
await expect(removeManagedMediaFile("https://example.com/x.svg")).resolves.toBe(false);
await expect(removeManagedMediaFile(null)).resolves.toBe(false);
});
});
describe("constants", () => {
it("caps media uploads at 5 MB", () => {
expect(MAX_MEDIA_FILE_SIZE).toBe(5 * 1024 * 1024);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { mediaFieldInputSchema } from "@/lib/media-validation";
const base = { assetId: "", url: "", label: "", kind: "IMAGE" as const };
describe("mediaFieldInputSchema", () => {
it("accepts a valid library selection", () => {
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "library", assetId: "asset_1" });
expect(parsed.mode).toBe("library");
expect(parsed.assetId).toBe("asset_1");
});
it("requires an assetId in library mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "library" })).toThrow(/media asset/i);
});
it("accepts a valid external url", () => {
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "external", url: "https://cdn/x.png" });
expect(parsed.url).toBe("https://cdn/x.png");
});
it("accepts a root-relative external url", () => {
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "external", url: "/uploads/media/x.png" });
expect(parsed.url).toBe("/uploads/media/x.png");
});
it("requires a url in external mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "external" })).toThrow(/URL/i);
});
it("rejects malformed urls", () => {
expect(() =>
mediaFieldInputSchema.parse({ ...base, mode: "external", url: "not-a-url" }),
).toThrow(/absolute URL or start with/i);
});
it("accepts upload mode without asset or url", () => {
const parsed = mediaFieldInputSchema.parse({ ...base, mode: "upload" });
expect(parsed.mode).toBe("upload");
});
it("rejects an unknown mode", () => {
expect(() => mediaFieldInputSchema.parse({ ...base, mode: "sideload" })).toThrow();
});
it("rejects an invalid media kind", () => {
expect(() =>
mediaFieldInputSchema.parse({ ...base, mode: "upload", kind: "VIDEO" }),
).toThrow();
});
it("trims text fields and defaults optionals to empty strings", () => {
const parsed = mediaFieldInputSchema.parse({
mode: "library",
assetId: " asset_9 ",
label: " Logo ",
kind: "IMAGE",
});
expect(parsed.assetId).toBe("asset_9");
expect(parsed.label).toBe("Logo");
expect(parsed.url).toBe("");
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { buildDefaultSiteSettings } from "@/lib/site-settings";
import {
applyTitleTemplateFn,
buildAppMetadataFromConfig,
buildLocaleAlternates,
buildLocalizedMetadataFromConfig,
} from "@/lib/metadata";
const noBindings = {
siteLogoLight: null,
siteLogoDark: null,
favicon: null,
defaultOgImage: null,
};
describe("applyTitleTemplateFn", () => {
it("substitutes page title and site name", () => {
expect(applyTitleTemplateFn("About", "{pageTitle} | {siteName}", "Studio")).toBe("About | Studio");
});
it("replaces every site-name token but only the first page-title token", () => {
expect(applyTitleTemplateFn("P", "{siteName} {pageTitle} {siteName}", "S")).toBe("S P S");
});
it("falls back to a default template when the token is missing", () => {
expect(applyTitleTemplateFn("About", "Just Site", "Studio")).toBe("About | Studio");
});
});
describe("buildLocaleAlternates", () => {
it("builds canonical, hreflang, and x-default against the default locale", () => {
const alt = buildLocaleAlternates("/about", "ar");
expect(alt.canonical).toBe("https://mohfarawati.de/about");
expect(alt.languages.ar).toBe("https://mohfarawati.de/about");
expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
expect(alt.languages.en).toBe("https://mohfarawati.de/en/about");
expect(alt.languages["x-default"]).toBe("https://mohfarawati.de/about");
});
it("shifts prefixes when the default locale changes", () => {
const alt = buildLocaleAlternates("/about", "en");
expect(alt.canonical).toBe("https://mohfarawati.de/about");
expect(alt.languages.en).toBe("https://mohfarawati.de/about");
expect(alt.languages.de).toBe("https://mohfarawati.de/de/about");
});
});
describe("buildAppMetadataFromConfig", () => {
it("uses summary twitter card and omits og images when unset", () => {
const settings = buildDefaultSiteSettings("Studio");
const metadata = buildAppMetadataFromConfig(settings, noBindings);
expect(metadata.title).toBe("Studio");
expect(metadata.twitter).toMatchObject({ card: "summary" });
expect(metadata.openGraph?.images).toBeUndefined();
expect(metadata.metadataBase?.toString()).toBe("https://mohfarawati.de/");
});
it("uses summary_large_image and a versioned favicon when bindings exist", () => {
const settings = buildDefaultSiteSettings("Studio");
const metadata = buildAppMetadataFromConfig(settings, {
...noBindings,
favicon: { assetId: "f", url: "/uploads/media/site-settings/favicon.svg", version: "v9" },
defaultOgImage: { assetId: "og", url: "/uploads/media/site-settings/og.png", version: "v9" },
});
expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
expect(metadata.icons).toMatchObject({ icon: [{ url: "/favicon.ico?v=v9" }] });
});
});
describe("buildLocalizedMetadataFromConfig", () => {
it("applies the title template and localized description by default", () => {
const settings = buildDefaultSiteSettings("Studio");
settings.locales.en.siteDescription = "English description";
const metadata = buildLocalizedMetadataFromConfig({
settings,
bindings: noBindings,
locale: "en",
pathname: "/about",
title: "About",
});
expect(metadata.title).toBe("About | Studio");
expect(metadata.description).toBe("English description");
expect(metadata.openGraph?.locale).toBe("en");
});
it("can skip the title template (homepage)", () => {
const settings = buildDefaultSiteSettings("Studio");
const metadata = buildLocalizedMetadataFromConfig({
settings,
bindings: noBindings,
locale: "ar",
pathname: "/",
title: "الرئيسية",
applyTitleTemplate: false,
});
expect(metadata.title).toBe("الرئيسية");
});
it("prefers an explicit description over the locale default", () => {
const settings = buildDefaultSiteSettings("Studio");
const metadata = buildLocalizedMetadataFromConfig({
settings,
bindings: noBindings,
locale: "de",
pathname: "/x",
title: "T",
description: " Custom desc ",
});
expect(metadata.description).toBe("Custom desc");
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
getFirstIncompleteWizardStep,
getPortfolioWizardProgress,
isPortfolioAssetReady,
isPortfolioSectionReady,
} from "@/lib/portfolio-form-progress";
const titles = { titleAr: "ع", titleEn: "en", titleDe: "de" };
function section(overrides: Record<string, unknown> = {}) {
return {
type: "RICH_TEXT" as const,
...titles,
bodyAr: "ب",
bodyEn: "body",
bodyDe: "koerper",
linkUrl: "",
mediaAssetId: "",
...overrides,
};
}
describe("isPortfolioSectionReady", () => {
it("requires titles in all languages", () => {
expect(isPortfolioSectionReady(section({ titleEn: "" }))).toBe(false);
});
it("RICH_TEXT/STATS/DELIVERABLES require body in all languages", () => {
for (const type of ["RICH_TEXT", "STATS", "DELIVERABLES"] as const) {
expect(isPortfolioSectionReady(section({ type }))).toBe(true);
expect(isPortfolioSectionReady(section({ type, bodyDe: "" }))).toBe(false);
}
});
it("GALLERY requires a media asset id", () => {
expect(isPortfolioSectionReady(section({ type: "GALLERY", mediaAssetId: "asset_1" }))).toBe(true);
expect(isPortfolioSectionReady(section({ type: "GALLERY", mediaAssetId: "" }))).toBe(false);
});
it("LINK requires a link url", () => {
expect(isPortfolioSectionReady(section({ type: "LINK", linkUrl: "https://x" }))).toBe(true);
expect(isPortfolioSectionReady(section({ type: "LINK", linkUrl: "" }))).toBe(false);
});
});
describe("isPortfolioAssetReady", () => {
it("requires media asset id and alt text in all languages", () => {
expect(isPortfolioAssetReady({ mediaAssetId: "a", altAr: "ع", altEn: "e", altDe: "d" })).toBe(true);
expect(isPortfolioAssetReady({ mediaAssetId: "", altAr: "ع", altEn: "e", altDe: "d" })).toBe(false);
expect(isPortfolioAssetReady({ mediaAssetId: "a", altAr: "ع", altEn: "", altDe: "d" })).toBe(false);
});
});
describe("getPortfolioWizardProgress", () => {
const completeInput = {
basics: {
categoryId: "cat_1",
slug: "case-study",
clientName: "Client",
projectYear: "2025",
sortOrder: "1",
viewMode: "GRID" as const,
},
content: {
titleAr: "ع", titleEn: "t", titleDe: "t",
serviceLabelAr: "خ", serviceLabelEn: "s", serviceLabelDe: "s",
summaryAr: "م", summaryEn: "sum", summaryDe: "zus",
},
sections: [section()],
assets: [{ mediaAssetId: "asset_1", altAr: "ع", altEn: "e", altDe: "d" }],
};
it("marks all steps complete for a fully filled project", () => {
const progress = getPortfolioWizardProgress(completeInput);
expect(progress.every((step) => step.complete)).toBe(true);
expect(getFirstIncompleteWizardStep(progress)).toBeNull();
});
it("rejects an invalid slug", () => {
const progress = getPortfolioWizardProgress({
...completeInput,
basics: { ...completeInput.basics, slug: "Invalid Slug" },
});
expect(progress.find((s) => s.key === "basics")?.complete).toBe(false);
expect(getFirstIncompleteWizardStep(progress)).toBe("basics");
});
it("enforces year bounds 2000..2100", () => {
const before = getPortfolioWizardProgress({
...completeInput,
basics: { ...completeInput.basics, projectYear: "1999" },
});
expect(before.find((s) => s.key === "basics")?.complete).toBe(false);
const after = getPortfolioWizardProgress({
...completeInput,
basics: { ...completeInput.basics, projectYear: "2101" },
});
expect(after.find((s) => s.key === "basics")?.complete).toBe(false);
});
it("enforces sortOrder bounds 0..9999", () => {
const progress = getPortfolioWizardProgress({
...completeInput,
basics: { ...completeInput.basics, sortOrder: "-1" },
});
expect(progress.find((s) => s.key === "basics")?.complete).toBe(false);
});
it("content is incomplete when a summary locale is missing", () => {
const progress = getPortfolioWizardProgress({
...completeInput,
content: { ...completeInput.content, summaryEn: "" },
});
expect(progress.find((s) => s.key === "content")?.complete).toBe(false);
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
});
it("sections/assets steps require at least one ready entry", () => {
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
});
it("summarizes section/asset readiness counts", () => {
const progress = getPortfolioWizardProgress({
...completeInput,
sections: [section(), section({ titleEn: "" })],
});
expect(progress.find((s) => s.key === "sections")?.summary).toBe("1/2 sections ready.");
expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import {
assetInputSchema,
categoryInputSchema,
projectInputSchema,
sectionInputSchema,
} from "@/lib/portfolio-validation";
function category(overrides: Record<string, unknown> = {}) {
return {
slug: "branding",
nameAr: "الهوية", nameEn: "Branding", nameDe: "Branding",
descriptionAr: "وصف", descriptionEn: "Description", descriptionDe: "Beschreibung",
sortOrder: 1,
isActive: true,
...overrides,
};
}
function baseMedia(overrides: Record<string, unknown> = {}) {
return { mode: "external", assetId: "", url: "https://x/y.png", label: "L", kind: "IMAGE", ...overrides };
}
function section(overrides: Record<string, unknown> = {}) {
return {
type: "RICH_TEXT",
titleAr: "ع", titleEn: "t", titleDe: "t",
bodyAr: "ب", bodyEn: "b", bodyDe: "b",
imagePath: "",
linkUrl: "",
sortOrder: 0,
...overrides,
};
}
function project(overrides: Record<string, unknown> = {}) {
return {
categoryId: "cat_1",
slug: "case-study",
viewMode: "GRID",
titleAr: "ع", titleEn: "T", titleDe: "T",
summaryAr: "م", summaryEn: "S", summaryDe: "S",
clientName: "Client",
projectYear: 2025,
serviceLabelAr: "خ", serviceLabelEn: "Svc", serviceLabelDe: "Svc",
previewUrl: "https://example.com",
currentCoverImagePath: "",
sortOrder: 1,
isFeatured: false,
isPublished: true,
sections: [],
assets: [],
...overrides,
};
}
describe("categoryInputSchema", () => {
it("accepts a valid payload", () => {
expect(categoryInputSchema.parse(category()).slug).toBe("branding");
});
it("requires lowercase hyphenated slugs", () => {
expect(() => categoryInputSchema.parse(category({ slug: "Not Valid" }))).toThrow(/slug/i);
expect(() => categoryInputSchema.parse(category({ slug: "-leading" }))).toThrow(/slug/i);
expect(categoryInputSchema.parse(category({ slug: "multi-word-slug" })).slug).toBe("multi-word-slug");
});
it("requires all name and description locales", () => {
expect(() => categoryInputSchema.parse(category({ nameEn: "" }))).toThrow(/nameEn/i);
expect(() => categoryInputSchema.parse(category({ descriptionDe: " " }))).toThrow(/descriptionDe/i);
});
it("coerces sortOrder and enforces its range", () => {
expect(categoryInputSchema.parse(category({ sortOrder: "5" })).sortOrder).toBe(5);
expect(() => categoryInputSchema.parse(category({ sortOrder: 10000 }))).toThrow();
expect(() => categoryInputSchema.parse(category({ sortOrder: -1 }))).toThrow();
});
});
describe("sectionInputSchema", () => {
it("accepts a RICH_TEXT section with body", () => {
expect(sectionInputSchema.parse(section()).type).toBe("RICH_TEXT");
});
it("requires body for RICH_TEXT, STATS and DELIVERABLES", () => {
for (const type of ["RICH_TEXT", "STATS", "DELIVERABLES"]) {
expect(() => sectionInputSchema.parse(section({ type, bodyEn: "" }))).toThrow(/body/i);
}
});
it("requires an image for GALLERY sections", () => {
expect(() =>
sectionInputSchema.parse(section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", imagePath: "" })),
).toThrow(/image/i);
expect(
sectionInputSchema.parse(
section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", imagePath: "/uploads/media/x.svg" }),
).type,
).toBe("GALLERY");
expect(
sectionInputSchema.parse(
section({ type: "GALLERY", bodyAr: "", bodyEn: "", bodyDe: "", media: baseMedia({ mode: "library", assetId: "a" }) }),
).type,
).toBe("GALLERY");
});
it("requires a link url for LINK sections", () => {
expect(() =>
sectionInputSchema.parse(section({ type: "LINK", bodyAr: "", bodyEn: "", bodyDe: "", linkUrl: "" })),
).toThrow(/link/i);
expect(
sectionInputSchema.parse(section({ type: "LINK", bodyAr: "", bodyEn: "", bodyDe: "", linkUrl: "https://x" })).linkUrl,
).toBe("https://x");
});
it("rejects malformed link urls", () => {
expect(() => sectionInputSchema.parse(section({ type: "LINK", linkUrl: "javascript:alert(1)" }))).toThrow(/absolute URL/i);
});
it("requires titles in all languages", () => {
expect(() => sectionInputSchema.parse(section({ titleAr: "" }))).toThrow(/titleAr/i);
});
});
describe("assetInputSchema", () => {
it("accepts a valid image asset", () => {
const parsed = assetInputSchema.parse({
kind: "IMAGE",
filePath: "/uploads/media/assets/x.svg",
fileFieldName: "",
media: baseMedia(),
altAr: "ع", altEn: "a", altDe: "a",
sortOrder: 0,
});
expect(parsed.kind).toBe("IMAGE");
});
it("only allows the IMAGE kind", () => {
expect(() =>
assetInputSchema.parse({ kind: "DOCUMENT", altAr: "ع", altEn: "a", altDe: "a", sortOrder: 0 }),
).toThrow();
});
it("requires alt text in all languages", () => {
expect(() =>
assetInputSchema.parse({ kind: "IMAGE", altAr: "ع", altEn: "", altDe: "a", sortOrder: 0 }),
).toThrow(/altEn/i);
});
it("rejects invalid embedded media urls", () => {
expect(() =>
assetInputSchema.parse({
kind: "IMAGE",
media: baseMedia({ url: "not-a-url" }),
altAr: "ع", altEn: "a", altDe: "a",
sortOrder: 0,
}),
).toThrow(/url/i);
});
});
describe("projectInputSchema", () => {
it("accepts a fully valid project", () => {
expect(projectInputSchema.parse(project()).slug).toBe("case-study");
});
it("defaults viewMode to GRID and accepts known modes", () => {
expect(projectInputSchema.parse(project({ viewMode: undefined })).viewMode).toBe("GRID");
expect(projectInputSchema.parse(project({ viewMode: "CASE_STUDY" })).viewMode).toBe("CASE_STUDY");
expect(() => projectInputSchema.parse(project({ viewMode: "WILD" }))).toThrow();
});
it("rejects invalid slugs", () => {
expect(() => projectInputSchema.parse(project({ slug: "Bad Slug" }))).toThrow(/slug/i);
});
it("coerces and bounds projectYear", () => {
expect(projectInputSchema.parse(project({ projectYear: "2025" })).projectYear).toBe(2025);
expect(() => projectInputSchema.parse(project({ projectYear: 1999 }))).toThrow();
expect(() => projectInputSchema.parse(project({ projectYear: 2101 }))).toThrow();
});
it("requires all localized content fields", () => {
expect(() => projectInputSchema.parse(project({ summaryDe: "" }))).toThrow(/summaryDe/i);
expect(() => projectInputSchema.parse(project({ serviceLabelAr: "" }))).toThrow(/serviceLabelAr/i);
expect(() => projectInputSchema.parse(project({ clientName: "" }))).toThrow(/clientName/i);
});
it("allows an empty preview url but rejects a relative one", () => {
expect(projectInputSchema.parse(project({ previewUrl: "" })).previewUrl).toBe("");
expect(() => projectInputSchema.parse(project({ previewUrl: "/relative" }))).toThrow(/absolute URL/i);
});
it("validates nested sections and assets", () => {
expect(() =>
projectInputSchema.parse(project({ sections: [section({ titleEn: "" })] })),
).toThrow(/titleEn/i);
expect(
projectInputSchema.parse(
project({ assets: [{ kind: "IMAGE", altAr: "ع", altEn: "a", altDe: "a", sortOrder: 0, media: baseMedia() }] }),
).assets.length,
).toBe(1);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { getLocalizedValue, resolvePortfolioProjectViewMode } from "@/lib/portfolio";
describe("resolvePortfolioProjectViewMode", () => {
it("keeps supported view modes", () => {
expect(resolvePortfolioProjectViewMode("GRID")).toBe("GRID");
expect(resolvePortfolioProjectViewMode("STORY")).toBe("STORY");
expect(resolvePortfolioProjectViewMode("CASE_STUDY")).toBe("CASE_STUDY");
});
it("falls back to GRID for unknown or missing values", () => {
expect(resolvePortfolioProjectViewMode(undefined)).toBe("GRID");
expect(resolvePortfolioProjectViewMode(null)).toBe("GRID");
expect(resolvePortfolioProjectViewMode("unexpected")).toBe("GRID");
});
});
describe("getLocalizedValue", () => {
const content = { ar: "عربي", en: "English", de: "Deutsch" };
it("returns the direct locale value when present", () => {
expect(getLocalizedValue(content, "en")).toBe("English");
expect(getLocalizedValue(content, "ar")).toBe("عربي");
});
it("falls back to the provided fallback locale", () => {
expect(getLocalizedValue({ ar: "", en: "", de: "Deutsch" }, "en", "de")).toBe("Deutsch");
});
it("falls back to any available value when neither locale is filled", () => {
expect(getLocalizedValue({ ar: "عربي", en: "", de: "" }, "en", "de")).toBe("عربي");
});
it("trims whitespace-only values before considering them empty", () => {
expect(getLocalizedValue({ ar: " ", en: " ", de: "Deutsch" }, "en", "de")).toBe("Deutsch");
});
it("returns an empty string when everything is blank", () => {
expect(getLocalizedValue({ ar: "", en: "", de: "" }, "en")).toBe("");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { buildSiteIconResponse, buildSiteIconUrls } from "@/lib/site-icons";
describe("buildSiteIconUrls", () => {
it("uses the default version when none is provided", () => {
const urls = buildSiteIconUrls({ siteName: "Studio" });
expect(urls.version).toBe("default");
expect(urls.faviconHref).toBe("/favicon.ico?v=default");
expect(urls.appleIconHref).toBe("/apple-icon.png?v=default");
expect(urls.manifestHref).toBe("/manifest.webmanifest?v=default");
expect(urls.faviconAssetUrl).toBeNull();
});
it("applies a provided favicon version to internal icon hrefs", () => {
const urls = buildSiteIconUrls({ siteName: "Studio", faviconVersion: "v1" });
expect(urls.faviconHref).toBe("/favicon.ico?v=v1");
expect(urls.appleIconHref).toBe("/apple-icon.png?v=v1");
});
it("versions a relative favicon asset url", () => {
const urls = buildSiteIconUrls({
siteName: "Studio",
faviconVersion: "v2",
faviconUrl: "/uploads/media/site-settings/favicon.svg",
});
expect(urls.faviconAssetUrl).toBe("/uploads/media/site-settings/favicon.svg?v=v2");
});
it("versions an absolute favicon asset url", () => {
const urls = buildSiteIconUrls({
siteName: "Studio",
faviconVersion: "v3",
faviconUrl: "https://cdn.example.com/favicon.png",
});
expect(urls.faviconAssetUrl).toBe("https://cdn.example.com/favicon.png?v=v3");
});
it("falls back to a default site name when empty", () => {
expect(buildSiteIconUrls({ siteName: " " }).siteName).toBe("Moh");
});
});
describe("buildSiteIconResponse", () => {
it("returns a transparent png for a null icon url", async () => {
const response = await buildSiteIconResponse(null);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(response.headers.get("Cache-Control")).toBe("no-store, max-age=0");
const bytes = new Uint8Array(await response.arrayBuffer());
// PNG magic number
expect(Array.from(bytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
it("returns the transparent fallback for unmanaged paths", async () => {
const response = await buildSiteIconResponse("https://example.com/external.png");
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SITE_PRIMARY_COLOR,
buildDefaultSiteSettings,
normalizeSiteDefaultLocale,
normalizeSitePrimaryColor,
parseSiteSettingsValue,
} from "@/lib/site-settings";
describe("normalizeSiteDefaultLocale", () => {
it("keeps the three supported locales", () => {
expect(normalizeSiteDefaultLocale("ar")).toBe("ar");
expect(normalizeSiteDefaultLocale("en")).toBe("en");
expect(normalizeSiteDefaultLocale("de")).toBe("de");
});
it("defaults to de for anything else", () => {
expect(normalizeSiteDefaultLocale("fr")).toBe("de");
expect(normalizeSiteDefaultLocale(undefined)).toBe("de");
expect(normalizeSiteDefaultLocale(123)).toBe("de");
});
});
describe("normalizeSitePrimaryColor", () => {
it("accepts and lowercases 6-digit hex", () => {
expect(normalizeSitePrimaryColor("#AABBCC")).toBe("#aabbcc");
expect(normalizeSitePrimaryColor(" #112233 ")).toBe("#112233");
});
it("rejects invalid colors", () => {
expect(normalizeSitePrimaryColor("red")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
expect(normalizeSitePrimaryColor("#abc")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
expect(normalizeSitePrimaryColor("#12345g")).toBe(DEFAULT_SITE_PRIMARY_COLOR);
expect(normalizeSitePrimaryColor(42)).toBe(DEFAULT_SITE_PRIMARY_COLOR);
expect(normalizeSitePrimaryColor(null)).toBe(DEFAULT_SITE_PRIMARY_COLOR);
});
});
describe("buildDefaultSiteSettings", () => {
it("uses the fallback name across all locales", () => {
const settings = buildDefaultSiteSettings("Studio Moh");
expect(settings.defaultLocale).toBe("de");
expect(settings.brand.primaryColor).toBe(DEFAULT_SITE_PRIMARY_COLOR);
expect(settings.locales.ar.siteName).toBe("Studio Moh");
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} | {siteName}");
expect(settings.locales.de.subhead).toBe("");
});
});
describe("parseSiteSettingsValue", () => {
it("returns defaults for empty input", () => {
expect(parseSiteSettingsValue(null, "Fallback")).toEqual(buildDefaultSiteSettings("Fallback"));
});
it("returns defaults for invalid json", () => {
expect(parseSiteSettingsValue("{bad", "Fallback")).toEqual(buildDefaultSiteSettings("Fallback"));
});
it("merges stored values with safe defaults", () => {
const settings = parseSiteSettingsValue(
JSON.stringify({
defaultLocale: "ar",
brand: { primaryColor: "#112233" },
locales: {
en: { siteName: "Brand EN", titleTemplate: "{pageTitle} - {siteName}", siteDescription: "English" },
de: { siteName: "Brand DE", subhead: "Sub" },
},
}),
"Fallback",
);
expect(settings.defaultLocale).toBe("ar");
expect(settings.brand.primaryColor).toBe("#112233");
expect(settings.locales.en.siteName).toBe("Brand EN");
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} - {siteName}");
expect(settings.locales.en.subhead).toBe("");
expect(settings.locales.ar.siteName).toBe("Fallback");
expect(settings.locales.de.subhead).toBe("Sub");
});
it("ignores title templates that lack the {pageTitle} token", () => {
const settings = parseSiteSettingsValue(
JSON.stringify({ locales: { en: { siteName: "X", titleTemplate: "no token here" } } }),
"Fallback",
);
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} | {siteName}");
});
it("falls back for invalid default locale and primary color", () => {
const settings = parseSiteSettingsValue(
JSON.stringify({ defaultLocale: "fr", brand: { primaryColor: "nope" } }),
"Fallback",
);
expect(settings.defaultLocale).toBe("de");
expect(settings.brand.primaryColor).toBe(DEFAULT_SITE_PRIMARY_COLOR);
});
it("trims string fields", () => {
const settings = parseSiteSettingsValue(
JSON.stringify({ locales: { de: { siteName: " Trimmed ", siteDescription: " d " } } }),
"Fallback",
);
expect(settings.locales.de.siteName).toBe("Trimmed");
expect(settings.locales.de.siteDescription).toBe("d");
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { buildSiteThemeStyleText, buildSiteThemeTokens } from "@/lib/site-theme";
const CHANNEL = /^\d+ \d+% \d+%$/;
describe("buildSiteThemeTokens", () => {
it("converts pure red to the expected HSL channels", () => {
const tokens = buildSiteThemeTokens("#ff0000");
expect(tokens.light.primary).toBe("0 100% 50%");
// dark primary lightens by 6 and clamps saturation into [40,95]
expect(tokens.dark.primary).toBe("0 95% 56%");
});
it("produces zero saturation for a neutral gray", () => {
const tokens = buildSiteThemeTokens("#808080");
expect(tokens.light.primary.startsWith("0 0%")).toBe(true);
});
it("emits well-formed channel strings for every token", () => {
const tokens = buildSiteThemeTokens("#dc5a35");
for (const value of [
tokens.light.primary,
tokens.light.secondary,
tokens.dark.primary,
tokens.dark.secondary,
]) {
expect(value).toMatch(CHANNEL);
}
});
it("derives distinct dark and secondary variants", () => {
const tokens = buildSiteThemeTokens("#dc5a35");
expect(tokens.dark.primary).not.toBe(tokens.light.primary);
expect(tokens.light.secondary).not.toBe(tokens.light.primary);
});
it("falls back to the default brand color for invalid input", () => {
expect(buildSiteThemeTokens("not-a-color")).toEqual(buildSiteThemeTokens("#dc5a35"));
expect(buildSiteThemeTokens("")).toEqual(buildSiteThemeTokens("#dc5a35"));
});
});
describe("buildSiteThemeStyleText", () => {
it("emits :root and .dark blocks with the derived channels", () => {
const css = buildSiteThemeStyleText("#ff0000");
expect(css).toContain(":root {");
expect(css).toContain(".dark {");
expect(css).toContain("--primary: 0 100% 50%;");
expect(css).toContain("--brand-secondary:");
expect(css).toContain("--sidebar-ring:");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { cn } from "@/lib/utils";
describe("cn", () => {
it("joins truthy class values", () => {
expect(cn("a", "b")).toBe("a b");
});
it("ignores falsey values", () => {
expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
});
it("supports conditional object syntax", () => {
expect(cn("base", { active: true, hidden: false })).toBe("base active");
});
it("merges conflicting tailwind classes, last wins", () => {
expect(cn("px-2", "px-4")).toBe("px-4");
expect(cn("text-sm text-red-500", "text-lg")).toBe("text-red-500 text-lg");
});
it("flattens arrays", () => {
expect(cn(["a", "b"], "c")).toBe("a b c");
});
});
+42 -5
View File
@@ -4,14 +4,51 @@ import { fileURLToPath } from "url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
const rootDir = path.dirname(fileURLToPath(new URL(import.meta.url))); const rootDir = path.dirname(fileURLToPath(new URL(import.meta.url)));
const alias = { "@": rootDir };
const esbuild = { jsx: "automatic" as const };
export default defineConfig({ export default defineConfig({
resolve: { resolve: { alias },
alias: { esbuild,
"@": rootDir,
},
},
test: { test: {
// Root-level (not valid inside a single `projects[]` entry — it's a global
// option). One shared integration test database means test files must run
// sequentially (never in parallel) so each file's truncation between tests
// never races another file's.
fileParallelism: false,
projects: [
{
resolve: { alias },
esbuild,
test: {
name: "unit",
environment: "node", environment: "node",
include: ["tests/unit/**/*.test.ts", "tests/*.test.ts"],
},
},
{
resolve: { alias },
esbuild,
test: {
name: "integration",
environment: "node",
include: ["tests/integration/**/*.test.{ts,tsx}"],
globalSetup: ["tests/helpers/global-db-setup.ts"],
setupFiles: ["tests/helpers/integration-setup.ts"],
hookTimeout: 60_000,
testTimeout: 30_000,
},
},
{
resolve: { alias },
esbuild,
test: {
name: "component",
environment: "jsdom",
include: ["tests/component/**/*.test.{ts,tsx}"],
setupFiles: ["tests/helpers/component-setup.ts"],
},
},
],
}, },
}); });