REFACTORED - Replace Prisma with Drizzle ORM
CI / quality (push) Waiting to run

- Add lib/db (schema, postgres.js client, enums, seed, migrations) on Drizzle
- Rewrite all lib and admin action queries from Prisma to Drizzle
- Keep existing table/column names so no data migration is needed
- Preserve signed-cookie admin auth unchanged
- Map unique-violation handling from Prisma P2002 to SQLSTATE 23505
- Swap deps, scripts, Makefile, and Dockerfile from Prisma to Drizzle
This commit is contained in:
Moh
2026-08-08 02:09:03 +02:00
parent 0f48381894
commit ba63f75ea8
38 changed files with 3964 additions and 2520 deletions
+3 -3
View File
@@ -7,7 +7,7 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npx prisma generate && npm run build RUN npm run build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
@@ -17,8 +17,8 @@ COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/lib/db ./lib/db
COPY --from=builder /app/next.config.mjs ./next.config.mjs COPY --from=builder /app/next.config.mjs ./next.config.mjs
EXPOSE 3000 EXPOSE 3000
+14 -16
View File
@@ -1,6 +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 help .PHONY: start stop restart deploy logs build ps port health clean-orphans app-shell db-shell db-init db-generate db-migrate db-push db-seed help
MIGRATION_NAME ?= init
start: start:
docker compose up -d --build docker compose up -d --build
@@ -38,19 +36,19 @@ db-shell:
docker compose exec db psql -U postgres -d moh_sass docker compose exec db psql -U postgres -d moh_sass
db-init: db-init:
docker compose exec app sh -lc "npx prisma generate && npx prisma migrate deploy && npx prisma db seed" docker compose exec app sh -lc "npm run db:migrate && npm run db:seed"
db-generate:
docker compose exec app npm run db:generate
db-migrate: db-migrate:
docker compose exec app npx prisma migrate deploy docker compose exec app npm run db:migrate
db-push:
docker compose exec app npm run db:push
db-seed: db-seed:
docker compose exec app npx prisma db seed docker compose exec app npm run db:seed
prisma-generate:
docker compose exec app npx prisma generate
prisma-migrate:
docker compose exec app npx prisma migrate dev --name $(MIGRATION_NAME)
health: health:
curl -sS https://mohfarawati.de/api/health curl -sS https://mohfarawati.de/api/health
@@ -68,9 +66,9 @@ help:
@echo " make clean-orphans Remove orphaned old containers" @echo " make clean-orphans Remove orphaned old containers"
@echo " make app-shell Open shell in app container" @echo " make app-shell Open shell in app container"
@echo " make db-shell Open PostgreSQL shell" @echo " make db-shell Open PostgreSQL shell"
@echo " make db-init Generate client, apply migrations, run seed" @echo " make db-init Apply migrations and run seed (first run)"
@echo " make db-migrate Apply prisma migrations" @echo " make db-generate Generate a Drizzle migration from schema changes"
@echo " make db-migrate Apply pending Drizzle migrations"
@echo " make db-push Push schema directly (dev convenience)"
@echo " make db-seed Seed database data" @echo " make db-seed Seed database data"
@echo " make prisma-generate Run prisma generate"
@echo " make prisma-migrate Create/apply dev migration"
@echo " make health Check app health endpoint via public domain" @echo " make health Check app health endpoint via public domain"
+5 -7
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaKind } from "@prisma/client"; import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -11,7 +11,9 @@ import { withFlash } from "@/lib/admin-feedback";
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media"; import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service"; import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
import { isManagedMediaFilePath } from "@/lib/media-storage"; import { isManagedMediaFilePath } from "@/lib/media-storage";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { MediaKind } from "@/lib/db/enums";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
@@ -71,11 +73,7 @@ export async function deleteMediaAssetAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." })); redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
} }
await prisma.mediaAsset.delete({ await db.delete(mediaAsset).where(eq(mediaAsset.id, asset.id));
where: {
id: asset.id,
},
});
if (isManagedMediaFilePath(asset.url)) { if (isManagedMediaFilePath(asset.url)) {
await deleteMediaAssetAndFile({ await deleteMediaAssetAndFile({
+85 -111
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaUsageType, Prisma } from "@prisma/client"; import { and, count, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -15,7 +15,16 @@ import { resolveMediaSelection } from "@/lib/media-service";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage"; import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation"; import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
import { isCheckedFormValue } from "@/lib/form-data"; import { isCheckedFormValue } from "@/lib/form-data";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { import {
@@ -81,6 +90,16 @@ function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validierung fehlgeschlagen."; return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
} }
// Postgres unique-violation SQLSTATE (was Prisma's P2002).
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: string }).code === "23505"
);
}
async function revalidatePortfolioPages() { async function revalidatePortfolioPages() {
revalidatePath(toInternalAdminPath("/")); revalidatePath(toInternalAdminPath("/"));
revalidatePath(toInternalAdminPath("/media")); revalidatePath(toInternalAdminPath("/media"));
@@ -120,17 +139,15 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"), isActive: normalizeCheckboxValue(formData, "isActive"),
}); });
if (parsed.id) { const { id: categoryId, ...categoryValues } = parsed;
await prisma.category.update({
where: { if (categoryId) {
id: parsed.id, await db
}, .update(category)
data: parsed, .set({ ...categoryValues, updatedAt: new Date() })
}); .where(eq(category.id, categoryId));
} else { } else {
await prisma.category.create({ await db.insert(category).values(categoryValues);
data: parsed,
});
} }
await revalidatePortfolioPages(); await revalidatePortfolioPages();
@@ -143,7 +160,7 @@ export async function upsertCategoryAction(formData: FormData) {
const message = const message =
error instanceof ZodError error instanceof ZodError
? parseZodError(error) ? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" : isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein." ? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden."; : "Kategorie konnte nicht gespeichert werden.";
@@ -158,21 +175,16 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? ""); const id = String(formData.get("id") ?? "");
try { try {
const projectCount = await prisma.portfolioProject.count({ const [projectCountRow] = await db
where: { .select({ value: count() })
categoryId: id, .from(portfolioProject)
}, .where(eq(portfolioProject.categoryId, id));
});
if (projectCount > 0) { if ((projectCountRow?.value ?? 0) > 0) {
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." })); redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
} }
await prisma.category.delete({ await db.delete(category).where(eq(category.id, id));
where: {
id,
},
});
await revalidatePortfolioPages(); await revalidatePortfolioPages();
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." })); redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
@@ -241,15 +253,16 @@ export async function saveProjectAction(formData: FormData) {
}); });
const existingProject = parsed.id const existingProject = parsed.id
? await prisma.portfolioProject.findUnique({ ? (
where: { await db
id: parsed.id, .select({
}, isPublished: portfolioProject.isPublished,
select: { publishedAt: portfolioProject.publishedAt,
isPublished: true,
publishedAt: true,
},
}) })
.from(portfolioProject)
.where(eq(portfolioProject.id, parsed.id))
.limit(1)
)[0] ?? null
: null; : null;
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt; const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
@@ -359,13 +372,8 @@ export async function saveProjectAction(formData: FormData) {
}); });
} }
const projectResult = await prisma.$transaction(async (tx) => { const projectResult = await db.transaction(async (tx) => {
const currentProject = parsed.id const projectValues = {
? await tx.portfolioProject.update({
where: {
id: parsed.id,
},
data: {
categoryId: parsed.categoryId, categoryId: parsed.categoryId,
slug: parsed.slug, slug: parsed.slug,
viewMode: parsed.viewMode, viewMode: parsed.viewMode,
@@ -384,56 +392,40 @@ export async function saveProjectAction(formData: FormData) {
coverImagePath: coverSelection.url || null, coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured, isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished, isPublished: parsed.isPublished,
sortOrder: parsed.sortOrder,
};
const [currentProject] = parsed.id
? await tx
.update(portfolioProject)
.set({
...projectValues,
publishedAt: parsed.isPublished publishedAt: parsed.isPublished
? shouldPublishNow ? shouldPublishNow
? new Date() ? new Date()
: existingProject?.publishedAt ?? new Date() : existingProject?.publishedAt ?? new Date()
: null, : null,
sortOrder: parsed.sortOrder, updatedAt: new Date(),
},
}) })
: await tx.portfolioProject.create({ .where(eq(portfolioProject.id, parsed.id))
data: { .returning()
categoryId: parsed.categoryId, : await tx
slug: parsed.slug, .insert(portfolioProject)
viewMode: parsed.viewMode, .values({
titleAr: parsed.titleAr, ...projectValues,
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, publishedAt: parsed.isPublished ? new Date() : null,
sortOrder: parsed.sortOrder, })
}, .returning();
});
await tx.portfolioSection.deleteMany({ await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
where: { await tx.delete(portfolioAsset).where(eq(portfolioAsset.projectId, currentProject.id));
projectId: currentProject.id,
},
});
await tx.portfolioAsset.deleteMany({
where: {
projectId: currentProject.id,
},
});
const createdSections = []; const createdSections = [];
for (const section of sectionRows) { for (const section of sectionRows) {
const createdSection = await tx.portfolioSection.create({ const [createdSection] = await tx
data: { .insert(portfolioSection)
.values({
projectId: currentProject.id, projectId: currentProject.id,
type: section.type, type: section.type,
titleAr: section.titleAr, titleAr: section.titleAr,
@@ -445,8 +437,8 @@ export async function saveProjectAction(formData: FormData) {
imagePath: section.imagePath || null, imagePath: section.imagePath || null,
linkUrl: section.linkUrl || null, linkUrl: section.linkUrl || null,
sortOrder: section.sortOrder, sortOrder: section.sortOrder,
}, })
}); .returning();
createdSections.push(createdSection); createdSections.push(createdSection);
} }
@@ -454,8 +446,9 @@ export async function saveProjectAction(formData: FormData) {
const createdAssets = []; const createdAssets = [];
for (const asset of assetRows) { for (const asset of assetRows) {
const createdAsset = await tx.portfolioAsset.create({ const [createdAsset] = await tx
data: { .insert(portfolioAsset)
.values({
projectId: currentProject.id, projectId: currentProject.id,
kind: asset.kind, kind: asset.kind,
filePath: asset.filePath, filePath: asset.filePath,
@@ -463,8 +456,8 @@ export async function saveProjectAction(formData: FormData) {
altEn: asset.altEn, altEn: asset.altEn,
altDe: asset.altDe, altDe: asset.altDe,
sortOrder: asset.sortOrder, sortOrder: asset.sortOrder,
}, })
}); .returning();
createdAssets.push(createdAsset); createdAssets.push(createdAsset);
} }
@@ -536,7 +529,7 @@ export async function saveProjectAction(formData: FormData) {
const message = const message =
error instanceof ZodError error instanceof ZodError
? parseZodError(error) ? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" : isUniqueViolation(error)
? "Projekt Slug muss eindeutig sein." ? "Projekt Slug muss eindeutig sein."
: error instanceof Error : error instanceof Error
? error.message ? error.message
@@ -544,20 +537,8 @@ export async function saveProjectAction(formData: FormData) {
await removeManagedPaths(uploadedPaths); await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) { if (createdMediaAssetIds.length > 0) {
await prisma.mediaUsage.deleteMany({ await db.delete(mediaUsage).where(inArray(mediaUsage.assetId, createdMediaAssetIds));
where: { await db.delete(mediaAsset).where(inArray(mediaAsset.id, createdMediaAssetIds));
assetId: {
in: createdMediaAssetIds,
},
},
});
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: createdMediaAssetIds,
},
},
});
} }
redirect(withFlash(redirectPath, { error: message })); redirect(withFlash(redirectPath, { error: message }));
} }
@@ -569,24 +550,17 @@ export async function deleteProjectAction(formData: FormData) {
const id = String(formData.get("id") ?? ""); const id = String(formData.get("id") ?? "");
try { try {
const project = await prisma.portfolioProject.findUnique({ const [project] = await db
where: { .select({ slug: portfolioProject.slug })
id, .from(portfolioProject)
}, .where(eq(portfolioProject.id, id))
select: { .limit(1);
slug: true,
},
});
if (!project) { if (!project) {
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." })); redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
} }
await prisma.portfolioProject.delete({ await db.delete(portfolioProject).where(eq(portfolioProject.id, id));
where: {
id,
},
});
await deleteEntityMediaUsages("portfolio-project", id); await deleteEntityMediaUsages("portfolio-project", id);
await revalidatePortfolioPages(); await revalidatePortfolioPages();
+5 -9
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { MediaUsageType } from "@prisma/client"; import { inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error"; import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,7 +30,9 @@ import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage"; import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation"; import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
async function ensureAdmin() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
@@ -60,13 +62,7 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) { async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
if (assetIds.length > 0) { if (assetIds.length > 0) {
await prisma.mediaAsset.deleteMany({ await db.delete(mediaAsset).where(inArray(mediaAsset.id, Array.from(new Set(assetIds))));
where: {
id: {
in: Array.from(new Set(assetIds)),
},
},
});
} }
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) { for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client"; import { MediaKind } from "@/lib/db/enums";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
+3 -2
View File
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -8,7 +9,7 @@ export async function GET() {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
try { try {
await prisma.$queryRaw`SELECT 1`; await db.execute(sql`SELECT 1`);
return NextResponse.json( return NextResponse.json(
{ {
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client"; import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react"; import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client"; import type { MediaKind } from "@/lib/db/enums";
import { FileType2, Grid2x2, ImageIcon, LayoutList, LoaderCircle, Trash2, Upload } from "lucide-react"; import { FileType2, Grid2x2, ImageIcon, LayoutList, LoaderCircle, Trash2, Upload } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client"; import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client"; import { MediaKind } from "@/lib/db/enums";
import { import {
Check, Check,
FileText, FileText,
+13
View File
@@ -0,0 +1,13 @@
import type { Config } from "drizzle-kit";
const rawConnectionString =
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
export default {
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: rawConnectionString.split("?")[0],
},
} satisfies Config;
+26 -22
View File
@@ -1,8 +1,10 @@
import { createHash, createHmac, timingSafeEqual } from "crypto"; import { createHash, createHmac, timingSafeEqual } from "crypto";
import { and, eq, like, lt } from "drizzle-orm";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "./prisma"; import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing"; import { getAdminAppPath } from "./admin-routing";
export const ADMIN_SESSION_COOKIE = "moh_admin_session"; export const ADMIN_SESSION_COOKIE = "moh_admin_session";
@@ -137,11 +139,11 @@ function getLockoutKey(ip: string): string {
async function cleanupExpiredLockouts(): Promise<void> { async function cleanupExpiredLockouts(): Promise<void> {
try { try {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000); const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await prisma.$executeRaw` await db
DELETE FROM "AppConfig" .delete(appConfig)
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`} .where(
AND "updatedAt" < ${cutoff} and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)),
`; );
} catch { } catch {
// Non-critical — ignore cleanup errors. // Non-critical — ignore cleanup errors.
} }
@@ -216,11 +218,12 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const key = getLockoutKey(ip); const key = getLockoutKey(ip);
const config = await prisma.appConfig.findUnique({ const rows = await db
where: { key }, .select({ value: appConfig.value })
select: { value: true }, .from(appConfig)
}); .where(eq(appConfig.key, key))
const state = parseFailState(config?.value); .limit(1);
const state = parseFailState(rows[0]?.value);
const now = Date.now(); const now = Date.now();
if (state.lockUntil > now) { if (state.lockUntil > now) {
@@ -244,23 +247,24 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts(); await cleanupExpiredLockouts();
const config = await prisma.appConfig.findUnique({ const rows = await db
where: { key }, .select({ value: appConfig.value })
select: { value: true }, .from(appConfig)
}); .where(eq(appConfig.key, key))
.limit(1);
const current = parseFailState(config?.value); const current = parseFailState(rows[0]?.value);
// If a previous lockout has expired, reset the counter. // If a previous lockout has expired, reset the counter.
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts; const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
const attempts = baseAttempts + 1; const attempts = baseAttempts + 1;
const locked = attempts >= MAX_FAILED_ATTEMPTS; const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0; const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
const value = JSON.stringify({ attempts, lockUntil });
await prisma.appConfig.upsert({ await db
where: { key }, .insert(appConfig)
update: { value: JSON.stringify({ attempts, lockUntil }) }, .values({ key, value })
create: { key, value: JSON.stringify({ attempts, lockUntil }) }, .onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
});
return { return {
locked, locked,
@@ -276,7 +280,7 @@ export async function resetAdminFailedAttempts(): Promise<void> {
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const key = getLockoutKey(ip); const key = getLockoutKey(ip);
await prisma.appConfig.deleteMany({ where: { key } }); await db.delete(appConfig).where(eq(appConfig.key, key));
} catch { } catch {
// Non-critical — ignore. // Non-critical — ignore.
} }
+44 -77
View File
@@ -1,4 +1,7 @@
import { prisma } from "./prisma"; import { and, eq, inArray } from "drizzle-orm";
import { db } from "./db";
import { appConfig, mediaUsage } from "./db/schema";
export const MAINTENANCE_MODE_KEY = "maintenance_mode"; export const MAINTENANCE_MODE_KEY = "maintenance_mode";
export { export {
SITE_NAME_KEY, SITE_NAME_KEY,
@@ -65,45 +68,44 @@ import {
type MarqueeSettings, type MarqueeSettings,
} from "./marquee-settings"; } from "./marquee-settings";
async function getAppConfigValue(key: string): Promise<string | undefined> {
const rows = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return rows[0]?.value;
}
async function upsertAppConfigValue(key: string, value: string): Promise<void> {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({
target: appConfig.key,
set: { value, updatedAt: new Date() },
});
}
export async function getMaintenanceMode(): Promise<boolean> { export async function getMaintenanceMode(): Promise<boolean> {
try { try {
const config = await prisma.appConfig.findUnique({ return (await getAppConfigValue(MAINTENANCE_MODE_KEY)) === "true";
where: { key: MAINTENANCE_MODE_KEY },
select: { value: true },
});
return config?.value === "true";
} catch { } catch {
return false; return false;
} }
} }
export async function setMaintenanceMode(enabled: boolean): Promise<void> { export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await prisma.appConfig.upsert({ await upsertAppConfigValue(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
where: { key: MAINTENANCE_MODE_KEY },
update: {
value: enabled ? "true" : "false",
},
create: {
key: MAINTENANCE_MODE_KEY,
value: enabled ? "true" : "false",
},
});
} }
export async function getSiteSettings(): Promise<SiteSettings> { export async function getSiteSettings(): Promise<SiteSettings> {
try { try {
const configs = await prisma.appConfig.findMany({ const configs = await db
where: { .select({ key: appConfig.key, value: appConfig.value })
key: { .from(appConfig)
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY], .where(inArray(appConfig.key, [SITE_SETTINGS_KEY, SITE_NAME_KEY]));
},
},
select: {
key: true,
value: true,
},
});
const configMap = new Map(configs.map((config) => [config.key, config.value])); const configMap = new Map(configs.map((config) => [config.key, config.value]));
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME; const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
@@ -115,26 +117,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
} }
export async function updateSiteSettings(settings: SiteSettings): Promise<void> { export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await prisma.appConfig.upsert({ await upsertAppConfigValue(SITE_SETTINGS_KEY, JSON.stringify(settings));
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
} }
export async function getMailSettings(): Promise<MailSettings> { export async function getMailSettings(): Promise<MailSettings> {
try { try {
const config = await prisma.appConfig.findUnique({ return parseMailSettingsValue(await getAppConfigValue(MAIL_SETTINGS_KEY));
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
} catch { } catch {
return buildDefaultMailSettings(); return buildDefaultMailSettings();
} }
@@ -147,26 +135,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
} }
export async function updateMailSettings(settings: MailSettings): Promise<void> { export async function updateMailSettings(settings: MailSettings): Promise<void> {
await prisma.appConfig.upsert({ await upsertAppConfigValue(MAIL_SETTINGS_KEY, JSON.stringify(settings));
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
} }
export async function getMarqueeSettings(): Promise<MarqueeSettings> { export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try { try {
const config = await prisma.appConfig.findUnique({ return parseMarqueeSettingsValue(await getAppConfigValue(MARQUEE_SETTINGS_KEY));
where: { key: MARQUEE_SETTINGS_KEY },
select: { value: true },
});
return parseMarqueeSettingsValue(config?.value);
} catch { } catch {
return buildDefaultMarqueeSettings(); return buildDefaultMarqueeSettings();
} }
@@ -175,30 +149,23 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> { export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings); const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await prisma.appConfig.upsert({ await upsertAppConfigValue(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
where: { key: MARQUEE_SETTINGS_KEY },
update: {
value: JSON.stringify(normalizedSettings),
},
create: {
key: MARQUEE_SETTINGS_KEY,
value: JSON.stringify(normalizedSettings),
},
});
} }
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> { export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try { try {
const usages = await prisma.mediaUsage.findMany({ const usages = await db.query.mediaUsage.findMany({
where: { where: and(
entityType: SITE_SETTINGS_ENTITY_TYPE, eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
entityId: SITE_SETTINGS_ENTITY_ID, eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
}, ),
select: { columns: {
fieldKey: true, fieldKey: true,
updatedAt: true, updatedAt: true,
},
with: {
asset: { asset: {
select: { columns: {
id: true, id: true,
url: true, url: true,
}, },
+56
View File
@@ -0,0 +1,56 @@
// Shared enum values + types. NO server/ORM imports here — this file is safe to
// import from client components (replaces the old `@prisma/client` enum imports).
//
// Defined as `const object + union type` (the same shape Prisma generated) rather
// than a TS `enum`, so bare string literals like "IMAGE" stay assignable and
// `z.nativeEnum(...)` keeps working.
export const PortfolioSectionType = {
RICH_TEXT: "RICH_TEXT",
GALLERY: "GALLERY",
STATS: "STATS",
DELIVERABLES: "DELIVERABLES",
LINK: "LINK",
} as const;
export type PortfolioSectionType = (typeof PortfolioSectionType)[keyof typeof PortfolioSectionType];
export const PortfolioAssetKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type PortfolioAssetKind = (typeof PortfolioAssetKind)[keyof typeof PortfolioAssetKind];
export const PortfolioProjectViewMode = {
GRID: "GRID",
STORY: "STORY",
CASE_STUDY: "CASE_STUDY",
} as const;
export type PortfolioProjectViewMode =
(typeof PortfolioProjectViewMode)[keyof typeof PortfolioProjectViewMode];
export const MediaSource = {
UPLOAD: "UPLOAD",
EXTERNAL: "EXTERNAL",
} as const;
export type MediaSource = (typeof MediaSource)[keyof typeof MediaSource];
export const MediaKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type MediaKind = (typeof MediaKind)[keyof typeof MediaKind];
export const MediaUsageType = {
PORTFOLIO_COVER: "PORTFOLIO_COVER",
PORTFOLIO_SECTION: "PORTFOLIO_SECTION",
PORTFOLIO_ASSET: "PORTFOLIO_ASSET",
GENERIC: "GENERIC",
} as const;
export type MediaUsageType = (typeof MediaUsageType)[keyof typeof MediaUsageType];
// Helper: enum-object -> tuple of its string values, for Drizzle pgEnum(...).
// Preserves the literal union (not widened to `string`) so pgEnum columns infer
// as the exact union type.
export function enumValues<T extends Record<string, string>>(e: T): [T[keyof T], ...T[keyof T][]] {
return Object.values(e) as [T[keyof T], ...T[keyof T][]];
}
+24
View File
@@ -0,0 +1,24 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const rawConnectionString =
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
// Prisma allowed a `?schema=public` query param that postgres.js does not
// understand — strip any unknown query string; `public` is the default schema.
const connectionString = rawConnectionString.split("?")[0];
const globalForDb = globalThis as unknown as {
pgClient: ReturnType<typeof postgres> | undefined;
};
const client = globalForDb.pgClient ?? postgres(connectionString, { max: 10 });
if (process.env.NODE_ENV !== "production") {
globalForDb.pgClient = client;
}
export const db = drizzle(client, { schema });
export { schema };
@@ -0,0 +1,125 @@
CREATE TYPE "public"."MediaKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."MediaSource" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
CREATE TYPE "public"."MediaUsageType" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
CREATE TYPE "public"."PortfolioAssetKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."PortfolioProjectViewMode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
CREATE TYPE "public"."PortfolioSectionType" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
CREATE TABLE "AppConfig" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "AppConfig_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "Category" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nameAr" text NOT NULL,
"nameEn" text NOT NULL,
"nameDe" text NOT NULL,
"descriptionAr" text NOT NULL,
"descriptionEn" text NOT NULL,
"descriptionDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"isActive" boolean DEFAULT true NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "Category_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "MediaAsset" (
"id" text PRIMARY KEY NOT NULL,
"source" "MediaSource" NOT NULL,
"kind" "MediaKind" NOT NULL,
"url" text NOT NULL,
"fileName" text NOT NULL,
"label" text NOT NULL,
"altText" text,
"mimeType" text,
"size" integer,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "MediaUsage" (
"id" text PRIMARY KEY NOT NULL,
"assetId" text NOT NULL,
"usageType" "MediaUsageType" NOT NULL,
"entityType" text NOT NULL,
"entityId" text NOT NULL,
"fieldKey" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioAsset" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"kind" "PortfolioAssetKind" NOT NULL,
"filePath" text NOT NULL,
"altAr" text NOT NULL,
"altEn" text NOT NULL,
"altDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioProject" (
"id" text PRIMARY KEY NOT NULL,
"categoryId" text NOT NULL,
"slug" text NOT NULL,
"viewMode" "PortfolioProjectViewMode" DEFAULT 'GRID' NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"summaryAr" text NOT NULL,
"summaryEn" text NOT NULL,
"summaryDe" text NOT NULL,
"clientName" text NOT NULL,
"projectYear" integer NOT NULL,
"serviceLabelAr" text NOT NULL,
"serviceLabelEn" text NOT NULL,
"serviceLabelDe" text NOT NULL,
"previewUrl" text,
"coverImagePath" text,
"isFeatured" boolean DEFAULT false NOT NULL,
"isPublished" boolean DEFAULT false NOT NULL,
"publishedAt" timestamp (3),
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "PortfolioProject_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "PortfolioSection" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"type" "PortfolioSectionType" NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"bodyAr" text NOT NULL,
"bodyEn" text NOT NULL,
"bodyDe" text NOT NULL,
"imagePath" text,
"linkUrl" text,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_MediaAsset_id_fk" FOREIGN KEY ("assetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioProject" ADD CONSTRAINT "PortfolioProject_categoryId_Category_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."Category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioSection" ADD CONSTRAINT "PortfolioSection_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset" USING btree ("kind","createdAt");--> statement-breakpoint
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage" USING btree ("usageType","entityType","entityId","fieldKey");--> statement-breakpoint
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage" USING btree ("assetId");--> statement-breakpoint
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage" USING btree ("entityType","entityId");--> statement-breakpoint
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset" USING btree ("projectId","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("categoryId","isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection" USING btree ("projectId","sortOrder");
+956
View File
@@ -0,0 +1,956 @@
{
"id": "d0f97c12-4a3b-4cf7-8f81-3d00571737f4",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.AppConfig": {
"name": "AppConfig",
"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
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"AppConfig_key_unique": {
"name": "AppConfig_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
},
"nameAr": {
"name": "nameAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nameEn": {
"name": "nameEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nameDe": {
"name": "nameDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptionAr": {
"name": "descriptionAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptionEn": {
"name": "descriptionEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptionDe": {
"name": "descriptionDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"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.MediaAsset": {
"name": "MediaAsset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"source": {
"name": "source",
"type": "MediaSource",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "MediaKind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fileName": {
"name": "fileName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altText": {
"name": "altText",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mimeType": {
"name": "mimeType",
"type": "text",
"primaryKey": false,
"notNull": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"MediaAsset_kind_createdAt_idx": {
"name": "MediaAsset_kind_createdAt_idx",
"columns": [
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "createdAt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.MediaUsage": {
"name": "MediaUsage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"assetId": {
"name": "assetId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"usageType": {
"name": "usageType",
"type": "MediaUsageType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"entityType": {
"name": "entityType",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entityId": {
"name": "entityId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fieldKey": {
"name": "fieldKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"MediaUsage_usageType_entityType_entityId_fieldKey_key": {
"name": "MediaUsage_usageType_entityType_entityId_fieldKey_key",
"columns": [
{
"expression": "usageType",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entityType",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entityId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "fieldKey",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"MediaUsage_assetId_idx": {
"name": "MediaUsage_assetId_idx",
"columns": [
{
"expression": "assetId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"MediaUsage_entityType_entityId_idx": {
"name": "MediaUsage_entityType_entityId_idx",
"columns": [
{
"expression": "entityType",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entityId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"MediaUsage_assetId_MediaAsset_id_fk": {
"name": "MediaUsage_assetId_MediaAsset_id_fk",
"tableFrom": "MediaUsage",
"tableTo": "MediaAsset",
"columnsFrom": [
"assetId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PortfolioAsset": {
"name": "PortfolioAsset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"projectId": {
"name": "projectId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "PortfolioAssetKind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"filePath": {
"name": "filePath",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altAr": {
"name": "altAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altEn": {
"name": "altEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altDe": {
"name": "altDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"PortfolioAsset_projectId_sortOrder_idx": {
"name": "PortfolioAsset_projectId_sortOrder_idx",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"PortfolioAsset_projectId_PortfolioProject_id_fk": {
"name": "PortfolioAsset_projectId_PortfolioProject_id_fk",
"tableFrom": "PortfolioAsset",
"tableTo": "PortfolioProject",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PortfolioProject": {
"name": "PortfolioProject",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"categoryId": {
"name": "categoryId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"viewMode": {
"name": "viewMode",
"type": "PortfolioProjectViewMode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'GRID'"
},
"titleAr": {
"name": "titleAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleEn": {
"name": "titleEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleDe": {
"name": "titleDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summaryAr": {
"name": "summaryAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summaryEn": {
"name": "summaryEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summaryDe": {
"name": "summaryDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"clientName": {
"name": "clientName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"projectYear": {
"name": "projectYear",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"serviceLabelAr": {
"name": "serviceLabelAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceLabelEn": {
"name": "serviceLabelEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceLabelDe": {
"name": "serviceLabelDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"previewUrl": {
"name": "previewUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"coverImagePath": {
"name": "coverImagePath",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isFeatured": {
"name": "isFeatured",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"isPublished": {
"name": "isPublished",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"publishedAt": {
"name": "publishedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": false
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"PortfolioProject_categoryId_isPublished_sortOrder_idx": {
"name": "PortfolioProject_categoryId_isPublished_sortOrder_idx",
"columns": [
{
"expression": "categoryId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "isPublished",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"PortfolioProject_isPublished_sortOrder_idx": {
"name": "PortfolioProject_isPublished_sortOrder_idx",
"columns": [
{
"expression": "isPublished",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"PortfolioProject_categoryId_Category_id_fk": {
"name": "PortfolioProject_categoryId_Category_id_fk",
"tableFrom": "PortfolioProject",
"tableTo": "Category",
"columnsFrom": [
"categoryId"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"PortfolioProject_slug_unique": {
"name": "PortfolioProject_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PortfolioSection": {
"name": "PortfolioSection",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"projectId": {
"name": "projectId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "PortfolioSectionType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"titleAr": {
"name": "titleAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleEn": {
"name": "titleEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleDe": {
"name": "titleDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"bodyAr": {
"name": "bodyAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"bodyEn": {
"name": "bodyEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"bodyDe": {
"name": "bodyDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imagePath": {
"name": "imagePath",
"type": "text",
"primaryKey": false,
"notNull": false
},
"linkUrl": {
"name": "linkUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"PortfolioSection_projectId_sortOrder_idx": {
"name": "PortfolioSection_projectId_sortOrder_idx",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"PortfolioSection_projectId_PortfolioProject_id_fk": {
"name": "PortfolioSection_projectId_PortfolioProject_id_fk",
"tableFrom": "PortfolioSection",
"tableTo": "PortfolioProject",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.MediaKind": {
"name": "MediaKind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.MediaSource": {
"name": "MediaSource",
"schema": "public",
"values": [
"UPLOAD",
"EXTERNAL"
]
},
"public.MediaUsageType": {
"name": "MediaUsageType",
"schema": "public",
"values": [
"PORTFOLIO_COVER",
"PORTFOLIO_SECTION",
"PORTFOLIO_ASSET",
"GENERIC"
]
},
"public.PortfolioAssetKind": {
"name": "PortfolioAssetKind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.PortfolioProjectViewMode": {
"name": "PortfolioProjectViewMode",
"schema": "public",
"values": [
"GRID",
"STORY",
"CASE_STUDY"
]
},
"public.PortfolioSectionType": {
"name": "PortfolioSectionType",
"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": 1786146995564,
"tag": "0000_absurd_rawhide_kid",
"breakpoints": true
}
]
}
+248
View File
@@ -0,0 +1,248 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
import {
MediaKind,
MediaSource,
MediaUsageType,
PortfolioAssetKind,
PortfolioProjectViewMode,
PortfolioSectionType,
enumValues,
} from "./enums";
// Postgres enum types — names match the ones Prisma created, so no DB migration
// is needed for the ORM swap.
export const portfolioSectionTypeEnum = pgEnum("PortfolioSectionType", enumValues(PortfolioSectionType));
export const portfolioAssetKindEnum = pgEnum("PortfolioAssetKind", enumValues(PortfolioAssetKind));
export const portfolioProjectViewModeEnum = pgEnum("PortfolioProjectViewMode", enumValues(PortfolioProjectViewMode));
export const mediaSourceEnum = pgEnum("MediaSource", enumValues(MediaSource));
export const mediaKindEnum = pgEnum("MediaKind", enumValues(MediaKind));
export const mediaUsageTypeEnum = pgEnum("MediaUsageType", enumValues(MediaUsageType));
// Shared column builders (Prisma parity): cuid ids, precision-3 timestamps.
const id = () => text("id").primaryKey().$defaultFn(() => createId());
const createdAt = () => timestamp("createdAt", { precision: 3, mode: "date" }).defaultNow().notNull();
const updatedAt = () =>
timestamp("updatedAt", { precision: 3, mode: "date" })
.defaultNow()
.notNull()
.$onUpdate(() => new Date());
export const appConfig = pgTable("AppConfig", {
id: id(),
key: text("key").notNull().unique(),
value: text("value").notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
export const category = pgTable(
"Category",
{
id: id(),
slug: text("slug").notNull().unique(),
nameAr: text("nameAr").notNull(),
nameEn: text("nameEn").notNull(),
nameDe: text("nameDe").notNull(),
descriptionAr: text("descriptionAr").notNull(),
descriptionEn: text("descriptionEn").notNull(),
descriptionDe: text("descriptionDe").notNull(),
sortOrder: integer("sortOrder").notNull().default(0),
isActive: boolean("isActive").notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
);
export const portfolioProject = pgTable(
"PortfolioProject",
{
id: id(),
categoryId: text("categoryId")
.notNull()
.references(() => category.id, { onDelete: "restrict" }),
slug: text("slug").notNull().unique(),
viewMode: portfolioProjectViewModeEnum("viewMode").notNull().default("GRID"),
titleAr: text("titleAr").notNull(),
titleEn: text("titleEn").notNull(),
titleDe: text("titleDe").notNull(),
summaryAr: text("summaryAr").notNull(),
summaryEn: text("summaryEn").notNull(),
summaryDe: text("summaryDe").notNull(),
clientName: text("clientName").notNull(),
projectYear: integer("projectYear").notNull(),
serviceLabelAr: text("serviceLabelAr").notNull(),
serviceLabelEn: text("serviceLabelEn").notNull(),
serviceLabelDe: text("serviceLabelDe").notNull(),
previewUrl: text("previewUrl"),
coverImagePath: text("coverImagePath"),
isFeatured: boolean("isFeatured").notNull().default(false),
isPublished: boolean("isPublished").notNull().default(false),
publishedAt: timestamp("publishedAt", { precision: 3, mode: "date" }),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [
index("PortfolioProject_categoryId_isPublished_sortOrder_idx").on(
table.categoryId,
table.isPublished,
table.sortOrder,
),
index("PortfolioProject_isPublished_sortOrder_idx").on(table.isPublished, table.sortOrder),
],
);
export const portfolioSection = pgTable(
"PortfolioSection",
{
id: id(),
projectId: text("projectId")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
type: portfolioSectionTypeEnum("type").notNull(),
titleAr: text("titleAr").notNull(),
titleEn: text("titleEn").notNull(),
titleDe: text("titleDe").notNull(),
bodyAr: text("bodyAr").notNull(),
bodyEn: text("bodyEn").notNull(),
bodyDe: text("bodyDe").notNull(),
imagePath: text("imagePath"),
linkUrl: text("linkUrl"),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [index("PortfolioSection_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
);
export const portfolioAsset = pgTable(
"PortfolioAsset",
{
id: id(),
projectId: text("projectId")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
kind: portfolioAssetKindEnum("kind").notNull(),
filePath: text("filePath").notNull(),
altAr: text("altAr").notNull(),
altEn: text("altEn").notNull(),
altDe: text("altDe").notNull(),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [index("PortfolioAsset_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
);
export const mediaAsset = pgTable(
"MediaAsset",
{
id: id(),
source: mediaSourceEnum("source").notNull(),
kind: mediaKindEnum("kind").notNull(),
url: text("url").notNull(),
fileName: text("fileName").notNull(),
label: text("label").notNull(),
altText: text("altText"),
mimeType: text("mimeType"),
size: integer("size"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [index("MediaAsset_kind_createdAt_idx").on(table.kind, table.createdAt)],
);
export const mediaUsage = pgTable(
"MediaUsage",
{
id: id(),
assetId: text("assetId")
.notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }),
usageType: mediaUsageTypeEnum("usageType").notNull(),
entityType: text("entityType").notNull(),
entityId: text("entityId").notNull(),
fieldKey: text("fieldKey").notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [
uniqueIndex("MediaUsage_usageType_entityType_entityId_fieldKey_key").on(
table.usageType,
table.entityType,
table.entityId,
table.fieldKey,
),
index("MediaUsage_assetId_idx").on(table.assetId),
index("MediaUsage_entityType_entityId_idx").on(table.entityType, table.entityId),
],
);
// Relations (enable db.query.* `with:` includes).
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],
}),
}));
// Inferred row types (replace the old `@prisma/client` model type imports).
export type AppConfig = typeof appConfig.$inferSelect;
export type Category = typeof category.$inferSelect;
export type PortfolioProject = typeof portfolioProject.$inferSelect;
export type PortfolioSection = typeof portfolioSection.$inferSelect;
export type PortfolioAsset = typeof portfolioAsset.$inferSelect;
export type MediaAsset = typeof mediaAsset.$inferSelect;
export type MediaUsage = typeof mediaUsage.$inferSelect;
export {
MediaKind,
MediaSource,
MediaUsageType,
PortfolioAssetKind,
PortfolioProjectViewMode,
PortfolioSectionType,
} from "./enums";
+565
View File
@@ -0,0 +1,565 @@
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import {
appConfig,
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "./schema";
const connectionString = (
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
).split("?")[0];
const client = postgres(connectionString, { max: 1 });
const db = drizzle(client, { schema });
type MediaAssetInput = {
source: "UPLOAD" | "EXTERNAL";
kind: "IMAGE" | "DOCUMENT";
url: string;
fileName: string;
label: string;
altText: string | null;
mimeType: string | null;
size: number | null;
};
async function upsertMediaAsset(input: MediaAssetInput) {
const [existing] = await db
.select()
.from(mediaAsset)
.where(and(eq(mediaAsset.label, input.label), eq(mediaAsset.url, input.url)))
.limit(1);
if (existing) {
const [updated] = await db
.update(mediaAsset)
.set({ ...input, updatedAt: new Date() })
.where(eq(mediaAsset.id, existing.id))
.returning();
return updated;
}
const [created] = await db.insert(mediaAsset).values(input).returning();
return created;
}
async function upsertAppConfig(key: string, value: string) {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
}
async function upsertCategory(values: typeof category.$inferInsert) {
const [row] = await db
.insert(category)
.values(values)
.onConflictDoUpdate({
target: category.slug,
set: {
nameAr: values.nameAr,
nameEn: values.nameEn,
nameDe: values.nameDe,
descriptionAr: values.descriptionAr,
descriptionEn: values.descriptionEn,
descriptionDe: values.descriptionDe,
sortOrder: values.sortOrder,
isActive: values.isActive,
updatedAt: new Date(),
},
})
.returning();
return row;
}
async function syncProjectContent(
projectId: string,
sections: Array<Omit<typeof portfolioSection.$inferInsert, "projectId">>,
assets: Array<Omit<typeof portfolioAsset.$inferInsert, "projectId">>,
) {
await db.delete(portfolioSection).where(eq(portfolioSection.projectId, projectId));
await db.delete(portfolioAsset).where(eq(portfolioAsset.projectId, projectId));
const createdSections = [];
for (const section of sections) {
const [row] = await db
.insert(portfolioSection)
.values({ projectId, ...section })
.returning();
createdSections.push(row);
}
const createdAssets = [];
for (const asset of assets) {
const [row] = await db
.insert(portfolioAsset)
.values({ projectId, ...asset })
.returning();
createdAssets.push(row);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(
projectId: string,
mediaMap: {
coverAssetId: string | null | undefined;
sectionUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
assetUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
},
) {
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
const usages: (typeof mediaUsage.$inferInsert)[] = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
if (!sectionUsage.assetId) continue;
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
if (!assetUsage.assetId) continue;
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await db.insert(mediaUsage).values(usages);
}
}
const SITE_SETTINGS_VALUE = JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
});
async function main() {
await db
.delete(mediaUsage)
.where(eq(mediaUsage.entityType, "portfolio-project"));
await db.delete(portfolioSection);
await db.delete(portfolioAsset);
await db.delete(portfolioProject);
await db.delete(category);
await upsertAppConfig("siteName", "moh-sass");
await upsertAppConfig("site_settings", SITE_SETTINGS_VALUE);
const brandCategory = await upsertCategory({
slug: "branding",
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
});
const webCategory = await upsertCategory({
slug: "web-experiences",
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
});
const commerceCategory = await upsertCategory({
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID" as const,
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY" as const,
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK" as const,
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY" as const,
titleAr: "إعادة تصميم الهوية",
titleEn: "Brand Redesign",
titleDe: "Brand Redesign",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
clientName: "Studio Client",
projectYear: 2025,
serviceLabelAr: "هوية بصرية",
serviceLabelEn: "Brand Identity",
serviceLabelDe: "Brand Identity",
previewUrl: "https://example.com/preview/brand-redesign",
coverImagePath: caseStudyCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
type: "RICH_TEXT" as const,
titleAr: "التحدي",
titleEn: "Challenge",
titleDe: "Herausforderung",
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "RICH_TEXT" as const,
titleAr: "الحل",
titleEn: "Solution",
titleDe: "Loesung",
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
bodyEn: "A clearer visual system with scalable usage rules was created.",
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY" as const,
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
assets: [
{
kind: "IMAGE" as const,
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
for (const projectConfig of projects) {
const { sections, assets, coverAssetId, ...projectValues } = projectConfig;
const [project] = await db
.insert(portfolioProject)
.values(projectValues)
.onConflictDoUpdate({
target: portfolioProject.slug,
set: {
categoryId: projectValues.categoryId,
viewMode: projectValues.viewMode,
titleAr: projectValues.titleAr,
titleEn: projectValues.titleEn,
titleDe: projectValues.titleDe,
summaryAr: projectValues.summaryAr,
summaryEn: projectValues.summaryEn,
summaryDe: projectValues.summaryDe,
clientName: projectValues.clientName,
projectYear: projectValues.projectYear,
serviceLabelAr: projectValues.serviceLabelAr,
serviceLabelEn: projectValues.serviceLabelEn,
serviceLabelDe: projectValues.serviceLabelDe,
previewUrl: projectValues.previewUrl,
coverImagePath: projectValues.coverImagePath,
isFeatured: projectValues.isFeatured,
isPublished: projectValues.isPublished,
publishedAt: projectValues.publishedAt,
sortOrder: projectValues.sortOrder,
updatedAt: new Date(),
},
})
.returning();
const created = await syncProjectContent(
project.id,
sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})),
assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})),
);
await syncProjectMediaUsages(project.id, {
coverAssetId,
sectionUsages: created.createdSections.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: sections[index]?.mediaAssetId,
})),
assetUsages: created.createdAssets.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: assets[index]?.mediaAssetId,
})),
});
}
}
main()
.then(async () => {
await client.end();
})
.catch(async (error) => {
console.error("Seed failed:", error);
await client.end();
process.exit(1);
});
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "path"; import path from "path";
import { MediaKind, MediaSource } from "@prisma/client"; import { MediaKind, MediaSource } from "@/lib/db/enums";
import { createMediaAsset, getMediaAssetById } from "@/lib/media"; import { createMediaAsset, getMediaAssetById } from "@/lib/media";
import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage"; import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage";
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client"; import { MediaKind } from "@/lib/db/enums";
import { z } from "zod"; import { z } from "zod";
const mediaModeSchema = z.enum(["library", "external", "upload"]); const mediaModeSchema = z.enum(["library", "external", "upload"]);
+61 -62
View File
@@ -1,12 +1,9 @@
import type { import { and, count, desc, eq } from "drizzle-orm";
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { db } from "@/lib/db";
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
import type { MediaAsset, MediaUsage } from "@/lib/db/schema";
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
export type MediaAssetView = Pick< export type MediaAssetView = Pick<
MediaAsset, MediaAsset,
@@ -52,38 +49,38 @@ function mapMediaAsset(
} }
export async function getAdminMediaAssets() { export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({ const assets = await db.query.mediaAsset.findMany({
include: { with: {
usages: { usages: {
orderBy: [{ createdAt: "desc" }], orderBy: (usage, { desc: descOrder }) => [descOrder(usage.createdAt)],
}, },
}, },
orderBy: [{ createdAt: "desc" }], orderBy: (asset, { desc: descOrder }) => [descOrder(asset.createdAt)],
}); });
return assets.map(mapMediaAsset); return assets.map(mapMediaAsset);
} }
export async function getMediaOptions(filters?: { kind?: MediaKind }) { export async function getMediaOptions(filters?: { kind?: MediaKind }) {
const assets = await prisma.mediaAsset.findMany({ const assets = await db
where: filters?.kind ? { kind: filters.kind } : undefined, .select({
orderBy: [{ createdAt: "desc" }], id: mediaAsset.id,
select: { kind: mediaAsset.kind,
id: true, url: mediaAsset.url,
kind: true, label: mediaAsset.label,
url: true, source: mediaAsset.source,
label: true, })
source: true, .from(mediaAsset)
}, .where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
}); .orderBy(desc(mediaAsset.createdAt));
return assets; return assets;
} }
export async function getMediaAssetById(id: string) { export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({ const asset = await db.query.mediaAsset.findFirst({
where: { id }, where: eq(mediaAsset.id, id),
include: { with: {
usages: true, usages: true,
}, },
}); });
@@ -101,8 +98,9 @@ export async function createMediaAsset(input: {
mimeType?: string | null; mimeType?: string | null;
size?: number | null; size?: number | null;
}) { }) {
return prisma.mediaAsset.create({ const [asset] = await db
data: { .insert(mediaAsset)
.values({
source: input.source, source: input.source,
kind: input.kind, kind: input.kind,
url: input.url, url: input.url,
@@ -111,8 +109,10 @@ export async function createMediaAsset(input: {
altText: input.altText ?? null, altText: input.altText ?? null,
mimeType: input.mimeType ?? null, mimeType: input.mimeType ?? null,
size: input.size ?? null, size: input.size ?? null,
}, })
}); .returning();
return asset;
} }
export async function replaceEntityMediaUsages(input: { export async function replaceEntityMediaUsages(input: {
@@ -124,51 +124,49 @@ export async function replaceEntityMediaUsages(input: {
fieldKey: string; fieldKey: string;
}>; }>;
}) { }) {
await prisma.$transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.mediaUsage.deleteMany({ await tx
where: { .delete(mediaUsage)
entityType: input.entityType, .where(
entityId: input.entityId, and(
}, eq(mediaUsage.entityType, input.entityType),
}); eq(mediaUsage.entityId, input.entityId),
),
);
if (input.usages.length === 0) { if (input.usages.length === 0) {
return; return;
} }
await tx.mediaUsage.createMany({ await tx.insert(mediaUsage).values(
data: input.usages.map((usage) => ({ input.usages.map((usage) => ({
assetId: usage.assetId, assetId: usage.assetId,
usageType: usage.usageType, usageType: usage.usageType,
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
fieldKey: usage.fieldKey, fieldKey: usage.fieldKey,
})), })),
}); );
}); });
} }
export async function deleteEntityMediaUsages(entityType: string, entityId: string) { export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({ await db
where: { .delete(mediaUsage)
entityType, .where(and(eq(mediaUsage.entityType, entityType), eq(mediaUsage.entityId, entityId)));
entityId,
},
});
} }
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> { export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({ const usages = await db
where: { .select({
entityType: "portfolio-project", assetId: mediaUsage.assetId,
entityId: projectId, usageType: mediaUsage.usageType,
}, fieldKey: mediaUsage.fieldKey,
select: { })
assetId: true, .from(mediaUsage)
usageType: true, .where(
fieldKey: true, and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)),
}, );
});
return usages.reduce<PortfolioMediaBindings>( return usages.reduce<PortfolioMediaBindings>(
(result, usage) => { (result, usage) => {
@@ -195,9 +193,10 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
} }
export async function countMediaUsageReferences(assetId: string) { export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({ const [row] = await db
where: { .select({ value: count() })
assetId, .from(mediaUsage)
}, .where(eq(mediaUsage.assetId, assetId));
});
return row?.value ?? 0;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client"; import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets"; export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PortfolioSectionType } from "@prisma/client"; import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod"; import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation"; import { mediaFieldInputSchema } from "./media-validation";
+65 -73
View File
@@ -1,16 +1,13 @@
import type { import { and, asc, desc, eq } from "drizzle-orm";
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
import { cache } from "react"; import { cache } from "react";
import { db } from "@/lib/db";
import { category, portfolioProject } from "@/lib/db/schema";
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@/lib/db/schema";
import type { PortfolioProjectViewMode } from "@/lib/db/enums";
import { getPortfolioMediaBindings } from "@/lib/media"; import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale"; import type { AppLocale } from "@/lib/locale";
import { prisma } from "@/lib/prisma";
type CategoryRecord = Pick< type CategoryRecord = Pick<
Category, Category,
@@ -240,132 +237,127 @@ export function getLocalizedValue(
} }
export async function getAdminPortfolioCategories() { export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({ const categories = await db.query.category.findMany({
include: { with: {
_count: { projects: {
select: { columns: { id: true },
projects: true,
}, },
}, },
}, orderBy: [asc(category.sortOrder), asc(category.createdAt)],
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}); });
return categories.map((category) => ({ return categories.map((record) => ({
...mapCategory(category), ...mapCategory(record),
projectCount: category._count.projects, projectCount: record.projects.length,
})); }));
} }
export async function getActivePortfolioCategories() { export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({ const categories = await db.query.category.findMany({
where: { where: eq(category.isActive, true),
isActive: true, orderBy: [asc(category.sortOrder), asc(category.createdAt)],
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
}); });
return categories.map(mapCategory); return categories.map(mapCategory);
} }
export async function getActivePortfolioCategoryBySlug(slug: string) { export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await prisma.category.findFirst({ const record = await db.query.category.findFirst({
where: { where: and(eq(category.slug, slug), eq(category.isActive, true)),
slug,
isActive: true,
},
}); });
return category ? mapCategory(category) : null; return record ? mapCategory(record) : null;
} }
export async function getAdminPortfolioProjects(filters?: { export async function getAdminPortfolioProjects(filters?: {
categoryId?: string; categoryId?: string;
status?: "all" | "draft" | "published"; status?: "all" | "draft" | "published";
}) { }) {
const projects = await prisma.portfolioProject.findMany({ const conditions = [
where: { filters?.categoryId ? eq(portfolioProject.categoryId, filters.categoryId) : undefined,
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}), filters?.status === "draft"
...(filters?.status === "draft" ? eq(portfolioProject.isPublished, false)
? { isPublished: false }
: filters?.status === "published" : filters?.status === "published"
? { isPublished: true } ? eq(portfolioProject.isPublished, true)
: {}), : undefined,
}, ].filter(Boolean);
include: {
const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined,
with: {
category: true, category: true,
sections: { sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
}, },
assets: { assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
}, },
}, },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
}); });
return projects.map((project) => mapProject(project)); return projects.map((project) => mapProject(project));
} }
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) { export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({ const projects = await db.query.portfolioProject.findMany({
where: { where: eq(portfolioProject.isPublished, true),
isPublished: true, with: {
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
category: true, category: true,
sections: { sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
}, },
assets: { assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
}, },
}, },
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }], orderBy: [
asc(portfolioProject.sortOrder),
desc(portfolioProject.publishedAt),
desc(portfolioProject.createdAt),
],
}); });
return projects.map((project) => mapProject(project)); return projects
.filter(
(project) =>
project.category.isActive &&
(!filters?.categorySlug || project.category.slug === filters.categorySlug),
)
.map((project) => mapProject(project));
} }
export const getPublishedPortfolioProjectBySlug = cache(async function (slug: string) { export const getPublishedPortfolioProjectBySlug = cache(async function (slug: string) {
const project = await prisma.portfolioProject.findFirst({ const project = await db.query.portfolioProject.findFirst({
where: { where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
slug, with: {
isPublished: true,
category: {
isActive: true,
},
},
include: {
category: true, category: true,
sections: { sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
}, },
assets: { assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
}, },
}, },
}); });
return project ? mapProject(project) : null; if (!project || !project.category.isActive) {
return null;
}
return mapProject(project);
}); });
export async function getAdminPortfolioProjectById(id: string) { export async function getAdminPortfolioProjectById(id: string) {
const project = await prisma.portfolioProject.findUnique({ const project = await db.query.portfolioProject.findFirst({
where: { where: eq(portfolioProject.id, id),
id, with: {
},
include: {
category: true, category: true,
sections: { sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
}, },
assets: { assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.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;
}
+1613 -1102
View File
File diff suppressed because it is too large Load Diff
+10 -12
View File
@@ -8,17 +8,14 @@
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"prisma:generate": "prisma generate", "db:generate": "drizzle-kit generate",
"db:migrate": "prisma migrate deploy", "db:migrate": "drizzle-kit migrate",
"db:migrate:dev": "prisma migrate dev", "db:push": "drizzle-kit push",
"db:seed": "prisma db seed" "db:studio": "drizzle-kit studio",
}, "db:seed": "tsx lib/db/seed.ts"
"prisma": {
"seed": "node prisma/seed.js"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2", "@paralleldrive/cuid2": "^2.2.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -28,6 +25,7 @@
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.5",
"framer-motion": "^12.35.0", "framer-motion": "^12.35.0",
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
@@ -35,7 +33,7 @@
"next-intl": "^4.8.3", "next-intl": "^4.8.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.20.0", "postgres": "^3.4.5",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
@@ -44,15 +42,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.4",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"postcss": "^8", "postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "^5", "typescript": "^5",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
-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])
}
-605
View File
@@ -1,605 +0,0 @@
const { PrismaPg } = require("@prisma/adapter-pg");
const { PrismaClient } = require("@prisma/client");
const { Pool } = require("pg");
const connectionString =
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const pool = new Pool({ connectionString });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
async function upsertMediaAsset(input) {
const existing = await prisma.mediaAsset.findFirst({
where: {
label: input.label,
url: input.url,
},
});
if (existing) {
return prisma.mediaAsset.update({
where: { id: existing.id },
data: input,
});
}
return prisma.mediaAsset.create({
data: input,
});
}
async function syncProjectContent(projectId, sections, assets) {
await prisma.portfolioSection.deleteMany({
where: { projectId },
});
await prisma.portfolioAsset.deleteMany({
where: { projectId },
});
const createdSections = [];
for (const section of sections) {
const createdSection = await prisma.portfolioSection.create({
data: {
projectId,
...section,
},
});
createdSections.push(createdSection);
}
const createdAssets = [];
for (const asset of assets) {
const createdAsset = await prisma.portfolioAsset.create({
data: {
projectId,
...asset,
},
});
createdAssets.push(createdAsset);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(projectId, mediaMap) {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
});
const usages = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await prisma.mediaUsage.createMany({ data: usages });
}
}
async function main() {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
},
});
await prisma.portfolioSection.deleteMany();
await prisma.portfolioAsset.deleteMany();
await prisma.portfolioProject.deleteMany();
await prisma.category.deleteMany();
await prisma.appConfig.upsert({
where: { key: "siteName" },
update: { value: "moh-sass" },
create: { key: "siteName", value: "moh-sass" },
});
await prisma.appConfig.upsert({
where: { key: "site_settings" },
update: {
value: JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
}),
},
create: {
key: "site_settings",
value: JSON.stringify({
titleTemplate: "{pageTitle} | moh-sass",
locales: {
ar: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
en: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
de: {
siteName: "moh-sass",
titleTemplate: "{pageTitle} | {siteName}",
siteDescription: "Multilingual Next.js base project",
subhead: "",
},
},
}),
},
});
const brandCategory = await prisma.category.upsert({
where: { slug: "branding" },
update: {
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
},
create: {
slug: "branding",
nameAr: "الهوية البصرية",
nameEn: "Branding",
nameDe: "Branding",
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
descriptionEn: "Brand identity, logo, and design system work.",
descriptionDe: "Branding, Logos und visuelle Systeme.",
sortOrder: 1,
isActive: true,
},
});
const webCategory = await prisma.category.upsert({
where: { slug: "web-experiences" },
update: {
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
},
create: {
slug: "web-experiences",
nameAr: "تجارب الويب",
nameEn: "Web Experiences",
nameDe: "Web Experiences",
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
descriptionEn: "Websites, landing pages, and digital experiences.",
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
sortOrder: 2,
isActive: true,
},
});
const commerceCategory = await prisma.category.upsert({
where: { slug: "commerce" },
update: {
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
create: {
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID",
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY",
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK",
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE",
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY",
titleAr: "إعادة تصميم الهوية",
titleEn: "Brand Redesign",
titleDe: "Brand Redesign",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
clientName: "Studio Client",
projectYear: 2025,
serviceLabelAr: "هوية بصرية",
serviceLabelEn: "Brand Identity",
serviceLabelDe: "Brand Identity",
previewUrl: "https://example.com/preview/brand-redesign",
coverImagePath: caseStudyCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "التحدي",
titleEn: "Challenge",
titleDe: "Herausforderung",
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "RICH_TEXT",
titleAr: "الحل",
titleEn: "Solution",
titleDe: "Loesung",
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
bodyEn: "A clearer visual system with scalable usage rules was created.",
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
for (const projectConfig of projects) {
const project = await prisma.portfolioProject.upsert({
where: { slug: projectConfig.slug },
update: {
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
create: {
slug: projectConfig.slug,
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
});
const created = await syncProjectContent(project.id, projectConfig.sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})), projectConfig.assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})));
await syncProjectMediaUsages(project.id, {
coverAssetId: projectConfig.coverAssetId,
sectionUsages: created.createdSections
.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: projectConfig.sections[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
assetUsages: created.createdAssets
.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: projectConfig.assets[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
});
}
}
main()
.catch((error) => {
console.error("Seed failed:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});