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)
This commit is contained in:
MOH
2026-08-07 14:18:41 +02:00
parent e377877e7e
commit 0a5f77d8de
48 changed files with 3765 additions and 1358 deletions
+3 -3
View File
@@ -7,7 +7,7 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate && npm run build RUN npm run build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
@@ -17,8 +17,8 @@ COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/lib/db ./lib/db
COPY --from=builder /app/next.config.mjs ./next.config.mjs COPY --from=builder /app/next.config.mjs ./next.config.mjs
EXPOSE 3000 EXPOSE 3000
+8 -16
View File
@@ -1,4 +1,4 @@
.PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-migrate db-seed prisma-generate prisma-migrate test test-watch help .PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-migrate db-generate test test-watch help
MIGRATION_NAME ?= init MIGRATION_NAME ?= init
@@ -38,13 +38,10 @@ db-shell:
docker compose exec db psql -U postgres -d moh_sass docker compose exec db psql -U postgres -d moh_sass
db-init: db-init:
docker compose exec app sh -lc "npx prisma generate && npx prisma migrate deploy && npx prisma db seed" docker compose exec app npm run db:migrate
db-migrate: db-migrate:
docker compose exec app npx prisma migrate deploy docker compose exec app npm run db:migrate
db-seed:
docker compose exec app npx prisma db seed
test: test:
@node scripts/test-summary.mjs @node scripts/test-summary.mjs
@@ -52,11 +49,8 @@ test:
test-watch: test-watch:
npm run test:watch npm run test:watch
prisma-generate: db-generate:
docker compose exec app npx prisma generate docker compose exec app npm run db:generate
prisma-migrate:
docker compose exec app npx prisma migrate dev --name $(MIGRATION_NAME)
health: health:
curl -sS https://mohfarawati.de/api/health curl -sS https://mohfarawati.de/api/health
@@ -74,11 +68,9 @@ help:
@echo " make clean-orphans Remove orphaned old containers" @echo " make clean-orphans Remove orphaned old containers"
@echo " make app-shell Open shell in app container" @echo " make app-shell Open shell in app container"
@echo " make db-shell Open PostgreSQL shell" @echo " make db-shell Open PostgreSQL shell"
@echo " make db-init Generate client, apply migrations, run seed" @echo " make db-init Apply Drizzle migrations to the DB"
@echo " make db-migrate Apply prisma migrations" @echo " make db-migrate Apply Drizzle migrations (drizzle-kit migrate)"
@echo " make db-seed Seed database data" @echo " make db-generate Generate a migration from schema changes (drizzle-kit generate)"
@echo " make prisma-generate Run prisma generate"
@echo " make prisma-migrate Create/apply dev migration"
@echo " make health Check app health endpoint via public domain" @echo " make health Check app health endpoint via public domain"
@echo " make test Run the whole test suite + print a copy-paste summary" @echo " make test Run the whole test suite + print a copy-paste summary"
@echo " make test-watch Run the test suite in watch mode" @echo " make test-watch Run the test suite in watch mode"
+6 -7
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaKind } from "@prisma/client"; 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,7 +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 { prisma } from "@/lib/prisma"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
@@ -71,11 +74,7 @@ export async function deleteMediaAssetAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." })); redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
} }
await prisma.mediaAsset.delete({ await db.delete(mediaAsset).where(eq(mediaAsset.id, asset.id));
where: {
id: asset.id,
},
});
if (isManagedMediaFilePath(asset.url)) { if (isManagedMediaFilePath(asset.url)) {
await deleteMediaAssetAndFile({ await deleteMediaAssetAndFile({
+96 -130
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaUsageType, Prisma } from "@prisma/client"; 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";
@@ -15,7 +15,16 @@ import { resolveMediaSelection } from "@/lib/media-service";
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 { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
import { isCheckedFormValue } from "@/lib/form-data"; import { isCheckedFormValue } from "@/lib/form-data";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { import {
@@ -81,6 +90,20 @@ function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validierung fehlgeschlagen."; return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
} }
// 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 {
if (typeof error !== "object" || error === null) {
return false;
}
const e = error as { code?: string; cause?: { code?: string }; message?: string };
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() {
revalidatePath(toInternalAdminPath("/")); revalidatePath(toInternalAdminPath("/"));
revalidatePath(toInternalAdminPath("/media")); revalidatePath(toInternalAdminPath("/media"));
@@ -121,16 +144,9 @@ export async function upsertCategoryAction(formData: FormData) {
}); });
if (parsed.id) { if (parsed.id) {
await prisma.category.update({ await db.update(category).set(parsed).where(eq(category.id, parsed.id));
where: {
id: parsed.id,
},
data: parsed,
});
} else { } else {
await prisma.category.create({ await db.insert(category).values(parsed);
data: parsed,
});
} }
await revalidatePortfolioPages(); await revalidatePortfolioPages();
@@ -143,7 +159,7 @@ export async function upsertCategoryAction(formData: FormData) {
const message = const message =
error instanceof ZodError error instanceof ZodError
? parseZodError(error) ? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" : isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein." ? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden."; : "Kategorie konnte nicht gespeichert werden.";
@@ -158,21 +174,13 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? ""); const id = String(formData.get("id") ?? "");
try { try {
const projectCount = await prisma.portfolioProject.count({ const projectCount = await db.$count(portfolioProject, eq(portfolioProject.categoryId, id));
where: {
categoryId: id,
},
});
if (projectCount > 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." }));
} }
await prisma.category.delete({ await db.delete(category).where(eq(category.id, id));
where: {
id,
},
});
await revalidatePortfolioPages(); await revalidatePortfolioPages();
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." })); redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
@@ -241,15 +249,16 @@ export async function saveProjectAction(formData: FormData) {
}); });
const existingProject = parsed.id const existingProject = parsed.id
? await prisma.portfolioProject.findUnique({ ? (
where: { await db
id: parsed.id, .select({
}, isPublished: portfolioProject.isPublished,
select: { publishedAt: portfolioProject.publishedAt,
isPublished: true, })
publishedAt: true, .from(portfolioProject)
}, .where(eq(portfolioProject.id, parsed.id))
}) .limit(1)
)[0] ?? null
: null; : null;
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt; const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
@@ -359,81 +368,56 @@ export async function saveProjectAction(formData: FormData) {
}); });
} }
const projectResult = await prisma.$transaction(async (tx) => { const projectResult = await db.transaction(async (tx) => {
const currentProject = parsed.id const projectValues = {
? await tx.portfolioProject.update({ categoryId: parsed.categoryId,
where: { slug: parsed.slug,
id: parsed.id, viewMode: parsed.viewMode,
}, titleAr: parsed.titleAr,
data: { titleEn: parsed.titleEn,
categoryId: parsed.categoryId, titleDe: parsed.titleDe,
slug: parsed.slug, summaryAr: parsed.summaryAr,
viewMode: parsed.viewMode, summaryEn: parsed.summaryEn,
titleAr: parsed.titleAr, summaryDe: parsed.summaryDe,
titleEn: parsed.titleEn, clientName: parsed.clientName,
titleDe: parsed.titleDe, projectYear: parsed.projectYear,
summaryAr: parsed.summaryAr, serviceLabelAr: parsed.serviceLabelAr,
summaryEn: parsed.summaryEn, serviceLabelEn: parsed.serviceLabelEn,
summaryDe: parsed.summaryDe, serviceLabelDe: parsed.serviceLabelDe,
clientName: parsed.clientName, previewUrl: parsed.previewUrl || null,
projectYear: parsed.projectYear, coverImagePath: coverSelection.url || null,
serviceLabelAr: parsed.serviceLabelAr, isFeatured: parsed.isFeatured,
serviceLabelEn: parsed.serviceLabelEn, isPublished: parsed.isPublished,
serviceLabelDe: parsed.serviceLabelDe, sortOrder: parsed.sortOrder,
previewUrl: parsed.previewUrl || null, };
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured, const [currentProject] = parsed.id
isPublished: parsed.isPublished, ? await tx
.update(portfolioProject)
.set({
...projectValues,
publishedAt: parsed.isPublished publishedAt: parsed.isPublished
? shouldPublishNow ? shouldPublishNow
? new Date() ? new Date()
: existingProject?.publishedAt ?? new Date() : existingProject?.publishedAt ?? new Date()
: null, : null,
sortOrder: parsed.sortOrder, })
}, .where(eq(portfolioProject.id, parsed.id))
}) .returning()
: await tx.portfolioProject.create({ : await tx
data: { .insert(portfolioProject)
categoryId: parsed.categoryId, .values({ ...projectValues, publishedAt: parsed.isPublished ? new Date() : null })
slug: parsed.slug, .returning();
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
publishedAt: parsed.isPublished ? new Date() : null,
sortOrder: parsed.sortOrder,
},
});
await tx.portfolioSection.deleteMany({ await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
where: { await tx.delete(portfolioAsset).where(eq(portfolioAsset.projectId, currentProject.id));
projectId: currentProject.id,
},
});
await tx.portfolioAsset.deleteMany({
where: {
projectId: currentProject.id,
},
});
const createdSections = []; const createdSections = [];
for (const section of sectionRows) { for (const section of sectionRows) {
const createdSection = await tx.portfolioSection.create({ const [createdSection] = await tx
data: { .insert(portfolioSection)
.values({
projectId: currentProject.id, projectId: currentProject.id,
type: section.type, type: section.type,
titleAr: section.titleAr, titleAr: section.titleAr,
@@ -445,8 +429,8 @@ export async function saveProjectAction(formData: FormData) {
imagePath: section.imagePath || null, imagePath: section.imagePath || null,
linkUrl: section.linkUrl || null, linkUrl: section.linkUrl || null,
sortOrder: section.sortOrder, sortOrder: section.sortOrder,
}, })
}); .returning();
createdSections.push(createdSection); createdSections.push(createdSection);
} }
@@ -454,8 +438,9 @@ export async function saveProjectAction(formData: FormData) {
const createdAssets = []; const createdAssets = [];
for (const asset of assetRows) { for (const asset of assetRows) {
const createdAsset = await tx.portfolioAsset.create({ const [createdAsset] = await tx
data: { .insert(portfolioAsset)
.values({
projectId: currentProject.id, projectId: currentProject.id,
kind: asset.kind, kind: asset.kind,
filePath: asset.filePath, filePath: asset.filePath,
@@ -463,8 +448,8 @@ export async function saveProjectAction(formData: FormData) {
altEn: asset.altEn, altEn: asset.altEn,
altDe: asset.altDe, altDe: asset.altDe,
sortOrder: asset.sortOrder, sortOrder: asset.sortOrder,
}, })
}); .returning();
createdAssets.push(createdAsset); createdAssets.push(createdAsset);
} }
@@ -536,7 +521,7 @@ export async function saveProjectAction(formData: FormData) {
const message = const message =
error instanceof ZodError error instanceof ZodError
? parseZodError(error) ? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" : isUniqueViolation(error)
? "Projekt Slug muss eindeutig sein." ? "Projekt Slug muss eindeutig sein."
: error instanceof Error : error instanceof Error
? error.message ? error.message
@@ -544,20 +529,8 @@ export async function saveProjectAction(formData: FormData) {
await removeManagedPaths(uploadedPaths); await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) { if (createdMediaAssetIds.length > 0) {
await prisma.mediaUsage.deleteMany({ await db.delete(mediaUsage).where(inArray(mediaUsage.assetId, createdMediaAssetIds));
where: { await db.delete(mediaAsset).where(inArray(mediaAsset.id, createdMediaAssetIds));
assetId: {
in: createdMediaAssetIds,
},
},
});
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: createdMediaAssetIds,
},
},
});
} }
redirect(withFlash(redirectPath, { error: message })); redirect(withFlash(redirectPath, { error: message }));
} }
@@ -569,24 +542,17 @@ export async function deleteProjectAction(formData: FormData) {
const id = String(formData.get("id") ?? ""); const id = String(formData.get("id") ?? "");
try { try {
const project = await prisma.portfolioProject.findUnique({ const [project] = await db
where: { .select({ slug: portfolioProject.slug })
id, .from(portfolioProject)
}, .where(eq(portfolioProject.id, id))
select: { .limit(1);
slug: true,
},
});
if (!project) { if (!project) {
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." })); redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
} }
await prisma.portfolioProject.delete({ await db.delete(portfolioProject).where(eq(portfolioProject.id, id));
where: {
id,
},
});
await deleteEntityMediaUsages("portfolio-project", id); await deleteEntityMediaUsages("portfolio-project", id);
await revalidatePortfolioPages(); await revalidatePortfolioPages();
+6 -9
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaUsageType } from "@prisma/client"; 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,7 +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 { prisma } from "@/lib/prisma"; import { inArray } from "drizzle-orm";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
@@ -60,13 +63,7 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) { async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
if (assetIds.length > 0) { if (assetIds.length > 0) {
await prisma.mediaAsset.deleteMany({ await db.delete(mediaAsset).where(inArray(mediaAsset.id, Array.from(new Set(assetIds))));
where: {
id: {
in: Array.from(new Set(assetIds)),
},
},
});
} }
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) { for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client"; import { MediaKind } from "@/lib/db/enums";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
+3 -2
View File
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -8,7 +9,7 @@ export async function GET() {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
try { try {
await prisma.$queryRaw`SELECT 1`; await db.execute(sql`SELECT 1`);
return NextResponse.json( return NextResponse.json(
{ {
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client"; import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react"; import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client"; import type { MediaKind } from "@/lib/db/enums";
import { FileType2, Grid2x2, ImageIcon, LayoutList, LoaderCircle, Trash2, Upload } from "lucide-react"; import { FileType2, Grid2x2, ImageIcon, LayoutList, LoaderCircle, Trash2, Upload } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client"; import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client"; import { MediaKind } from "@/lib/db/enums";
import { import {
Check, Check,
FileText, FileText,
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public",
},
});
+25 -20
View File
@@ -2,7 +2,10 @@ import { createHash, createHmac, timingSafeEqual } from "crypto";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "./prisma"; import { and, eq, like, lt } from "drizzle-orm";
import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing"; import { getAdminAppPath } from "./admin-routing";
export const ADMIN_SESSION_COOKIE = "moh_admin_session"; export const ADMIN_SESSION_COOKIE = "moh_admin_session";
@@ -137,11 +140,9 @@ function getLockoutKey(ip: string): string {
async function cleanupExpiredLockouts(): Promise<void> { async function cleanupExpiredLockouts(): Promise<void> {
try { try {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000); const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await prisma.$executeRaw` await db
DELETE FROM "AppConfig" .delete(appConfig)
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`} .where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)));
AND "updatedAt" < ${cutoff}
`;
} catch { } catch {
// Non-critical — ignore cleanup errors. // Non-critical — ignore cleanup errors.
} }
@@ -216,10 +217,11 @@ 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 config = await prisma.appConfig.findUnique({ const [config] = await db
where: { key }, .select({ value: appConfig.value })
select: { value: true }, .from(appConfig)
}); .where(eq(appConfig.key, key))
.limit(1);
const state = parseFailState(config?.value); const state = parseFailState(config?.value);
const now = Date.now(); const now = Date.now();
@@ -244,10 +246,11 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts(); await cleanupExpiredLockouts();
const config = await prisma.appConfig.findUnique({ const [config] = await db
where: { key }, .select({ value: appConfig.value })
select: { value: true }, .from(appConfig)
}); .where(eq(appConfig.key, key))
.limit(1);
const current = parseFailState(config?.value); const current = parseFailState(config?.value);
// If a previous lockout has expired, reset the counter. // If a previous lockout has expired, reset the counter.
@@ -256,11 +259,13 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
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;
await prisma.appConfig.upsert({ await db
where: { key }, .insert(appConfig)
update: { value: JSON.stringify({ attempts, lockUntil }) }, .values({ key, value: JSON.stringify({ attempts, lockUntil }) })
create: { key, value: JSON.stringify({ attempts, lockUntil }) }, .onConflictDoUpdate({
}); target: appConfig.key,
set: { value: JSON.stringify({ attempts, lockUntil }) },
});
return { return {
locked, locked,
@@ -276,7 +281,7 @@ export async function resetAdminFailedAttempts(): Promise<void> {
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const key = getLockoutKey(ip); const key = getLockoutKey(ip);
await prisma.appConfig.deleteMany({ where: { key } }); await db.delete(appConfig).where(eq(appConfig.key, key));
} catch { } catch {
// Non-critical — ignore. // Non-critical — ignore.
} }
+69 -120
View File
@@ -1,4 +1,7 @@
import { prisma } from "./prisma"; import { and, eq, inArray } from "drizzle-orm";
import { db } from "./db";
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,
@@ -65,45 +68,42 @@ import {
type MarqueeSettings, type MarqueeSettings,
} from "./marquee-settings"; } from "./marquee-settings";
// Small helpers over the app_config key/value table (Drizzle).
async function readConfigValue(key: string): Promise<string | undefined> {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return row?.value;
}
async function upsertConfig(key: string, value: string): Promise<void> {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value } });
}
export async function getMaintenanceMode(): Promise<boolean> { export async function getMaintenanceMode(): Promise<boolean> {
try { try {
const config = await prisma.appConfig.findUnique({ return (await readConfigValue(MAINTENANCE_MODE_KEY)) === "true";
where: { key: MAINTENANCE_MODE_KEY },
select: { value: true },
});
return config?.value === "true";
} catch { } catch {
return false; return false;
} }
} }
export async function setMaintenanceMode(enabled: boolean): Promise<void> { export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await prisma.appConfig.upsert({ await upsertConfig(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
where: { key: MAINTENANCE_MODE_KEY },
update: {
value: enabled ? "true" : "false",
},
create: {
key: MAINTENANCE_MODE_KEY,
value: enabled ? "true" : "false",
},
});
} }
export async function getSiteSettings(): Promise<SiteSettings> { export async function getSiteSettings(): Promise<SiteSettings> {
try { try {
const configs = await prisma.appConfig.findMany({ const configs = await db
where: { .select({ key: appConfig.key, value: appConfig.value })
key: { .from(appConfig)
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY], .where(inArray(appConfig.key, [SITE_SETTINGS_KEY, SITE_NAME_KEY]));
},
},
select: {
key: true,
value: true,
},
});
const configMap = new Map(configs.map((config) => [config.key, config.value])); const configMap = new Map(configs.map((config) => [config.key, config.value]));
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME; const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
@@ -115,26 +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 prisma.appConfig.upsert({ await upsertConfig(SITE_SETTINGS_KEY, JSON.stringify(settings));
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
} }
export async function getMailSettings(): Promise<MailSettings> { export async function getMailSettings(): Promise<MailSettings> {
try { try {
const config = await prisma.appConfig.findUnique({ return parseMailSettingsValue(await readConfigValue(MAIL_SETTINGS_KEY));
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
} catch { } catch {
return buildDefaultMailSettings(); return buildDefaultMailSettings();
} }
@@ -147,26 +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 prisma.appConfig.upsert({ await upsertConfig(MAIL_SETTINGS_KEY, JSON.stringify(settings));
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
} }
export async function getMarqueeSettings(): Promise<MarqueeSettings> { export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try { try {
const config = await prisma.appConfig.findUnique({ return parseMarqueeSettingsValue(await readConfigValue(MARQUEE_SETTINGS_KEY));
where: { key: MARQUEE_SETTINGS_KEY },
select: { value: true },
});
return parseMarqueeSettingsValue(config?.value);
} catch { } catch {
return buildDefaultMarqueeSettings(); return buildDefaultMarqueeSettings();
} }
@@ -175,75 +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 prisma.appConfig.upsert({ await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
where: { key: MARQUEE_SETTINGS_KEY },
update: {
value: JSON.stringify(normalizedSettings),
},
create: {
key: MARQUEE_SETTINGS_KEY,
value: JSON.stringify(normalizedSettings),
},
});
} }
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> { export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try { try {
const usages = await prisma.mediaUsage.findMany({ const usages = await db
where: { .select({
entityType: SITE_SETTINGS_ENTITY_TYPE, fieldKey: mediaUsage.fieldKey,
entityId: SITE_SETTINGS_ENTITY_ID, updatedAt: mediaUsage.updatedAt,
}, assetId: mediaAsset.id,
select: { assetUrl: mediaAsset.url,
fieldKey: true, })
updatedAt: true, .from(mediaUsage)
asset: { .innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId))
select: { .where(
id: true, and(
url: true, eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
}, eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
}, ),
}, );
});
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, version: usage.updatedAt.toISOString(),
url: usage.asset.url, };
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoDark = { result.siteLogoLight = 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_LOGO_DARK_FIELD_KEY) {
result.favicon = { result.siteLogoDark = 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_FAVICON_FIELD_KEY) {
result.defaultOgImage = { result.favicon = binding;
assetId: usage.asset.id, }
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
return result; if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
}, result.defaultOgImage = binding;
getDefaultSiteSettingsMediaBindings(), }
);
return result;
}, getDefaultSiteSettingsMediaBindings());
} catch { } catch {
return getDefaultSiteSettingsMediaBindings(); return getDefaultSiteSettingsMediaBindings();
} }
+35
View File
@@ -0,0 +1,35 @@
import {
mediaKind,
mediaSource,
mediaUsageType,
portfolioAssetKind,
portfolioProjectViewMode,
portfolioSectionType,
} from "./schema";
/**
* Prisma-compatible enum objects + types, derived from the Drizzle pgEnums, so
* existing consumers can keep writing `MediaKind.IMAGE` (value) and `: MediaKind`
* (type) — only the import path changes from `@prisma/client` to `@/lib/db/enums`.
*/
function asEnum<T extends string>(values: readonly T[]): { [K in T]: K } {
return Object.fromEntries(values.map((v) => [v, v])) as { [K in T]: K };
}
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];
+26
View File
@@ -0,0 +1,26 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
/**
* The Drizzle database client (postgres.js driver), matching the house standard
* used by the other projects. Replaces the old Prisma client (`lib/prisma.ts`).
* A single connection is reused across hot reloads in dev.
*/
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const globalForDb = globalThis as unknown as {
dbClient: ReturnType<typeof postgres> | undefined;
};
const client = globalForDb.dbClient ?? postgres(connectionString);
if (process.env.NODE_ENV !== "production") {
globalForDb.dbClient = client;
}
export const db = drizzle(client, { schema });
export { schema };
+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");
+956
View File
@@ -0,0 +1,956 @@
{
"id": "a001b9c1-c931-4e9f-b21c-50cdfbffb6a6",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.app_config": {
"name": "app_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"app_config_key_unique": {
"name": "app_config_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.category": {
"name": "category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name_ar": {
"name": "name_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name_en": {
"name": "name_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name_de": {
"name": "name_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description_ar": {
"name": "description_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description_en": {
"name": "description_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description_de": {
"name": "description_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"category_slug_unique": {
"name": "category_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.media_asset": {
"name": "media_asset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"source": {
"name": "source",
"type": "media_source",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "media_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"file_name": {
"name": "file_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_text": {
"name": "alt_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_asset_kind_created_idx": {
"name": "media_asset_kind_created_idx",
"columns": [
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.media_usage": {
"name": "media_usage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"asset_id": {
"name": "asset_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"usage_type": {
"name": "usage_type",
"type": "media_usage_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"field_key": {
"name": "field_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_usage_unique_slot": {
"name": "media_usage_unique_slot",
"columns": [
{
"expression": "usage_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "field_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_usage_asset_idx": {
"name": "media_usage_asset_idx",
"columns": [
{
"expression": "asset_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_usage_entity_idx": {
"name": "media_usage_entity_idx",
"columns": [
{
"expression": "entity_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"media_usage_asset_id_media_asset_id_fk": {
"name": "media_usage_asset_id_media_asset_id_fk",
"tableFrom": "media_usage",
"tableTo": "media_asset",
"columnsFrom": [
"asset_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.portfolio_asset": {
"name": "portfolio_asset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "portfolio_asset_kind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_ar": {
"name": "alt_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_en": {
"name": "alt_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_de": {
"name": "alt_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"portfolio_asset_project_sort_idx": {
"name": "portfolio_asset_project_sort_idx",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"portfolio_asset_project_id_portfolio_project_id_fk": {
"name": "portfolio_asset_project_id_portfolio_project_id_fk",
"tableFrom": "portfolio_asset",
"tableTo": "portfolio_project",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.portfolio_project": {
"name": "portfolio_project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"category_id": {
"name": "category_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"view_mode": {
"name": "view_mode",
"type": "portfolio_project_view_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'GRID'"
},
"title_ar": {
"name": "title_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_en": {
"name": "title_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_de": {
"name": "title_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary_ar": {
"name": "summary_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary_en": {
"name": "summary_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary_de": {
"name": "summary_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_name": {
"name": "client_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"project_year": {
"name": "project_year",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"service_label_ar": {
"name": "service_label_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"service_label_en": {
"name": "service_label_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"service_label_de": {
"name": "service_label_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"preview_url": {
"name": "preview_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_image_path": {
"name": "cover_image_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_featured": {
"name": "is_featured",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_published": {
"name": "is_published",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"portfolio_project_category_published_sort_idx": {
"name": "portfolio_project_category_published_sort_idx",
"columns": [
{
"expression": "category_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "is_published",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"portfolio_project_published_sort_idx": {
"name": "portfolio_project_published_sort_idx",
"columns": [
{
"expression": "is_published",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"portfolio_project_category_id_category_id_fk": {
"name": "portfolio_project_category_id_category_id_fk",
"tableFrom": "portfolio_project",
"tableTo": "category",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"portfolio_project_slug_unique": {
"name": "portfolio_project_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.portfolio_section": {
"name": "portfolio_section",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "portfolio_section_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"title_ar": {
"name": "title_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_en": {
"name": "title_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title_de": {
"name": "title_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_ar": {
"name": "body_ar",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_en": {
"name": "body_en",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_de": {
"name": "body_de",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_path": {
"name": "image_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"link_url": {
"name": "link_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"portfolio_section_project_sort_idx": {
"name": "portfolio_section_project_sort_idx",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sort_order",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"portfolio_section_project_id_portfolio_project_id_fk": {
"name": "portfolio_section_project_id_portfolio_project_id_fk",
"tableFrom": "portfolio_section",
"tableTo": "portfolio_project",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.media_kind": {
"name": "media_kind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.media_source": {
"name": "media_source",
"schema": "public",
"values": [
"UPLOAD",
"EXTERNAL"
]
},
"public.media_usage_type": {
"name": "media_usage_type",
"schema": "public",
"values": [
"PORTFOLIO_COVER",
"PORTFOLIO_SECTION",
"PORTFOLIO_ASSET",
"GENERIC"
]
},
"public.portfolio_asset_kind": {
"name": "portfolio_asset_kind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.portfolio_project_view_mode": {
"name": "portfolio_project_view_mode",
"schema": "public",
"values": [
"GRID",
"STORY",
"CASE_STUDY"
]
},
"public.portfolio_section_type": {
"name": "portfolio_section_type",
"schema": "public",
"values": [
"RICH_TEXT",
"GALLERY",
"STATS",
"DELIVERABLES",
"LINK"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1786049545718,
"tag": "0000_fixed_venom",
"breakpoints": true
}
]
}
+241
View File
@@ -0,0 +1,241 @@
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
/**
* Drizzle schema — the single source of truth for the database, replacing the
* old Prisma schema (see docs). The database is Postgres; migrations are
* generated with `drizzle-kit generate`. IDs are app-generated opaque strings
* (was Prisma `cuid()`), timestamps default in the DB and bump on update.
*/
// `crypto.randomUUID()` is a global in Node 20+ and browsers (no node: import),
// so the schema stays safe to pull into a client bundle via lib/db/enums.
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
const updatedAt = timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date());
// --- 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(),
key: text("key").notNull().unique(),
value: text("value").notNull(),
createdAt,
updatedAt,
});
export const category = pgTable("category", {
id: id(),
slug: text("slug").notNull().unique(),
nameAr: text("name_ar").notNull(),
nameEn: text("name_en").notNull(),
nameDe: text("name_de").notNull(),
descriptionAr: text("description_ar").notNull(),
descriptionEn: text("description_en").notNull(),
descriptionDe: text("description_de").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
isActive: boolean("is_active").notNull().default(true),
createdAt,
updatedAt,
});
export const portfolioProject = pgTable(
"portfolio_project",
{
id: id(),
categoryId: text("category_id")
.notNull()
.references(() => category.id, { onDelete: "restrict" }),
slug: text("slug").notNull().unique(),
viewMode: portfolioProjectViewMode("view_mode").notNull().default("GRID"),
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull(),
titleDe: text("title_de").notNull(),
summaryAr: text("summary_ar").notNull(),
summaryEn: text("summary_en").notNull(),
summaryDe: text("summary_de").notNull(),
clientName: text("client_name").notNull(),
projectYear: integer("project_year").notNull(),
serviceLabelAr: text("service_label_ar").notNull(),
serviceLabelEn: text("service_label_en").notNull(),
serviceLabelDe: text("service_label_de").notNull(),
previewUrl: text("preview_url"),
coverImagePath: text("cover_image_path"),
isFeatured: boolean("is_featured").notNull().default(false),
isPublished: boolean("is_published").notNull().default(false),
publishedAt: timestamp("published_at", { withTimezone: true }),
sortOrder: integer("sort_order").notNull().default(0),
createdAt,
updatedAt,
},
(t) => [
index("portfolio_project_category_published_sort_idx").on(t.categoryId, t.isPublished, t.sortOrder),
index("portfolio_project_published_sort_idx").on(t.isPublished, t.sortOrder),
],
);
export const portfolioSection = pgTable(
"portfolio_section",
{
id: id(),
projectId: text("project_id")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
type: portfolioSectionType("type").notNull(),
titleAr: text("title_ar").notNull(),
titleEn: text("title_en").notNull(),
titleDe: text("title_de").notNull(),
bodyAr: text("body_ar").notNull(),
bodyEn: text("body_en").notNull(),
bodyDe: text("body_de").notNull(),
imagePath: text("image_path"),
linkUrl: text("link_url"),
sortOrder: integer("sort_order").notNull().default(0),
createdAt,
updatedAt,
},
(t) => [index("portfolio_section_project_sort_idx").on(t.projectId, t.sortOrder)],
);
export const portfolioAsset = pgTable(
"portfolio_asset",
{
id: id(),
projectId: text("project_id")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
kind: portfolioAssetKind("kind").notNull(),
filePath: text("file_path").notNull(),
altAr: text("alt_ar").notNull(),
altEn: text("alt_en").notNull(),
altDe: text("alt_de").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
createdAt,
updatedAt,
},
(t) => [index("portfolio_asset_project_sort_idx").on(t.projectId, t.sortOrder)],
);
export const mediaAsset = pgTable(
"media_asset",
{
id: id(),
source: mediaSource("source").notNull(),
kind: mediaKind("kind").notNull(),
url: text("url").notNull(),
fileName: text("file_name").notNull(),
label: text("label").notNull(),
altText: text("alt_text"),
mimeType: text("mime_type"),
size: integer("size"),
createdAt,
updatedAt,
},
(t) => [index("media_asset_kind_created_idx").on(t.kind, t.createdAt)],
);
export const mediaUsage = pgTable(
"media_usage",
{
id: id(),
assetId: text("asset_id")
.notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }),
usageType: mediaUsageType("usage_type").notNull(),
entityType: text("entity_type").notNull(),
entityId: text("entity_id").notNull(),
fieldKey: text("field_key").notNull(),
createdAt,
updatedAt,
},
(t) => [
uniqueIndex("media_usage_unique_slot").on(t.usageType, t.entityType, t.entityId, t.fieldKey),
index("media_usage_asset_idx").on(t.assetId),
index("media_usage_entity_idx").on(t.entityType, t.entityId),
],
);
// --- Relations (for the relational query API: db.query.*.findMany({ with })) --
export const categoryRelations = relations(category, ({ many }) => ({
projects: many(portfolioProject),
}));
export const portfolioProjectRelations = relations(portfolioProject, ({ one, many }) => ({
category: one(category, {
fields: [portfolioProject.categoryId],
references: [category.id],
}),
sections: many(portfolioSection),
assets: many(portfolioAsset),
}));
export const portfolioSectionRelations = relations(portfolioSection, ({ one }) => ({
project: one(portfolioProject, {
fields: [portfolioSection.projectId],
references: [portfolioProject.id],
}),
}));
export const portfolioAssetRelations = relations(portfolioAsset, ({ one }) => ({
project: one(portfolioProject, {
fields: [portfolioAsset.projectId],
references: [portfolioProject.id],
}),
}));
export const mediaAssetRelations = relations(mediaAsset, ({ many }) => ({
usages: many(mediaUsage),
}));
export const mediaUsageRelations = relations(mediaUsage, ({ one }) => ({
asset: one(mediaAsset, {
fields: [mediaUsage.assetId],
references: [mediaAsset.id],
}),
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "path"; import path from "path";
import { MediaKind, MediaSource } from "@prisma/client"; import { MediaKind, MediaSource } from "@/lib/db/enums";
import { createMediaAsset, getMediaAssetById } from "@/lib/media"; import { createMediaAsset, getMediaAssetById } from "@/lib/media";
import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage"; import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage";
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client"; import { MediaKind } from "@/lib/db/enums";
import { z } from "zod"; import { z } from "zod";
const mediaModeSchema = z.enum(["library", "external", "upload"]); const mediaModeSchema = z.enum(["library", "external", "upload"]);
+54 -79
View File
@@ -1,20 +1,17 @@
import type { import { and, desc, eq } from "drizzle-orm";
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
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">;
@@ -25,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,
@@ -52,40 +45,32 @@ function mapMediaAsset(
} }
export async function getAdminMediaAssets() { export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({ const assets = await db.query.mediaAsset.findMany({
include: { with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } },
usages: { orderBy: [desc(mediaAsset.createdAt)],
orderBy: [{ createdAt: "desc" }],
},
},
orderBy: [{ createdAt: "desc" }],
}); });
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 prisma.mediaAsset.findMany({ return db
where: filters?.kind ? { kind: filters.kind } : undefined, .select({
orderBy: [{ createdAt: "desc" }], id: mediaAsset.id,
select: { kind: mediaAsset.kind,
id: true, url: mediaAsset.url,
kind: true, label: mediaAsset.label,
url: true, source: mediaAsset.source,
label: true, })
source: true, .from(mediaAsset)
}, .where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
}); .orderBy(desc(mediaAsset.createdAt));
return assets;
} }
export async function getMediaAssetById(id: string) { export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({ const asset = await db.query.mediaAsset.findFirst({
where: { id }, where: eq(mediaAsset.id, id),
include: { with: { usages: true },
usages: true,
},
}); });
return asset ? mapMediaAsset(asset) : null; return asset ? mapMediaAsset(asset) : null;
@@ -101,8 +86,9 @@ export async function createMediaAsset(input: {
mimeType?: string | null; mimeType?: string | null;
size?: number | null; size?: number | null;
}) { }) {
return prisma.mediaAsset.create({ const [created] = await db
data: { .insert(mediaAsset)
.values({
source: input.source, source: input.source,
kind: input.kind, kind: input.kind,
url: input.url, url: input.url,
@@ -111,8 +97,10 @@ export async function createMediaAsset(input: {
altText: input.altText ?? null, altText: input.altText ?? null,
mimeType: input.mimeType ?? null, mimeType: input.mimeType ?? null,
size: input.size ?? null, size: input.size ?? null,
}, })
}); .returning();
return created;
} }
export async function replaceEntityMediaUsages(input: { export async function replaceEntityMediaUsages(input: {
@@ -124,51 +112,42 @@ export async function replaceEntityMediaUsages(input: {
fieldKey: string; fieldKey: string;
}>; }>;
}) { }) {
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.mediaUsage.deleteMany({ await tx
where: { .delete(mediaUsage)
entityType: input.entityType, .where(and(eq(mediaUsage.entityType, input.entityType), eq(mediaUsage.entityId, input.entityId)));
entityId: input.entityId,
},
});
if (input.usages.length === 0) { if (input.usages.length === 0) {
return; return;
} }
await tx.mediaUsage.createMany({ await tx.insert(mediaUsage).values(
data: input.usages.map((usage) => ({ input.usages.map((usage) => ({
assetId: usage.assetId, assetId: usage.assetId,
usageType: usage.usageType, usageType: usage.usageType,
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
fieldKey: usage.fieldKey, fieldKey: usage.fieldKey,
})), })),
}); );
}); });
} }
export async function deleteEntityMediaUsages(entityType: string, entityId: string) { export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({ await db
where: { .delete(mediaUsage)
entityType, .where(and(eq(mediaUsage.entityType, entityType), eq(mediaUsage.entityId, entityId)));
entityId,
},
});
} }
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> { export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({ const usages = await db
where: { .select({
entityType: "portfolio-project", assetId: mediaUsage.assetId,
entityId: projectId, usageType: mediaUsage.usageType,
}, fieldKey: mediaUsage.fieldKey,
select: { })
assetId: true, .from(mediaUsage)
usageType: true, .where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
fieldKey: true,
},
});
return usages.reduce<PortfolioMediaBindings>( return usages.reduce<PortfolioMediaBindings>(
(result, usage) => { (result, usage) => {
@@ -195,9 +174,5 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
} }
export async function countMediaUsageReferences(assetId: string) { export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({ return db.$count(mediaUsage, eq(mediaUsage.assetId, assetId));
where: {
assetId,
},
});
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client"; import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets"; export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PortfolioSectionType } from "@prisma/client"; import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod"; import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation"; import { mediaFieldInputSchema } from "./media-validation";
+72 -147
View File
@@ -1,74 +1,22 @@
import type {
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
import { cache } from "react"; import { cache } from "react";
import { and, asc, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import {
category as categoryTable,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
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";
import { prisma } from "@/lib/prisma";
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;
@@ -240,40 +188,29 @@ export function getLocalizedValue(
} }
export async function getAdminPortfolioCategories() { export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({ const categories = await db.query.category.findMany({
include: { orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
_count: { with: { projects: { columns: { id: true } } },
select: {
projects: true,
},
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}); });
return categories.map((category) => ({ return categories.map((category) => ({
...mapCategory(category), ...mapCategory(category),
projectCount: category._count.projects, projectCount: category.projects.length,
})); }));
} }
export async function getActivePortfolioCategories() { export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({ const categories = await db.query.category.findMany({
where: { where: eq(categoryTable.isActive, true),
isActive: true, orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}); });
return categories.map(mapCategory); return categories.map(mapCategory);
} }
export async function getActivePortfolioCategoryBySlug(slug: string) { export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await prisma.category.findFirst({ const category = await db.query.category.findFirst({
where: { where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)),
slug,
isActive: true,
},
}); });
return category ? mapCategory(category) : null; return category ? mapCategory(category) : null;
@@ -283,90 +220,78 @@ export async function getAdminPortfolioProjects(filters?: {
categoryId?: string; categoryId?: string;
status?: "all" | "draft" | "published"; status?: "all" | "draft" | "published";
}) { }) {
const projects = await prisma.portfolioProject.findMany({ const conditions = [
where: { ...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []),
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}), ...(filters?.status === "draft"
...(filters?.status === "draft" ? [eq(portfolioProject.isPublished, false)]
? { isPublished: false } : filters?.status === "published"
: filters?.status === "published" ? [eq(portfolioProject.isPublished, true)]
? { isPublished: true } : []),
: {}), ];
},
include: { const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined,
with: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
}, },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
}); });
return projects.map((project) => mapProject(project)); return projects.map((project) => mapProject(project));
} }
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) { export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({ const projects = await db.query.portfolioProject.findMany({
where: { where: eq(portfolioProject.isPublished, true),
isPublished: true, with: {
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
}, },
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }], orderBy: [
asc(portfolioProject.sortOrder),
desc(portfolioProject.publishedAt),
desc(portfolioProject.createdAt),
],
}); });
return projects.map((project) => mapProject(project)); // Prisma filtered on the related category (active + optional slug); the
// relational query filters the main table only, so narrow here.
return projects
.filter(
(project) =>
project.category.isActive &&
(!filters?.categorySlug || project.category.slug === filters.categorySlug),
)
.map((project) => mapProject(project));
} }
export const getPublishedPortfolioProjectBySlug = cache(async function (slug: string) { export const getPublishedPortfolioProjectBySlug = cache(async function (slug: string) {
const project = await prisma.portfolioProject.findFirst({ const project = await db.query.portfolioProject.findFirst({
where: { where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
slug, with: {
isPublished: true,
category: {
isActive: true,
},
},
include: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
}, },
}); });
return project ? mapProject(project) : null; if (!project || !project.category.isActive) {
return null;
}
return mapProject(project);
}); });
export async function getAdminPortfolioProjectById(id: string) { export async function getAdminPortfolioProjectById(id: string) {
const project = await prisma.portfolioProject.findUnique({ const project = await db.query.portfolioProject.findFirst({
where: { where: eq(portfolioProject.id, id),
id, with: {
},
include: {
category: true, category: true,
sections: { sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
}, },
}); });
-32
View File
@@ -1,32 +0,0 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
prismaPool: Pool | undefined;
};
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const pool =
globalForPrisma.prismaPool ??
new Pool({
connectionString,
});
const adapter = new PrismaPg(pool);
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: ["warn", "error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prismaPool = pool;
globalForPrisma.prisma = prisma;
}
+1822 -275
View File
File diff suppressed because it is too large Load Diff
+7 -12
View File
@@ -8,17 +8,12 @@
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"prisma:generate": "prisma generate", "db:generate": "drizzle-kit generate",
"db:migrate": "prisma migrate deploy", "db:migrate": "drizzle-kit migrate",
"db:migrate:dev": "prisma migrate dev", "db:push": "drizzle-kit push",
"db:seed": "prisma db seed" "db:studio": "drizzle-kit studio"
},
"prisma": {
"seed": "node prisma/seed.js"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.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",
@@ -28,6 +23,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.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",
@@ -36,6 +32,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.20.0", "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",
@@ -44,7 +41,6 @@
}, },
"devDependencies": { "devDependencies": {
"@electric-sql/pglite": "^0.5.4", "@electric-sql/pglite": "^0.5.4",
"@electric-sql/pglite-socket": "^0.2.7",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0", "@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -53,12 +49,11 @@
"@types/pg": "^8.18.0", "@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.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", "jsdom": "^30.0.1",
"pglite-prisma-adapter": "^0.7.2",
"postcss": "^8", "postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5", "typescript": "^5",
-13
View File
@@ -1,13 +0,0 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "node prisma/seed.js",
},
datasource: {
url: process.env["DATABASE_URL"] ?? "",
},
});
@@ -1,13 +0,0 @@
-- CreateTable
CREATE TABLE "AppConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AppConfig_key_key" ON "AppConfig"("key");
@@ -1,112 +0,0 @@
CREATE TYPE "PortfolioSectionType" AS ENUM (
'RICH_TEXT',
'GALLERY',
'STATS',
'DELIVERABLES',
'LINK'
);
CREATE TYPE "PortfolioAssetKind" AS ENUM (
'IMAGE',
'DOCUMENT'
);
CREATE TABLE "Category" (
"id" TEXT 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 NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PortfolioProject" (
"id" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"slug" TEXT 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 NOT NULL DEFAULT false,
"isPublished" BOOLEAN NOT NULL DEFAULT false,
"publishedAt" TIMESTAMP(3),
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioProject_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PortfolioSection" (
"id" TEXT 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 NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioSection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PortfolioAsset" (
"id" TEXT 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 NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PortfolioAsset_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
CREATE UNIQUE INDEX "PortfolioProject_slug_key" ON "PortfolioProject"("slug");
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject"("categoryId", "isPublished", "sortOrder");
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject"("isPublished", "sortOrder");
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection"("projectId", "sortOrder");
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset"("projectId", "sortOrder");
ALTER TABLE "PortfolioProject"
ADD CONSTRAINT "PortfolioProject_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "Category"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "PortfolioSection"
ADD CONSTRAINT "PortfolioSection_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "PortfolioProject"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PortfolioAsset"
ADD CONSTRAINT "PortfolioAsset_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "PortfolioProject"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,44 +0,0 @@
CREATE TYPE "MediaSource" AS ENUM ('UPLOAD', 'EXTERNAL');
CREATE TYPE "MediaKind" AS ENUM ('IMAGE', 'DOCUMENT');
CREATE TYPE "MediaUsageType" AS ENUM ('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');
CREATE TABLE "MediaAsset" (
"id" TEXT 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) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaAsset_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "MediaUsage" (
"id" TEXT 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) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaUsage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset"("kind", "createdAt");
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage"("assetId");
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage"("entityType", "entityId");
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage"("usageType", "entityType", "entityId", "fieldKey");
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "MediaAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,4 +0,0 @@
CREATE TYPE "PortfolioProjectViewMode" AS ENUM ('GRID', 'STORY', 'CASE_STUDY');
ALTER TABLE "PortfolioProject"
ADD COLUMN "viewMode" "PortfolioProjectViewMode" NOT NULL DEFAULT 'GRID';
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
-168
View File
@@ -1,168 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model AppConfig {
id String @id @default(cuid())
key String @unique
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum PortfolioSectionType {
RICH_TEXT
GALLERY
STATS
DELIVERABLES
LINK
}
enum PortfolioAssetKind {
IMAGE
DOCUMENT
}
enum PortfolioProjectViewMode {
GRID
STORY
CASE_STUDY
}
enum MediaSource {
UPLOAD
EXTERNAL
}
enum MediaKind {
IMAGE
DOCUMENT
}
enum MediaUsageType {
PORTFOLIO_COVER
PORTFOLIO_SECTION
PORTFOLIO_ASSET
GENERIC
}
model Category {
id String @id @default(cuid())
slug String @unique
nameAr String
nameEn String
nameDe String
descriptionAr String
descriptionEn String
descriptionDe String
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
projects PortfolioProject[]
}
model PortfolioProject {
id String @id @default(cuid())
categoryId String
slug String @unique
viewMode PortfolioProjectViewMode @default(GRID)
titleAr String
titleEn String
titleDe String
summaryAr String
summaryEn String
summaryDe String
clientName String
projectYear Int
serviceLabelAr String
serviceLabelEn String
serviceLabelDe String
previewUrl String?
coverImagePath String?
isFeatured Boolean @default(false)
isPublished Boolean @default(false)
publishedAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
sections PortfolioSection[]
assets PortfolioAsset[]
@@index([categoryId, isPublished, sortOrder])
@@index([isPublished, sortOrder])
}
model PortfolioSection {
id String @id @default(cuid())
projectId String
type PortfolioSectionType
titleAr String
titleEn String
titleDe String
bodyAr String
bodyEn String
bodyDe String
imagePath String?
linkUrl String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project PortfolioProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, sortOrder])
}
model PortfolioAsset {
id String @id @default(cuid())
projectId String
kind PortfolioAssetKind
filePath String
altAr String
altEn String
altDe String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project PortfolioProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, sortOrder])
}
model MediaAsset {
id String @id @default(cuid())
source MediaSource
kind MediaKind
url String
fileName String
label String
altText String?
mimeType String?
size Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usages MediaUsage[]
@@index([kind, createdAt])
}
model MediaUsage {
id String @id @default(cuid())
assetId String
usageType MediaUsageType
entityType String
entityId String
fieldKey String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@unique([usageType, entityType, entityId, fieldKey])
@@index([assetId])
@@index([entityType, entityId])
}
+58 -37
View File
@@ -1,6 +1,13 @@
import { MediaKind, MediaSource, MediaUsageType, PortfolioSectionType } from "@prisma/client"; import { db } from "@/lib/db";
import {
import { prisma } from "@/lib/prisma"; category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaKind, MediaSource, MediaUsageType, PortfolioSectionType } from "@/lib/db/enums";
let counter = 0; let counter = 0;
function uniq(prefix: string) { function uniq(prefix: string) {
@@ -8,11 +15,11 @@ function uniq(prefix: string) {
return `${prefix}-${counter}`; return `${prefix}-${counter}`;
} }
export function createCategory(overrides: Record<string, unknown> = {}) { export async function createCategory(overrides: Record<string, unknown> = {}) {
const slug = (overrides.slug as string) ?? uniq("cat"); const [row] = await db
return prisma.category.create({ .insert(category)
data: { .values({
slug, slug: (overrides.slug as string) ?? uniq("cat"),
nameAr: "الاسم", nameAr: "الاسم",
nameEn: "Name", nameEn: "Name",
nameDe: "Name", nameDe: "Name",
@@ -22,16 +29,19 @@ export function createCategory(overrides: Record<string, unknown> = {}) {
sortOrder: 0, sortOrder: 0,
isActive: true, isActive: true,
...overrides, ...overrides,
}, } as typeof category.$inferInsert)
}); .returning();
return row;
} }
export async function createProject(overrides: Record<string, unknown> = {}) { export async function createProject(overrides: Record<string, unknown> = {}) {
const categoryId = (overrides.categoryId as string) ?? (await createCategory()).id; const categoryId = (overrides.categoryId as string) ?? (await createCategory()).id;
const isPublished = (overrides.isPublished as boolean) ?? true; const isPublished = (overrides.isPublished as boolean) ?? true;
const slug = (overrides.slug as string) ?? uniq("proj"); const slug = (overrides.slug as string) ?? uniq("proj");
return prisma.portfolioProject.create({ const [row] = await db
data: { .insert(portfolioProject)
.values({
categoryId, categoryId,
slug, slug,
viewMode: "GRID", viewMode: "GRID",
@@ -51,13 +61,16 @@ export async function createProject(overrides: Record<string, unknown> = {}) {
...overrides, ...overrides,
isPublished, isPublished,
publishedAt: isPublished ? new Date() : null, publishedAt: isPublished ? new Date() : null,
}, } as typeof portfolioProject.$inferInsert)
}); .returning();
return row;
} }
export function createSection(projectId: string, overrides: Record<string, unknown> = {}) { export async function createSection(projectId: string, overrides: Record<string, unknown> = {}) {
return prisma.portfolioSection.create({ const [row] = await db
data: { .insert(portfolioSection)
.values({
projectId, projectId,
type: PortfolioSectionType.RICH_TEXT, type: PortfolioSectionType.RICH_TEXT,
titleAr: "ع", titleAr: "ع",
@@ -68,13 +81,16 @@ export function createSection(projectId: string, overrides: Record<string, unkno
bodyDe: "b", bodyDe: "b",
sortOrder: 0, sortOrder: 0,
...overrides, ...overrides,
}, } as typeof portfolioSection.$inferInsert)
}); .returning();
return row;
} }
export function createAsset(projectId: string, overrides: Record<string, unknown> = {}) { export async function createAsset(projectId: string, overrides: Record<string, unknown> = {}) {
return prisma.portfolioAsset.create({ const [row] = await db
data: { .insert(portfolioAsset)
.values({
projectId, projectId,
kind: "IMAGE", kind: "IMAGE",
filePath: "/uploads/media/assets/x.svg", filePath: "/uploads/media/assets/x.svg",
@@ -83,35 +99,40 @@ export function createAsset(projectId: string, overrides: Record<string, unknown
altDe: "a", altDe: "a",
sortOrder: 0, sortOrder: 0,
...overrides, ...overrides,
}, } as typeof portfolioAsset.$inferInsert)
}); .returning();
return row;
} }
export function createMediaAsset(overrides: Record<string, unknown> = {}) { export async function createMediaAsset(overrides: Record<string, unknown> = {}) {
return prisma.mediaAsset.create({ const [row] = await db
data: { .insert(mediaAsset)
.values({
source: MediaSource.EXTERNAL, source: MediaSource.EXTERNAL,
kind: MediaKind.IMAGE, kind: MediaKind.IMAGE,
url: (overrides.url as string) ?? `https://cdn.example.com/${uniq("img")}.png`, url: (overrides.url as string) ?? `https://cdn.example.com/${uniq("img")}.png`,
fileName: "img.png", fileName: "img.png",
label: "Image", label: "Image",
...overrides, ...overrides,
}, } as typeof mediaAsset.$inferInsert)
}); .returning();
return row;
} }
export function createMediaUsage( export async function createMediaUsage(assetId: string, overrides: Record<string, unknown> = {}) {
assetId: string, const [row] = await db
overrides: Record<string, unknown> = {}, .insert(mediaUsage)
) { .values({
return prisma.mediaUsage.create({
data: {
assetId, assetId,
usageType: MediaUsageType.GENERIC, usageType: MediaUsageType.GENERIC,
entityType: "test-entity", entityType: "test-entity",
entityId: "e1", entityId: "e1",
fieldKey: uniq("field"), fieldKey: uniq("field"),
...overrides, ...overrides,
}, } as typeof mediaUsage.$inferInsert)
}); .returning();
return row;
} }
+8 -8
View File
@@ -5,12 +5,12 @@ import path from "path";
* Global integration setup. * Global integration setup.
* *
* Only needed when running against a real Postgres via TEST_DATABASE_URL: reset the * Only needed when running against a real Postgres via TEST_DATABASE_URL: reset the
* schema and apply every migration once before the workers start. When * 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 * TEST_DATABASE_URL is not set, each worker spins up its own in-process PGlite
* (see tests/helpers/integration-setup.ts) and this is a no-op. * database (see tests/helpers/integration-setup.ts) and this is a no-op.
*/ */
const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma", "migrations"); const MIGRATIONS_DIR = path.resolve(process.cwd(), "lib", "db", "migrations");
export default async function setup() { export default async function setup() {
const connectionString = process.env.TEST_DATABASE_URL?.trim(); const connectionString = process.env.TEST_DATABASE_URL?.trim();
@@ -23,11 +23,11 @@ export default async function setup() {
await client.connect(); await client.connect();
try { try {
await client.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;"); await client.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;");
const dirs = readdirSync(MIGRATIONS_DIR) const files = readdirSync(MIGRATIONS_DIR)
.filter((entry) => /^\d/.test(entry)) .filter((entry) => entry.endsWith(".sql"))
.sort(); .sort();
for (const dir of dirs) { for (const file of files) {
await client.query(readFileSync(path.join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8")); await client.query(readFileSync(path.join(MIGRATIONS_DIR, file), "utf8"));
} }
} finally { } finally {
await client.end(); await client.end();
+32 -27
View File
@@ -1,16 +1,19 @@
import { readFileSync, readdirSync } from "fs"; import { readFileSync, readdirSync } from "fs";
import path from "path"; import path from "path";
import { sql } from "drizzle-orm";
import { afterAll, beforeEach, vi } from "vitest"; import { afterAll, beforeEach, vi } from "vitest";
import * as schema from "@/lib/db/schema";
/** /**
* Integration database wiring. * Integration database wiring (Drizzle).
* *
* - If TEST_DATABASE_URL is set, the real `lib/prisma` singleton is used unchanged, * - If TEST_DATABASE_URL is set, the real `@/lib/db` singleton is used unchanged,
* pointed at that Postgres (e.g. the Docker instance). Migrations are applied once * pointed at that Postgres. Migrations are applied once by the global setup;
* by the global setup; files run serially and truncate between tests. * files run serially and truncate between tests.
* *
* - Otherwise, `lib/prisma` is mocked with a Prisma client backed by an in-process * - Otherwise, `@/lib/db` is mocked with a Drizzle client backed by an in-process
* PGlite database (Postgres compiled to WASM) — real Postgres semantics, fully * PGlite database (Postgres compiled to WASM) — real Postgres semantics, fully
* isolated per worker, no external server. Production code is never modified. * isolated per worker, no external server. Production code is never modified.
*/ */
@@ -20,44 +23,45 @@ if (realDbUrl) {
process.env.DATABASE_URL = realDbUrl; process.env.DATABASE_URL = realDbUrl;
} }
vi.mock("@/lib/prisma", async () => { vi.mock("@/lib/db", async () => {
if (process.env.TEST_DATABASE_URL?.trim()) { if (process.env.TEST_DATABASE_URL?.trim()) {
return await vi.importActual<typeof import("@/lib/prisma")>("@/lib/prisma"); return await vi.importActual<typeof import("@/lib/db")>("@/lib/db");
} }
const { PGlite } = await import("@electric-sql/pglite"); const { PGlite } = await import("@electric-sql/pglite");
const { PrismaPGlite } = await import("pglite-prisma-adapter"); const { drizzle } = await import("drizzle-orm/pglite");
const { PrismaClient } = await import("@prisma/client");
const db = await PGlite.create(); const client = new PGlite();
const migrationsDir = path.resolve(process.cwd(), "prisma", "migrations"); const migrationsDir = path.resolve(process.cwd(), "lib", "db", "migrations");
const dirs = readdirSync(migrationsDir) const files = readdirSync(migrationsDir)
.filter((entry) => /^\d/.test(entry)) .filter((entry) => entry.endsWith(".sql"))
.sort(); .sort();
for (const dir of dirs) { for (const file of files) {
await db.exec(readFileSync(path.join(migrationsDir, dir, "migration.sql"), "utf8")); await client.exec(readFileSync(path.join(migrationsDir, file), "utf8"));
} }
const prisma = new PrismaClient({ adapter: new PrismaPGlite(db) }); const db = drizzle(client, { schema });
return { prisma }; return { db, schema };
}); });
const { prisma } = await import("@/lib/prisma"); const { db } = await import("@/lib/db");
export { db };
// Truncated in dependency order (children first) between every test for isolation. // Truncated in dependency order (children first) between every test for isolation.
const TABLES = [ const TABLES = [
"MediaUsage", "media_usage",
"MediaAsset", "media_asset",
"PortfolioAsset", "portfolio_asset",
"PortfolioSection", "portfolio_section",
"PortfolioProject", "portfolio_project",
"Category", "category",
"AppConfig", "app_config",
]; ];
export async function resetDb() { export async function resetDb() {
const list = TABLES.map((table) => `"${table}"`).join(", "); const list = TABLES.map((table) => `"${table}"`).join(", ");
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`); await db.execute(sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`));
} }
beforeEach(async () => { beforeEach(async () => {
@@ -65,5 +69,6 @@ beforeEach(async () => {
}); });
afterAll(async () => { afterAll(async () => {
await prisma.$disconnect(); // 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.
}); });
+15 -13
View File
@@ -1,11 +1,14 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { appConfig, category } from "@/lib/db/schema";
describe("integration harness smoke test", () => { describe("integration harness smoke test", () => {
it("connects to the migrated test database and performs CRUD", async () => { it("connects to the migrated test database and performs CRUD", async () => {
const created = await prisma.category.create({ const [created] = await db
data: { .insert(category)
.values({
slug: "smoke", slug: "smoke",
nameAr: "a", nameAr: "a",
nameEn: "b", nameEn: "b",
@@ -13,28 +16,27 @@ describe("integration harness smoke test", () => {
descriptionAr: "a", descriptionAr: "a",
descriptionEn: "b", descriptionEn: "b",
descriptionDe: "c", descriptionDe: "c",
}, })
}); .returning();
expect(created.id).toBeTruthy(); expect(created.id).toBeTruthy();
expect(created.isActive).toBe(true); expect(created.isActive).toBe(true);
const found = await prisma.category.findUnique({ where: { slug: "smoke" } }); const found = await db.query.category.findFirst({ where: eq(category.slug, "smoke") });
expect(found?.nameEn).toBe("b"); expect(found?.nameEn).toBe("b");
}); });
it("resets the database between tests", async () => { it("resets the database between tests", async () => {
const count = await prisma.category.count(); const count = await db.$count(category);
expect(count).toBe(0); expect(count).toBe(0);
}); });
it("supports enums and appconfig upsert", async () => { it("supports enums and appconfig upsert", async () => {
await prisma.appConfig.upsert({ await db
where: { key: "k" }, .insert(appConfig)
update: { value: "v2" }, .values({ key: "k", value: "v1" })
create: { key: "k", value: "v1" }, .onConflictDoUpdate({ target: appConfig.key, set: { value: "v2" } });
}); const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, "k") });
const row = await prisma.appConfig.findUnique({ where: { key: "k" } });
expect(row?.value).toBe("v1"); expect(row?.value).toBe("v1");
}); });
}); });
+8 -5
View File
@@ -10,8 +10,11 @@ vi.mock("@/lib/admin-auth", async () => {
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie }; return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
}); });
import { eq } from "drizzle-orm";
import { createMediaAssetAction, deleteMediaAssetAction } from "@/app/_admin/media/actions"; import { createMediaAssetAction, deleteMediaAssetAction } from "@/app/_admin/media/actions";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { removeManagedMediaFile } from "@/lib/media-storage"; import { removeManagedMediaFile } from "@/lib/media-storage";
import { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories"; import { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability"; import { canManageUploads } from "@/tests/helpers/fs-capability";
@@ -25,7 +28,7 @@ describe("createMediaAssetAction", () => {
it("errors when no file is provided", async () => { it("errors when no file is provided", async () => {
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" }))); const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
expect(url).toContain("error="); expect(url).toContain("error=");
expect(await prisma.mediaAsset.count()).toBe(0); expect(await db.$count(mediaAsset)).toBe(0);
}); });
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => { it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
@@ -34,7 +37,7 @@ describe("createMediaAssetAction", () => {
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })), createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
); );
expect(url).toContain("success="); expect(url).toContain("success=");
const assets = await prisma.mediaAsset.findMany(); const assets = await db.select().from(mediaAsset);
expect(assets.length).toBe(1); expect(assets.length).toBe(1);
expect(assets[0].source).toBe("UPLOAD"); expect(assets[0].source).toBe("UPLOAD");
await removeManagedMediaFile(assets[0].url); await removeManagedMediaFile(assets[0].url);
@@ -58,14 +61,14 @@ describe("deleteMediaAssetAction", () => {
await createMediaUsage(asset.id); await createMediaUsage(asset.id);
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id }))); const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
expect(url).toContain("error="); expect(url).toContain("error=");
expect(await prisma.mediaAsset.findUnique({ where: { id: asset.id } })).not.toBeNull(); expect((await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, asset.id) })) ?? null).not.toBeNull();
}); });
it("deletes an unused external asset", async () => { it("deletes an unused external asset", async () => {
const asset = await createMediaAsset({ url: "https://cdn/external.png" }); const asset = await createMediaAsset({ url: "https://cdn/external.png" });
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id }))); const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
expect(url).toContain("success="); expect(url).toContain("success=");
expect(await prisma.mediaAsset.findUnique({ where: { id: asset.id } })).toBeNull(); expect((await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, asset.id) })) ?? null).toBeNull();
}); });
it("redirects unauthenticated callers to the admin root", async () => { it("redirects unauthenticated callers to the admin root", async () => {
+23 -17
View File
@@ -16,7 +16,15 @@ import {
saveProjectAction, saveProjectAction,
upsertCategoryAction, upsertCategoryAction,
} from "@/app/_admin/portfolio/actions"; } from "@/app/_admin/portfolio/actions";
import { prisma } from "@/lib/prisma"; 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 { createCategory, createProject } from "@/tests/helpers/factories";
import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks"; import { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
@@ -81,7 +89,7 @@ describe("upsertCategoryAction", () => {
it("creates a category", async () => { it("creates a category", async () => {
const url = await captureRedirect(() => upsertCategoryAction(categoryForm())); const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
expect(url).toContain("success="); expect(url).toContain("success=");
const category = await prisma.category.findUnique({ where: { slug: "branding" } }); const category = await db.query.category.findFirst({ where: eq(categoryTable.slug, "branding") });
expect(category?.nameEn).toBe("Branding"); expect(category?.nameEn).toBe("Branding");
expect(category?.isActive).toBe(true); expect(category?.isActive).toBe(true);
}); });
@@ -92,7 +100,7 @@ describe("upsertCategoryAction", () => {
upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })), upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })),
); );
expect(url).toContain("success="); expect(url).toContain("success=");
const category = await prisma.category.findUnique({ where: { id: existing.id } }); const category = await db.query.category.findFirst({ where: eq(categoryTable.id, existing.id) });
expect(category?.nameEn).toBe("Renamed"); expect(category?.nameEn).toBe("Renamed");
}); });
@@ -121,14 +129,14 @@ describe("deleteCategoryAction", () => {
await createProject({ categoryId: category.id }); await createProject({ categoryId: category.id });
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id }))); const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
expect(url).toContain("error="); expect(url).toContain("error=");
expect(await prisma.category.findUnique({ where: { id: category.id } })).not.toBeNull(); expect((await db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).not.toBeNull();
}); });
it("deletes an empty category", async () => { it("deletes an empty category", async () => {
const category = await createCategory(); const category = await createCategory();
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id }))); const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
expect(url).toContain("success="); expect(url).toContain("success=");
expect(await prisma.category.findUnique({ where: { id: category.id } })).toBeNull(); expect((await db.query.category.findFirst({ where: eq(categoryTable.id, category.id) })) ?? null).toBeNull();
}); });
}); });
@@ -138,16 +146,14 @@ describe("saveProjectAction", () => {
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id))); const url = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
expect(url).toContain("success="); expect(url).toContain("success=");
const project = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } }); const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
expect(project).not.toBeNull(); expect(project).not.toBeNull();
expect(project?.isPublished).toBe(true); expect(project?.isPublished).toBe(true);
expect(project?.publishedAt).not.toBeNull(); expect(project?.publishedAt).not.toBeNull();
expect(project?.coverImagePath).toBe("https://cdn/cover.png"); expect(project?.coverImagePath).toBe("https://cdn/cover.png");
expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1); expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
const usages = await prisma.mediaUsage.findMany({ const usages = await db.select().from(mediaUsageTable).where(and(eq(mediaUsageTable.entityType, "portfolio-project"), eq(mediaUsageTable.entityId, project!.id)));
where: { entityType: "portfolio-project", entityId: project!.id },
});
const usageTypes = usages.map((u) => u.usageType).sort(); const usageTypes = usages.map((u) => u.usageType).sort();
expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]); expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]);
}); });
@@ -156,26 +162,26 @@ describe("saveProjectAction", () => {
const category = await createCategory(); const category = await createCategory();
const created = await captureRedirect(() => saveProjectAction(projectForm(category.id))); const created = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
void created; void created;
const project = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } }); const project = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
const url = await captureRedirect(() => const url = await captureRedirect(() =>
saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })), saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
); );
expect(url).toContain("success="); expect(url).toContain("success=");
const updated = await prisma.portfolioProject.findUnique({ where: { id: project!.id } }); const updated = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project!.id) });
expect(updated?.titleEn).toBe("Updated Title"); expect(updated?.titleEn).toBe("Updated Title");
// assets are replaced, not duplicated // assets are replaced, not duplicated
expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1); 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 () => { it("keeps the original publishedAt when re-saving an already published project", async () => {
const category = await createCategory(); const category = await createCategory();
await captureRedirect(() => saveProjectAction(projectForm(category.id))); await captureRedirect(() => saveProjectAction(projectForm(category.id)));
const first = await prisma.portfolioProject.findUnique({ where: { slug: "case-study" } }); const first = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.slug, "case-study") });
const originalPublishedAt = first!.publishedAt; const originalPublishedAt = first!.publishedAt;
await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id }))); await captureRedirect(() => saveProjectAction(projectForm(category.id, { id: first!.id })));
const second = await prisma.portfolioProject.findUnique({ where: { id: first!.id } }); const second = await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, first!.id) });
expect(second?.publishedAt?.toISOString()).toBe(originalPublishedAt?.toISOString()); expect(second?.publishedAt?.toISOString()).toBe(originalPublishedAt?.toISOString());
}); });
@@ -183,7 +189,7 @@ describe("saveProjectAction", () => {
const category = await createCategory(); const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" }))); const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" })));
expect(url).toContain("error="); expect(url).toContain("error=");
expect(await prisma.portfolioProject.count()).toBe(0); expect(await db.$count(portfolioProjectTable)).toBe(0);
}); });
it("reports a unique-constraint violation on duplicate slugs", async () => { it("reports a unique-constraint violation on duplicate slugs", async () => {
@@ -206,7 +212,7 @@ describe("deleteProjectAction", () => {
const project = await createProject(); const project = await createProject();
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id }))); const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id })));
expect(url).toContain("success="); expect(url).toContain("success=");
expect(await prisma.portfolioProject.findUnique({ where: { id: project.id } })).toBeNull(); expect((await db.query.portfolioProject.findFirst({ where: eq(portfolioProjectTable.id, project.id) })) ?? null).toBeNull();
}); });
it("errors when the project does not exist", async () => { it("errors when the project does not exist", async () => {
+2 -2
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { GET as healthGet } from "@/app/api/health/route"; import { GET as healthGet } from "@/app/api/health/route";
import { GET as defaultLocaleGet } from "@/app/api/site/default-locale/route"; import { GET as defaultLocaleGet } from "@/app/api/site/default-locale/route";
import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config"; import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -20,7 +20,7 @@ describe("GET /api/health", () => {
}); });
it("reports degraded (503) when the database query throws", async () => { it("reports degraded (503) when the database query throws", async () => {
vi.spyOn(prisma, "$queryRaw").mockRejectedValueOnce(new Error("db down")); vi.spyOn(db, "execute").mockRejectedValueOnce(new Error("db down"));
const response = await healthGet(); const response = await healthGet();
expect(response.status).toBe(503); expect(response.status).toBe(503);
const body = await response.json(); const body = await response.json();
+10 -11
View File
@@ -20,7 +20,10 @@ import {
updateMarqueeSettings, updateMarqueeSettings,
updateSiteSettings, updateSiteSettings,
} from "@/lib/app-config"; } from "@/lib/app-config";
import { prisma } from "@/lib/prisma"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { appConfig, mediaUsage } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories"; import { createMediaAsset } from "@/tests/helpers/factories";
describe("maintenance mode", () => { describe("maintenance mode", () => {
@@ -31,7 +34,7 @@ describe("maintenance mode", () => {
it("persists and reads back the enabled flag", async () => { it("persists and reads back the enabled flag", async () => {
await setMaintenanceMode(true); await setMaintenanceMode(true);
expect(await getMaintenanceMode()).toBe(true); expect(await getMaintenanceMode()).toBe(true);
const row = await prisma.appConfig.findUnique({ where: { key: MAINTENANCE_MODE_KEY } }); const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, MAINTENANCE_MODE_KEY) });
expect(row?.value).toBe("true"); expect(row?.value).toBe("true");
await setMaintenanceMode(false); await setMaintenanceMode(false);
expect(await getMaintenanceMode()).toBe(false); expect(await getMaintenanceMode()).toBe(false);
@@ -46,7 +49,7 @@ describe("site settings", () => {
}); });
it("uses the stored siteName key as the fallback name", async () => { it("uses the stored siteName key as the fallback name", async () => {
await prisma.appConfig.create({ data: { key: SITE_NAME_KEY, value: "My Studio" } }); await db.insert(appConfig).values({ key: SITE_NAME_KEY, value: "My Studio" });
const settings = await getSiteSettings(); const settings = await getSiteSettings();
expect(settings.locales.ar.siteName).toBe("My Studio"); expect(settings.locales.ar.siteName).toBe("My Studio");
}); });
@@ -113,24 +116,20 @@ describe("getSiteSettingsMediaBindings", () => {
it("maps media usages to their field bindings", async () => { it("maps media usages to their field bindings", async () => {
const logo = await createMediaAsset({ url: "https://cdn/logo.png" }); const logo = await createMediaAsset({ url: "https://cdn/logo.png" });
const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" }); const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" });
await prisma.mediaUsage.create({ await db.insert(mediaUsage).values({
data: {
assetId: logo.id, assetId: logo.id,
usageType: "GENERIC", usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE, entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID, entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY, fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
}, });
}); await db.insert(mediaUsage).values({
await prisma.mediaUsage.create({
data: {
assetId: favicon.id, assetId: favicon.id,
usageType: "GENERIC", usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE, entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID, entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY, fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
}, });
});
const bindings = await getSiteSettingsMediaBindings(); const bindings = await getSiteSettingsMediaBindings();
expect(bindings.siteLogoLight?.assetId).toBe(logo.id); expect(bindings.siteLogoLight?.assetId).toBe(logo.id);
+6 -3
View File
@@ -2,9 +2,12 @@ import { readFile } from "fs/promises";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { resolveMediaSelection } from "@/lib/media-service"; import { resolveMediaSelection } from "@/lib/media-service";
import { resolveMediaUploadPath } from "@/lib/media-storage"; import { resolveMediaUploadPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories"; import { createMediaAsset } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability"; import { canManageUploads } from "@/tests/helpers/fs-capability";
@@ -47,7 +50,7 @@ describe("resolveMediaSelection — external mode", () => {
expect(result.createdAssetId).toBeTruthy(); expect(result.createdAssetId).toBeTruthy();
expect(result.url).toBe("https://cdn/new/photo.png"); expect(result.url).toBe("https://cdn/new/photo.png");
const stored = await prisma.mediaAsset.findUnique({ where: { id: result.assetId! } }); const stored = await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, result.assetId!) });
expect(stored?.source).toBe("EXTERNAL"); expect(stored?.source).toBe("EXTERNAL");
expect(stored?.fileName).toBe("photo.png"); expect(stored?.fileName).toBe("photo.png");
expect(stored?.label).toBe("Photo"); expect(stored?.label).toBe("Photo");
@@ -108,7 +111,7 @@ describe("resolveMediaSelection — upload mode (filesystem)", () => {
required: true, required: true,
}); });
expect(result.uploadedUrl).toBeTruthy(); expect(result.uploadedUrl).toBeTruthy();
const stored = await prisma.mediaAsset.findUnique({ where: { id: result.assetId! } }); const stored = await db.query.mediaAsset.findFirst({ where: eq(mediaAsset.id, result.assetId!) });
expect(stored?.source).toBe("UPLOAD"); expect(stored?.source).toBe("UPLOAD");
// File actually written to disk // File actually written to disk
const bytes = await readFile(resolveMediaUploadPath(result.url)); const bytes = await readFile(resolveMediaUploadPath(result.url));
+5 -8
View File
@@ -10,7 +10,8 @@ import {
getPortfolioMediaBindings, getPortfolioMediaBindings,
replaceEntityMediaUsages, replaceEntityMediaUsages,
} from "@/lib/media"; } from "@/lib/media";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaUsage } from "@/lib/db/schema";
import { createMediaAsset as seedAsset } from "@/tests/helpers/factories"; import { createMediaAsset as seedAsset } from "@/tests/helpers/factories";
describe("createMediaAsset / getMediaAssetById", () => { describe("createMediaAsset / getMediaAssetById", () => {
@@ -110,13 +111,11 @@ describe("getPortfolioMediaBindings", () => {
const section = await seedAsset(); const section = await seedAsset();
const asset = await seedAsset(); const asset = await seedAsset();
await prisma.mediaUsage.createMany({ await db.insert(mediaUsage).values([
data: [
{ assetId: cover.id, usageType: "PORTFOLIO_COVER", entityType: "portfolio-project", entityId: "proj", fieldKey: "cover" }, { 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: 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" }, { assetId: asset.id, usageType: "PORTFOLIO_ASSET", entityType: "portfolio-project", entityId: "proj", fieldKey: "ast_1" },
], ]);
});
const bindings = await getPortfolioMediaBindings("proj"); const bindings = await getPortfolioMediaBindings("proj");
expect(bindings.coverAssetId).toBe(cover.id); expect(bindings.coverAssetId).toBe(cover.id);
@@ -131,7 +130,5 @@ describe("getPortfolioMediaBindings", () => {
}); });
async function createMediaUsageFor(assetId: string) { async function createMediaUsageFor(assetId: string) {
await prisma.mediaUsage.create({ await db.insert(mediaUsage).values({ assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" });
data: { assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" },
});
} }
+10 -9
View File
@@ -9,7 +9,10 @@ import {
getPublishedPortfolioProjectBySlug, getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects, getPublishedPortfolioProjects,
} from "@/lib/portfolio"; } from "@/lib/portfolio";
import { prisma } from "@/lib/prisma"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { category, mediaUsage, portfolioAsset, portfolioProject, portfolioSection } from "@/lib/db/schema";
import { import {
createAsset, createAsset,
createCategory, createCategory,
@@ -75,15 +78,13 @@ describe("admin projects", () => {
it("attaches media bindings to a project fetched by id", async () => { it("attaches media bindings to a project fetched by id", async () => {
const project = await createProject(); const project = await createProject();
const cover = await createMediaAsset(); const cover = await createMediaAsset();
await prisma.mediaUsage.create({ await db.insert(mediaUsage).values({
data: {
assetId: cover.id, assetId: cover.id,
usageType: "PORTFOLIO_COVER", usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project", entityType: "portfolio-project",
entityId: project.id, entityId: project.id,
fieldKey: "cover", fieldKey: "cover",
}, });
});
const detail = await getAdminPortfolioProjectById(project.id); const detail = await getAdminPortfolioProjectById(project.id);
expect(detail?.coverMediaAssetId).toBe(cover.id); expect(detail?.coverMediaAssetId).toBe(cover.id);
}); });
@@ -127,15 +128,15 @@ describe("referential integrity", () => {
it("restricts deleting a category that still has projects", async () => { it("restricts deleting a category that still has projects", async () => {
const cat = await createCategory(); const cat = await createCategory();
await createProject({ categoryId: cat.id }); await createProject({ categoryId: cat.id });
await expect(prisma.category.delete({ where: { id: cat.id } })).rejects.toThrow(); 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 () => { it("cascades section and asset deletion when a project is removed", async () => {
const project = await createProject(); const project = await createProject();
await createSection(project.id); await createSection(project.id);
await createAsset(project.id); await createAsset(project.id);
await prisma.portfolioProject.delete({ where: { id: project.id } }); await db.delete(portfolioProject).where(eq(portfolioProject.id, project.id));
expect(await prisma.portfolioSection.count()).toBe(0); expect(await db.$count(portfolioSection)).toBe(0);
expect(await prisma.portfolioAsset.count()).toBe(0); expect(await db.$count(portfolioAsset)).toBe(0);
}); });
}); });