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
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
@@ -17,8 +17,8 @@ COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/lib/db ./lib/db
COPY --from=builder /app/next.config.mjs ./next.config.mjs
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
@@ -38,13 +38,10 @@ db-shell:
docker compose exec db psql -U postgres -d moh_sass
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:
docker compose exec app npx prisma migrate deploy
db-seed:
docker compose exec app npx prisma db seed
docker compose exec app npm run db:migrate
test:
@node scripts/test-summary.mjs
@@ -52,11 +49,8 @@ test:
test-watch:
npm run test:watch
prisma-generate:
docker compose exec app npx prisma generate
prisma-migrate:
docker compose exec app npx prisma migrate dev --name $(MIGRATION_NAME)
db-generate:
docker compose exec app npm run db:generate
health:
curl -sS https://mohfarawati.de/api/health
@@ -74,11 +68,9 @@ help:
@echo " make clean-orphans Remove orphaned old containers"
@echo " make app-shell Open shell in app container"
@echo " make db-shell Open PostgreSQL shell"
@echo " make db-init Generate client, apply migrations, run seed"
@echo " make db-migrate Apply prisma migrations"
@echo " make db-seed Seed database data"
@echo " make prisma-generate Run prisma generate"
@echo " make prisma-migrate Create/apply dev migration"
@echo " make db-init Apply Drizzle migrations to the DB"
@echo " make db-migrate Apply Drizzle migrations (drizzle-kit migrate)"
@echo " make db-generate Generate a migration from schema changes (drizzle-kit generate)"
@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-watch Run the test suite in watch mode"
+6 -7
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
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() {
if (!(await isAdminAuthenticated())) {
@@ -71,11 +74,7 @@ export async function deleteMediaAssetAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
}
await prisma.mediaAsset.delete({
where: {
id: asset.id,
},
});
await db.delete(mediaAsset).where(eq(mediaAsset.id, asset.id));
if (isManagedMediaFilePath(asset.url)) {
await deleteMediaAssetAndFile({
+76 -110
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaUsageType, Prisma } from "@prisma/client";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 { removeManagedMediaFile } from "@/lib/media-storage";
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 { getSiteSettings } from "@/lib/app-config";
import {
@@ -81,6 +90,20 @@ function parseZodError(error: ZodError) {
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() {
revalidatePath(toInternalAdminPath("/"));
revalidatePath(toInternalAdminPath("/media"));
@@ -121,16 +144,9 @@ export async function upsertCategoryAction(formData: FormData) {
});
if (parsed.id) {
await prisma.category.update({
where: {
id: parsed.id,
},
data: parsed,
});
await db.update(category).set(parsed).where(eq(category.id, parsed.id));
} else {
await prisma.category.create({
data: parsed,
});
await db.insert(category).values(parsed);
}
await revalidatePortfolioPages();
@@ -143,7 +159,7 @@ export async function upsertCategoryAction(formData: FormData) {
const message =
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
: isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden.";
@@ -158,21 +174,13 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? "");
try {
const projectCount = await prisma.portfolioProject.count({
where: {
categoryId: id,
},
});
const projectCount = await db.$count(portfolioProject, eq(portfolioProject.categoryId, id));
if (projectCount > 0) {
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
}
await prisma.category.delete({
where: {
id,
},
});
await db.delete(category).where(eq(category.id, id));
await revalidatePortfolioPages();
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
@@ -241,15 +249,16 @@ export async function saveProjectAction(formData: FormData) {
});
const existingProject = parsed.id
? await prisma.portfolioProject.findUnique({
where: {
id: parsed.id,
},
select: {
isPublished: true,
publishedAt: true,
},
? (
await db
.select({
isPublished: portfolioProject.isPublished,
publishedAt: portfolioProject.publishedAt,
})
.from(portfolioProject)
.where(eq(portfolioProject.id, parsed.id))
.limit(1)
)[0] ?? null
: null;
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
@@ -359,13 +368,8 @@ export async function saveProjectAction(formData: FormData) {
});
}
const projectResult = await prisma.$transaction(async (tx) => {
const currentProject = parsed.id
? await tx.portfolioProject.update({
where: {
id: parsed.id,
},
data: {
const projectResult = await db.transaction(async (tx) => {
const projectValues = {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
@@ -384,56 +388,36 @@ export async function saveProjectAction(formData: FormData) {
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
sortOrder: parsed.sortOrder,
};
const [currentProject] = parsed.id
? await tx
.update(portfolioProject)
.set({
...projectValues,
publishedAt: parsed.isPublished
? shouldPublishNow
? new Date()
: existingProject?.publishedAt ?? new Date()
: null,
sortOrder: parsed.sortOrder,
},
})
: await tx.portfolioProject.create({
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
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,
},
});
.where(eq(portfolioProject.id, parsed.id))
.returning()
: await tx
.insert(portfolioProject)
.values({ ...projectValues, publishedAt: parsed.isPublished ? new Date() : null })
.returning();
await tx.portfolioSection.deleteMany({
where: {
projectId: currentProject.id,
},
});
await tx.portfolioAsset.deleteMany({
where: {
projectId: currentProject.id,
},
});
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
await tx.delete(portfolioAsset).where(eq(portfolioAsset.projectId, currentProject.id));
const createdSections = [];
for (const section of sectionRows) {
const createdSection = await tx.portfolioSection.create({
data: {
const [createdSection] = await tx
.insert(portfolioSection)
.values({
projectId: currentProject.id,
type: section.type,
titleAr: section.titleAr,
@@ -445,8 +429,8 @@ export async function saveProjectAction(formData: FormData) {
imagePath: section.imagePath || null,
linkUrl: section.linkUrl || null,
sortOrder: section.sortOrder,
},
});
})
.returning();
createdSections.push(createdSection);
}
@@ -454,8 +438,9 @@ export async function saveProjectAction(formData: FormData) {
const createdAssets = [];
for (const asset of assetRows) {
const createdAsset = await tx.portfolioAsset.create({
data: {
const [createdAsset] = await tx
.insert(portfolioAsset)
.values({
projectId: currentProject.id,
kind: asset.kind,
filePath: asset.filePath,
@@ -463,8 +448,8 @@ export async function saveProjectAction(formData: FormData) {
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
},
});
})
.returning();
createdAssets.push(createdAsset);
}
@@ -536,7 +521,7 @@ export async function saveProjectAction(formData: FormData) {
const message =
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
: isUniqueViolation(error)
? "Projekt Slug muss eindeutig sein."
: error instanceof Error
? error.message
@@ -544,20 +529,8 @@ export async function saveProjectAction(formData: FormData) {
await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) {
await prisma.mediaUsage.deleteMany({
where: {
assetId: {
in: createdMediaAssetIds,
},
},
});
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: createdMediaAssetIds,
},
},
});
await db.delete(mediaUsage).where(inArray(mediaUsage.assetId, createdMediaAssetIds));
await db.delete(mediaAsset).where(inArray(mediaAsset.id, createdMediaAssetIds));
}
redirect(withFlash(redirectPath, { error: message }));
}
@@ -569,24 +542,17 @@ export async function deleteProjectAction(formData: FormData) {
const id = String(formData.get("id") ?? "");
try {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
select: {
slug: true,
},
});
const [project] = await db
.select({ slug: portfolioProject.slug })
.from(portfolioProject)
.where(eq(portfolioProject.id, id))
.limit(1);
if (!project) {
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
}
await prisma.portfolioProject.delete({
where: {
id,
},
});
await db.delete(portfolioProject).where(eq(portfolioProject.id, id));
await deleteEntityMediaUsages("portfolio-project", id);
await revalidatePortfolioPages();
+6 -9
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaUsageType } from "@prisma/client";
import { MediaUsageType } from "@/lib/db/enums";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,7 +30,10 @@ import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage";
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() {
if (!(await isAdminAuthenticated())) {
@@ -60,13 +63,7 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
if (assetIds.length > 0) {
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: Array.from(new Set(assetIds)),
},
},
});
await db.delete(mediaAsset).where(inArray(mediaAsset.id, Array.from(new Set(assetIds))));
}
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 { MotionFade } from "@/components/motion-fade";
+3 -2
View File
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
@@ -8,7 +9,7 @@ export async function GET() {
const timestamp = new Date().toISOString();
try {
await prisma.$queryRaw`SELECT 1`;
await db.execute(sql`SELECT 1`);
return NextResponse.json(
{
+1 -1
View File
@@ -2,7 +2,7 @@
/* 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 { useEffect, useMemo, useRef, useState } from "react";
+1 -1
View File
@@ -2,7 +2,7 @@
/* 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 Link from "next/link";
import { useEffect, useMemo, useState } from "react";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import {
ArrowDown,
ArrowUp,
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import {
Check,
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",
},
});
+24 -19
View File
@@ -2,7 +2,10 @@ import { createHash, createHmac, timingSafeEqual } from "crypto";
import { cookies, headers } from "next/headers";
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";
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
@@ -137,11 +140,9 @@ function getLockoutKey(ip: string): string {
async function cleanupExpiredLockouts(): Promise<void> {
try {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await prisma.$executeRaw`
DELETE FROM "AppConfig"
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`}
AND "updatedAt" < ${cutoff}
`;
await db
.delete(appConfig)
.where(and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)));
} catch {
// Non-critical — ignore cleanup errors.
}
@@ -216,10 +217,11 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const [config] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const state = parseFailState(config?.value);
const now = Date.now();
@@ -244,10 +246,11 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts();
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const [config] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const current = parseFailState(config?.value);
// If a previous lockout has expired, reset the counter.
@@ -256,10 +259,12 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
await prisma.appConfig.upsert({
where: { key },
update: { value: JSON.stringify({ attempts, lockUntil }) },
create: { key, value: JSON.stringify({ attempts, lockUntil }) },
await db
.insert(appConfig)
.values({ key, value: JSON.stringify({ attempts, lockUntil }) })
.onConflictDoUpdate({
target: appConfig.key,
set: { value: JSON.stringify({ attempts, lockUntil }) },
});
return {
@@ -276,7 +281,7 @@ export async function resetAdminFailedAttempts(): Promise<void> {
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
await prisma.appConfig.deleteMany({ where: { key } });
await db.delete(appConfig).where(eq(appConfig.key, key));
} catch {
// Non-critical — ignore.
}
+59 -110
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 {
SITE_NAME_KEY,
@@ -65,45 +68,42 @@ import {
type MarqueeSettings,
} 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> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAINTENANCE_MODE_KEY },
select: { value: true },
});
return config?.value === "true";
return (await readConfigValue(MAINTENANCE_MODE_KEY)) === "true";
} catch {
return false;
}
}
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAINTENANCE_MODE_KEY },
update: {
value: enabled ? "true" : "false",
},
create: {
key: MAINTENANCE_MODE_KEY,
value: enabled ? "true" : "false",
},
});
await upsertConfig(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
}
export async function getSiteSettings(): Promise<SiteSettings> {
try {
const configs = await prisma.appConfig.findMany({
where: {
key: {
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY],
},
},
select: {
key: true,
value: true,
},
});
const configs = await db
.select({ key: appConfig.key, value: appConfig.value })
.from(appConfig)
.where(inArray(appConfig.key, [SITE_SETTINGS_KEY, SITE_NAME_KEY]));
const configMap = new Map(configs.map((config) => [config.key, config.value]));
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> {
await prisma.appConfig.upsert({
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertConfig(SITE_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMailSettings(): Promise<MailSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
return parseMailSettingsValue(await readConfigValue(MAIL_SETTINGS_KEY));
} catch {
return buildDefaultMailSettings();
}
@@ -147,26 +133,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
}
export async function updateMailSettings(settings: MailSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertConfig(MAIL_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MARQUEE_SETTINGS_KEY },
select: { value: true },
});
return parseMarqueeSettingsValue(config?.value);
return parseMarqueeSettingsValue(await readConfigValue(MARQUEE_SETTINGS_KEY));
} catch {
return buildDefaultMarqueeSettings();
}
@@ -175,75 +147,52 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await prisma.appConfig.upsert({
where: { key: MARQUEE_SETTINGS_KEY },
update: {
value: JSON.stringify(normalizedSettings),
},
create: {
key: MARQUEE_SETTINGS_KEY,
value: JSON.stringify(normalizedSettings),
},
});
await upsertConfig(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
},
select: {
fieldKey: true,
updatedAt: true,
asset: {
select: {
id: true,
url: true,
},
},
},
});
const usages = await db
.select({
fieldKey: mediaUsage.fieldKey,
updatedAt: mediaUsage.updatedAt,
assetId: mediaAsset.id,
assetUrl: mediaAsset.url,
})
.from(mediaUsage)
.innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId))
.where(
and(
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
),
);
return usages.reduce<SiteSettingsMediaBindings>(
(result, usage) => {
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoLight = {
assetId: usage.asset.id,
url: usage.asset.url,
return usages.reduce<SiteSettingsMediaBindings>((result, usage) => {
const binding = {
assetId: usage.assetId,
url: usage.assetUrl,
version: usage.updatedAt.toISOString(),
};
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoLight = binding;
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
result.siteLogoDark = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
result.siteLogoDark = binding;
}
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.favicon = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
result.favicon = binding;
}
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
result.defaultOgImage = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
result.defaultOgImage = binding;
}
return result;
},
getDefaultSiteSettingsMediaBindings(),
);
}, getDefaultSiteSettingsMediaBindings());
} catch {
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 { MediaKind, MediaSource } from "@prisma/client";
import { MediaKind, MediaSource } from "@/lib/db/enums";
import { createMediaAsset, getMediaAssetById } from "@/lib/media";
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";
const mediaModeSchema = z.enum(["library", "external", "upload"]);
+54 -79
View File
@@ -1,20 +1,17 @@
import type {
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { and, desc, eq } from "drizzle-orm";
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<
MediaAsset,
MediaAssetRow,
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
> & {
usages: Array<
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
>;
usages: Array<Pick<MediaUsageRow, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">>;
};
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
@@ -25,11 +22,7 @@ export type PortfolioMediaBindings = {
assetIds: Record<string, string>;
};
function mapMediaAsset(
asset: MediaAsset & {
usages: MediaUsage[];
},
): MediaAssetView {
function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): MediaAssetView {
return {
id: asset.id,
source: asset.source,
@@ -52,40 +45,32 @@ function mapMediaAsset(
}
export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({
include: {
usages: {
orderBy: [{ createdAt: "desc" }],
},
},
orderBy: [{ createdAt: "desc" }],
const assets = await db.query.mediaAsset.findMany({
with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } },
orderBy: [desc(mediaAsset.createdAt)],
});
return assets.map(mapMediaAsset);
}
export async function getMediaOptions(filters?: { kind?: MediaKind }) {
const assets = await prisma.mediaAsset.findMany({
where: filters?.kind ? { kind: filters.kind } : undefined,
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
kind: true,
url: true,
label: true,
source: true,
},
});
return assets;
export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<MediaOption[]> {
return db
.select({
id: mediaAsset.id,
kind: mediaAsset.kind,
url: mediaAsset.url,
label: mediaAsset.label,
source: mediaAsset.source,
})
.from(mediaAsset)
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
.orderBy(desc(mediaAsset.createdAt));
}
export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({
where: { id },
include: {
usages: true,
},
const asset = await db.query.mediaAsset.findFirst({
where: eq(mediaAsset.id, id),
with: { usages: true },
});
return asset ? mapMediaAsset(asset) : null;
@@ -101,8 +86,9 @@ export async function createMediaAsset(input: {
mimeType?: string | null;
size?: number | null;
}) {
return prisma.mediaAsset.create({
data: {
const [created] = await db
.insert(mediaAsset)
.values({
source: input.source,
kind: input.kind,
url: input.url,
@@ -111,8 +97,10 @@ export async function createMediaAsset(input: {
altText: input.altText ?? null,
mimeType: input.mimeType ?? null,
size: input.size ?? null,
},
});
})
.returning();
return created;
}
export async function replaceEntityMediaUsages(input: {
@@ -124,51 +112,42 @@ export async function replaceEntityMediaUsages(input: {
fieldKey: string;
}>;
}) {
await prisma.$transaction(async (tx) => {
await tx.mediaUsage.deleteMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
});
await db.transaction(async (tx) => {
await tx
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, input.entityType), eq(mediaUsage.entityId, input.entityId)));
if (input.usages.length === 0) {
return;
}
await tx.mediaUsage.createMany({
data: input.usages.map((usage) => ({
await tx.insert(mediaUsage).values(
input.usages.map((usage) => ({
assetId: usage.assetId,
usageType: usage.usageType,
entityType: input.entityType,
entityId: input.entityId,
fieldKey: usage.fieldKey,
})),
});
);
});
}
export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({
where: {
entityType,
entityId,
},
});
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, entityType), eq(mediaUsage.entityId, entityId)));
}
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
select: {
assetId: true,
usageType: true,
fieldKey: true,
},
});
const usages = await db
.select({
assetId: mediaUsage.assetId,
usageType: mediaUsage.usageType,
fieldKey: mediaUsage.fieldKey,
})
.from(mediaUsage)
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
return usages.reduce<PortfolioMediaBindings>(
(result, usage) => {
@@ -195,9 +174,5 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
}
export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({
where: {
assetId,
},
});
return db.$count(mediaUsage, eq(mediaUsage.assetId, 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";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PortfolioSectionType } from "@prisma/client";
import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
+70 -145
View File
@@ -1,74 +1,22 @@
import type {
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
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 type { AppLocale } from "@/lib/locale";
import { prisma } from "@/lib/prisma";
type CategoryRecord = Pick<
Category,
| "id"
| "slug"
| "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"
>;
type CategoryRecord = typeof categoryTable.$inferSelect;
type SectionRecord = typeof portfolioSection.$inferSelect;
type AssetRecord = typeof portfolioAsset.$inferSelect;
type ProjectRecord = typeof portfolioProject.$inferSelect;
export type LocalizedContent = {
ar: string;
@@ -240,40 +188,29 @@ export function getLocalizedValue(
}
export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({
include: {
_count: {
select: {
projects: true,
},
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
const categories = await db.query.category.findMany({
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
with: { projects: { columns: { id: true } } },
});
return categories.map((category) => ({
...mapCategory(category),
projectCount: category._count.projects,
projectCount: category.projects.length,
}));
}
export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({
where: {
isActive: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
const categories = await db.query.category.findMany({
where: eq(categoryTable.isActive, true),
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
});
return categories.map(mapCategory);
}
export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await prisma.category.findFirst({
where: {
slug,
isActive: true,
},
const category = await db.query.category.findFirst({
where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)),
});
return category ? mapCategory(category) : null;
@@ -283,90 +220,78 @@ export async function getAdminPortfolioProjects(filters?: {
categoryId?: string;
status?: "all" | "draft" | "published";
}) {
const projects = await prisma.portfolioProject.findMany({
where: {
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}),
const conditions = [
...(filters?.categoryId ? [eq(portfolioProject.categoryId, filters.categoryId)] : []),
...(filters?.status === "draft"
? { isPublished: false }
? [eq(portfolioProject.isPublished, false)]
: filters?.status === "published"
? { isPublished: true }
: {}),
},
include: {
? [eq(portfolioProject.isPublished, true)]
: []),
];
const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined,
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
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));
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({
where: {
isPublished: true,
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
const projects = await db.query.portfolioProject.findMany({
where: eq(portfolioProject.isPublished, true),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
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) {
const project = await prisma.portfolioProject.findFirst({
where: {
slug,
isPublished: true,
category: {
isActive: true,
},
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
});
return project ? mapProject(project) : null;
if (!project || !project.category.isActive) {
return null;
}
return mapProject(project);
});
export async function getAdminPortfolioProjectById(id: string) {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
},
});
-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",
"lint": "eslint .",
"test": "vitest run",
"prisma:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "node prisma/seed.js"
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -28,6 +23,7 @@
"@types/nodemailer": "^7.0.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.35.0",
"gsap": "^3.15.0",
"lucide-react": "^0.577.0",
@@ -36,6 +32,7 @@
"next-themes": "^0.4.6",
"nodemailer": "^8.0.1",
"pg": "^8.20.0",
"postgres": "^3.4.9",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
@@ -44,7 +41,6 @@
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.4",
"@electric-sql/pglite-socket": "^0.2.7",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
@@ -53,12 +49,11 @@
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.6",
"jsdom": "^30.0.1",
"pglite-prisma-adapter": "^0.7.2",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"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 { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaKind, MediaSource, MediaUsageType, PortfolioSectionType } from "@/lib/db/enums";
let counter = 0;
function uniq(prefix: string) {
@@ -8,11 +15,11 @@ function uniq(prefix: string) {
return `${prefix}-${counter}`;
}
export function createCategory(overrides: Record<string, unknown> = {}) {
const slug = (overrides.slug as string) ?? uniq("cat");
return prisma.category.create({
data: {
slug,
export async function createCategory(overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(category)
.values({
slug: (overrides.slug as string) ?? uniq("cat"),
nameAr: "الاسم",
nameEn: "Name",
nameDe: "Name",
@@ -22,16 +29,19 @@ export function createCategory(overrides: Record<string, unknown> = {}) {
sortOrder: 0,
isActive: true,
...overrides,
},
});
} as typeof category.$inferInsert)
.returning();
return row;
}
export async function createProject(overrides: Record<string, unknown> = {}) {
const categoryId = (overrides.categoryId as string) ?? (await createCategory()).id;
const isPublished = (overrides.isPublished as boolean) ?? true;
const slug = (overrides.slug as string) ?? uniq("proj");
return prisma.portfolioProject.create({
data: {
const [row] = await db
.insert(portfolioProject)
.values({
categoryId,
slug,
viewMode: "GRID",
@@ -51,13 +61,16 @@ export async function createProject(overrides: Record<string, unknown> = {}) {
...overrides,
isPublished,
publishedAt: isPublished ? new Date() : null,
},
});
} as typeof portfolioProject.$inferInsert)
.returning();
return row;
}
export function createSection(projectId: string, overrides: Record<string, unknown> = {}) {
return prisma.portfolioSection.create({
data: {
export async function createSection(projectId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(portfolioSection)
.values({
projectId,
type: PortfolioSectionType.RICH_TEXT,
titleAr: "ع",
@@ -68,13 +81,16 @@ export function createSection(projectId: string, overrides: Record<string, unkno
bodyDe: "b",
sortOrder: 0,
...overrides,
},
});
} as typeof portfolioSection.$inferInsert)
.returning();
return row;
}
export function createAsset(projectId: string, overrides: Record<string, unknown> = {}) {
return prisma.portfolioAsset.create({
data: {
export async function createAsset(projectId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(portfolioAsset)
.values({
projectId,
kind: "IMAGE",
filePath: "/uploads/media/assets/x.svg",
@@ -83,35 +99,40 @@ export function createAsset(projectId: string, overrides: Record<string, unknown
altDe: "a",
sortOrder: 0,
...overrides,
},
});
} as typeof portfolioAsset.$inferInsert)
.returning();
return row;
}
export function createMediaAsset(overrides: Record<string, unknown> = {}) {
return prisma.mediaAsset.create({
data: {
export async function createMediaAsset(overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(mediaAsset)
.values({
source: MediaSource.EXTERNAL,
kind: MediaKind.IMAGE,
url: (overrides.url as string) ?? `https://cdn.example.com/${uniq("img")}.png`,
fileName: "img.png",
label: "Image",
...overrides,
},
});
} as typeof mediaAsset.$inferInsert)
.returning();
return row;
}
export function createMediaUsage(
assetId: string,
overrides: Record<string, unknown> = {},
) {
return prisma.mediaUsage.create({
data: {
export async function createMediaUsage(assetId: string, overrides: Record<string, unknown> = {}) {
const [row] = await db
.insert(mediaUsage)
.values({
assetId,
usageType: MediaUsageType.GENERIC,
entityType: "test-entity",
entityId: "e1",
fieldKey: uniq("field"),
...overrides,
},
});
} as typeof mediaUsage.$inferInsert)
.returning();
return row;
}
+8 -8
View File
@@ -5,12 +5,12 @@ import path from "path";
* Global integration setup.
*
* Only needed when running against a real Postgres via TEST_DATABASE_URL: reset the
* schema and apply every migration once before the workers start. When
* TEST_DATABASE_URL is not set, each worker spins up its own in-process PGlite database
* (see tests/helpers/integration-setup.ts) and this is a no-op.
* schema and apply every Drizzle migration once before the workers start. When
* TEST_DATABASE_URL is not set, each worker spins up its own in-process PGlite
* database (see tests/helpers/integration-setup.ts) and this is a no-op.
*/
const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma", "migrations");
const MIGRATIONS_DIR = path.resolve(process.cwd(), "lib", "db", "migrations");
export default async function setup() {
const connectionString = process.env.TEST_DATABASE_URL?.trim();
@@ -23,11 +23,11 @@ export default async function setup() {
await client.connect();
try {
await client.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;");
const dirs = readdirSync(MIGRATIONS_DIR)
.filter((entry) => /^\d/.test(entry))
const files = readdirSync(MIGRATIONS_DIR)
.filter((entry) => entry.endsWith(".sql"))
.sort();
for (const dir of dirs) {
await client.query(readFileSync(path.join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8"));
for (const file of files) {
await client.query(readFileSync(path.join(MIGRATIONS_DIR, file), "utf8"));
}
} finally {
await client.end();
+32 -27
View File
@@ -1,16 +1,19 @@
import { readFileSync, readdirSync } from "fs";
import path from "path";
import { sql } from "drizzle-orm";
import { afterAll, beforeEach, vi } from "vitest";
import * as schema from "@/lib/db/schema";
/**
* Integration database wiring.
* Integration database wiring (Drizzle).
*
* - If TEST_DATABASE_URL is set, the real `lib/prisma` singleton is used unchanged,
* pointed at that Postgres (e.g. the Docker instance). Migrations are applied once
* by the global setup; files run serially and truncate between tests.
* - If TEST_DATABASE_URL is set, the real `@/lib/db` singleton is used unchanged,
* pointed at that Postgres. Migrations are applied once by the global setup;
* files run serially and truncate between tests.
*
* - Otherwise, `lib/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
* isolated per worker, no external server. Production code is never modified.
*/
@@ -20,44 +23,45 @@ if (realDbUrl) {
process.env.DATABASE_URL = realDbUrl;
}
vi.mock("@/lib/prisma", async () => {
vi.mock("@/lib/db", async () => {
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 { PrismaPGlite } = await import("pglite-prisma-adapter");
const { PrismaClient } = await import("@prisma/client");
const { drizzle } = await import("drizzle-orm/pglite");
const db = await PGlite.create();
const migrationsDir = path.resolve(process.cwd(), "prisma", "migrations");
const dirs = readdirSync(migrationsDir)
.filter((entry) => /^\d/.test(entry))
const client = new PGlite();
const migrationsDir = path.resolve(process.cwd(), "lib", "db", "migrations");
const files = readdirSync(migrationsDir)
.filter((entry) => entry.endsWith(".sql"))
.sort();
for (const dir of dirs) {
await db.exec(readFileSync(path.join(migrationsDir, dir, "migration.sql"), "utf8"));
for (const file of files) {
await client.exec(readFileSync(path.join(migrationsDir, file), "utf8"));
}
const prisma = new PrismaClient({ adapter: new PrismaPGlite(db) });
return { prisma };
const db = drizzle(client, { schema });
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.
const TABLES = [
"MediaUsage",
"MediaAsset",
"PortfolioAsset",
"PortfolioSection",
"PortfolioProject",
"Category",
"AppConfig",
"media_usage",
"media_asset",
"portfolio_asset",
"portfolio_section",
"portfolio_project",
"category",
"app_config",
];
export async function resetDb() {
const list = TABLES.map((table) => `"${table}"`).join(", ");
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`);
await db.execute(sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`));
}
beforeEach(async () => {
@@ -65,5 +69,6 @@ beforeEach(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 { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import { appConfig, category } from "@/lib/db/schema";
describe("integration harness smoke test", () => {
it("connects to the migrated test database and performs CRUD", async () => {
const created = await prisma.category.create({
data: {
const [created] = await db
.insert(category)
.values({
slug: "smoke",
nameAr: "a",
nameEn: "b",
@@ -13,28 +16,27 @@ describe("integration harness smoke test", () => {
descriptionAr: "a",
descriptionEn: "b",
descriptionDe: "c",
},
});
})
.returning();
expect(created.id).toBeTruthy();
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");
});
it("resets the database between tests", async () => {
const count = await prisma.category.count();
const count = await db.$count(category);
expect(count).toBe(0);
});
it("supports enums and appconfig upsert", async () => {
await prisma.appConfig.upsert({
where: { key: "k" },
update: { value: "v2" },
create: { key: "k", value: "v1" },
});
const row = await prisma.appConfig.findUnique({ where: { key: "k" } });
await db
.insert(appConfig)
.values({ key: "k", value: "v1" })
.onConflictDoUpdate({ target: appConfig.key, set: { value: "v2" } });
const row = await db.query.appConfig.findFirst({ where: eq(appConfig.key, "k") });
expect(row?.value).toBe("v1");
});
});
+8 -5
View File
@@ -10,8 +10,11 @@ vi.mock("@/lib/admin-auth", async () => {
return { isAdminAuthenticated: m.isAdminAuthenticated, clearAdminSessionCookie: m.clearAdminSessionCookie };
});
import { eq } from "drizzle-orm";
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 { createMediaAsset, createMediaUsage } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability";
@@ -25,7 +28,7 @@ describe("createMediaAssetAction", () => {
it("errors when no file is provided", async () => {
const url = await captureRedirect(() => createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "L" })));
expect(url).toContain("error=");
expect(await prisma.mediaAsset.count()).toBe(0);
expect(await db.$count(mediaAsset)).toBe(0);
});
it.skipIf(!canManageUploads)("creates an asset from an uploaded file", async () => {
@@ -34,7 +37,7 @@ describe("createMediaAssetAction", () => {
createMediaAssetAction(formDataFrom({ kind: "IMAGE", label: "Pic", file })),
);
expect(url).toContain("success=");
const assets = await prisma.mediaAsset.findMany();
const assets = await db.select().from(mediaAsset);
expect(assets.length).toBe(1);
expect(assets[0].source).toBe("UPLOAD");
await removeManagedMediaFile(assets[0].url);
@@ -58,14 +61,14 @@ describe("deleteMediaAssetAction", () => {
await createMediaUsage(asset.id);
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
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 () => {
const asset = await createMediaAsset({ url: "https://cdn/external.png" });
const url = await captureRedirect(() => deleteMediaAssetAction(formDataFrom({ assetId: asset.id })));
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 () => {
+23 -17
View File
@@ -16,7 +16,15 @@ import {
saveProjectAction,
upsertCategoryAction,
} 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 { adminAuth, captureRedirect, formDataFrom, resetNextMocks } from "@/tests/helpers/next-mocks";
@@ -81,7 +89,7 @@ describe("upsertCategoryAction", () => {
it("creates a category", async () => {
const url = await captureRedirect(() => upsertCategoryAction(categoryForm()));
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?.isActive).toBe(true);
});
@@ -92,7 +100,7 @@ describe("upsertCategoryAction", () => {
upsertCategoryAction(categoryForm({ id: existing.id, slug: "old", nameEn: "Renamed" })),
);
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");
});
@@ -121,14 +129,14 @@ describe("deleteCategoryAction", () => {
await createProject({ categoryId: category.id });
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
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 () => {
const category = await createCategory();
const url = await captureRedirect(() => deleteCategoryAction(formDataFrom({ id: category.id })));
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)));
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?.isPublished).toBe(true);
expect(project?.publishedAt).not.toBeNull();
expect(project?.coverImagePath).toBe("https://cdn/cover.png");
expect(await prisma.portfolioAsset.count({ where: { projectId: project!.id } })).toBe(1);
const usages = await prisma.mediaUsage.findMany({
where: { entityType: "portfolio-project", entityId: project!.id },
});
expect(await db.$count(portfolioAssetTable, eq(portfolioAssetTable.projectId, project!.id))).toBe(1);
const usages = await db.select().from(mediaUsageTable).where(and(eq(mediaUsageTable.entityType, "portfolio-project"), eq(mediaUsageTable.entityId, project!.id)));
const usageTypes = usages.map((u) => u.usageType).sort();
expect(usageTypes).toEqual(["PORTFOLIO_ASSET", "PORTFOLIO_COVER"]);
});
@@ -156,26 +162,26 @@ describe("saveProjectAction", () => {
const category = await createCategory();
const created = await captureRedirect(() => saveProjectAction(projectForm(category.id)));
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(() =>
saveProjectAction(projectForm(category.id, { id: project!.id, titleEn: "Updated Title" })),
);
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");
// 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 () => {
const category = await createCategory();
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;
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());
});
@@ -183,7 +189,7 @@ describe("saveProjectAction", () => {
const category = await createCategory();
const url = await captureRedirect(() => saveProjectAction(projectForm(category.id, { titleEn: "" })));
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 () => {
@@ -206,7 +212,7 @@ describe("deleteProjectAction", () => {
const project = await createProject();
const url = await captureRedirect(() => deleteProjectAction(formDataFrom({ id: project.id })));
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 () => {
+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 defaultLocaleGet } from "@/app/api/site/default-locale/route";
import { setMaintenanceMode, updateSiteSettings, getSiteSettings } from "@/lib/app-config";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
afterEach(() => {
vi.restoreAllMocks();
@@ -20,7 +20,7 @@ describe("GET /api/health", () => {
});
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();
expect(response.status).toBe(503);
const body = await response.json();
+8 -9
View File
@@ -20,7 +20,10 @@ import {
updateMarqueeSettings,
updateSiteSettings,
} 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";
describe("maintenance mode", () => {
@@ -31,7 +34,7 @@ describe("maintenance mode", () => {
it("persists and reads back the enabled flag", async () => {
await setMaintenanceMode(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");
await setMaintenanceMode(false);
expect(await getMaintenanceMode()).toBe(false);
@@ -46,7 +49,7 @@ describe("site settings", () => {
});
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();
expect(settings.locales.ar.siteName).toBe("My Studio");
});
@@ -113,23 +116,19 @@ describe("getSiteSettingsMediaBindings", () => {
it("maps media usages to their field bindings", async () => {
const logo = await createMediaAsset({ url: "https://cdn/logo.png" });
const favicon = await createMediaAsset({ url: "https://cdn/favicon.svg" });
await prisma.mediaUsage.create({
data: {
await db.insert(mediaUsage).values({
assetId: logo.id,
usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
},
});
await prisma.mediaUsage.create({
data: {
await db.insert(mediaUsage).values({
assetId: favicon.id,
usageType: "GENERIC",
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
},
});
const bindings = await getSiteSettingsMediaBindings();
+6 -3
View File
@@ -2,9 +2,12 @@ import { readFile } from "fs/promises";
import { describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { resolveMediaSelection } from "@/lib/media-service";
import { resolveMediaUploadPath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { createMediaAsset } from "@/tests/helpers/factories";
import { canManageUploads } from "@/tests/helpers/fs-capability";
@@ -47,7 +50,7 @@ describe("resolveMediaSelection — external mode", () => {
expect(result.createdAssetId).toBeTruthy();
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?.fileName).toBe("photo.png");
expect(stored?.label).toBe("Photo");
@@ -108,7 +111,7 @@ describe("resolveMediaSelection — upload mode (filesystem)", () => {
required: true,
});
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");
// File actually written to disk
const bytes = await readFile(resolveMediaUploadPath(result.url));
+5 -8
View File
@@ -10,7 +10,8 @@ import {
getPortfolioMediaBindings,
replaceEntityMediaUsages,
} 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";
describe("createMediaAsset / getMediaAssetById", () => {
@@ -110,13 +111,11 @@ describe("getPortfolioMediaBindings", () => {
const section = await seedAsset();
const asset = await seedAsset();
await prisma.mediaUsage.createMany({
data: [
await db.insert(mediaUsage).values([
{ assetId: cover.id, usageType: "PORTFOLIO_COVER", entityType: "portfolio-project", entityId: "proj", fieldKey: "cover" },
{ assetId: section.id, usageType: "PORTFOLIO_SECTION", entityType: "portfolio-project", entityId: "proj", fieldKey: "sec_1" },
{ assetId: asset.id, usageType: "PORTFOLIO_ASSET", entityType: "portfolio-project", entityId: "proj", fieldKey: "ast_1" },
],
});
]);
const bindings = await getPortfolioMediaBindings("proj");
expect(bindings.coverAssetId).toBe(cover.id);
@@ -131,7 +130,5 @@ describe("getPortfolioMediaBindings", () => {
});
async function createMediaUsageFor(assetId: string) {
await prisma.mediaUsage.create({
data: { assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" },
});
await db.insert(mediaUsage).values({ assetId, usageType: "GENERIC", entityType: "e", entityId: "1", fieldKey: "f" });
}
+9 -8
View File
@@ -9,7 +9,10 @@ import {
getPublishedPortfolioProjectBySlug,
getPublishedPortfolioProjects,
} 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 {
createAsset,
createCategory,
@@ -75,14 +78,12 @@ describe("admin projects", () => {
it("attaches media bindings to a project fetched by id", async () => {
const project = await createProject();
const cover = await createMediaAsset();
await prisma.mediaUsage.create({
data: {
await db.insert(mediaUsage).values({
assetId: cover.id,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: project.id,
fieldKey: "cover",
},
});
const detail = await getAdminPortfolioProjectById(project.id);
expect(detail?.coverMediaAssetId).toBe(cover.id);
@@ -127,15 +128,15 @@ describe("referential integrity", () => {
it("restricts deleting a category that still has projects", async () => {
const cat = await createCategory();
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 () => {
const project = await createProject();
await createSection(project.id);
await createAsset(project.id);
await prisma.portfolioProject.delete({ where: { id: project.id } });
expect(await prisma.portfolioSection.count()).toBe(0);
expect(await prisma.portfolioAsset.count()).toBe(0);
await db.delete(portfolioProject).where(eq(portfolioProject.id, project.id));
expect(await db.$count(portfolioSection)).toBe(0);
expect(await db.$count(portfolioAsset)).toBe(0);
});
});