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
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
@@ -17,8 +17,8 @@ COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/lib/db ./lib/db
COPY --from=builder /app/next.config.mjs ./next.config.mjs
EXPOSE 3000
+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
MIGRATION_NAME ?= init
.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
start:
docker compose up -d --build
@@ -38,19 +36,19 @@ db-shell:
docker compose exec db psql -U postgres -d moh_sass
db-init:
docker compose exec app sh -lc "npx prisma generate && npx prisma migrate deploy && npx prisma db seed"
docker compose exec app sh -lc "npm run db:migrate && npm run db:seed"
db-generate:
docker compose exec app npm run db:generate
db-migrate:
docker compose exec app npx prisma migrate deploy
docker compose exec app npm run db:migrate
db-push:
docker compose exec app npm run db:push
db-seed:
docker compose exec app npx prisma db seed
prisma-generate:
docker compose exec app npx prisma generate
prisma-migrate:
docker compose exec app npx prisma migrate dev --name $(MIGRATION_NAME)
docker compose exec app npm run db:seed
health:
curl -sS https://mohfarawati.de/api/health
@@ -68,9 +66,9 @@ help:
@echo " make clean-orphans Remove orphaned old containers"
@echo " make app-shell Open shell in app container"
@echo " make db-shell Open PostgreSQL shell"
@echo " make db-init Generate client, apply migrations, run seed"
@echo " make db-migrate Apply prisma migrations"
@echo " make db-init Apply migrations and run seed (first run)"
@echo " make db-generate Generate a Drizzle migration from schema changes"
@echo " make db-migrate Apply pending Drizzle migrations"
@echo " make db-push Push schema directly (dev convenience)"
@echo " make db-seed Seed database data"
@echo " make prisma-generate Run prisma generate"
@echo " make prisma-migrate Create/apply dev migration"
@echo " make health Check app health endpoint via public domain"
+5 -7
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaKind } from "@prisma/client";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
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 { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
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() {
if (!(await isAdminAuthenticated())) {
@@ -71,11 +73,7 @@ export async function deleteMediaAssetAction(formData: FormData) {
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
}
await prisma.mediaAsset.delete({
where: {
id: asset.id,
},
});
await db.delete(mediaAsset).where(eq(mediaAsset.id, asset.id));
if (isManagedMediaFilePath(asset.url)) {
await deleteMediaAssetAndFile({
+105 -131
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaUsageType, Prisma } from "@prisma/client";
import { and, count, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -15,7 +15,16 @@ import { resolveMediaSelection } from "@/lib/media-service";
import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import {
category,
mediaAsset,
mediaUsage,
portfolioAsset,
portfolioProject,
portfolioSection,
} from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
import { isCheckedFormValue } from "@/lib/form-data";
import { getSiteSettings } from "@/lib/app-config";
import {
@@ -81,6 +90,16 @@ function parseZodError(error: ZodError) {
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() {
revalidatePath(toInternalAdminPath("/"));
revalidatePath(toInternalAdminPath("/media"));
@@ -120,17 +139,15 @@ export async function upsertCategoryAction(formData: FormData) {
isActive: normalizeCheckboxValue(formData, "isActive"),
});
if (parsed.id) {
await prisma.category.update({
where: {
id: parsed.id,
},
data: parsed,
});
const { id: categoryId, ...categoryValues } = parsed;
if (categoryId) {
await db
.update(category)
.set({ ...categoryValues, updatedAt: new Date() })
.where(eq(category.id, categoryId));
} else {
await prisma.category.create({
data: parsed,
});
await db.insert(category).values(categoryValues);
}
await revalidatePortfolioPages();
@@ -143,7 +160,7 @@ export async function upsertCategoryAction(formData: FormData) {
const message =
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
: isUniqueViolation(error)
? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden.";
@@ -158,21 +175,16 @@ export async function deleteCategoryAction(formData: FormData) {
const id = String(formData.get("id") ?? "");
try {
const projectCount = await prisma.portfolioProject.count({
where: {
categoryId: id,
},
});
const [projectCountRow] = await db
.select({ value: count() })
.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." }));
}
await prisma.category.delete({
where: {
id,
},
});
await db.delete(category).where(eq(category.id, id));
await revalidatePortfolioPages();
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
@@ -241,15 +253,16 @@ export async function saveProjectAction(formData: FormData) {
});
const existingProject = parsed.id
? await prisma.portfolioProject.findUnique({
where: {
id: parsed.id,
},
select: {
isPublished: true,
publishedAt: true,
},
})
? (
await db
.select({
isPublished: portfolioProject.isPublished,
publishedAt: portfolioProject.publishedAt,
})
.from(portfolioProject)
.where(eq(portfolioProject.id, parsed.id))
.limit(1)
)[0] ?? null
: null;
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
@@ -359,81 +372,60 @@ export async function saveProjectAction(formData: FormData) {
});
}
const projectResult = await prisma.$transaction(async (tx) => {
const currentProject = parsed.id
? await tx.portfolioProject.update({
where: {
id: parsed.id,
},
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
const projectResult = await db.transaction(async (tx) => {
const projectValues = {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
sortOrder: parsed.sortOrder,
};
const [currentProject] = parsed.id
? await tx
.update(portfolioProject)
.set({
...projectValues,
publishedAt: parsed.isPublished
? shouldPublishNow
? new Date()
: existingProject?.publishedAt ?? new Date()
: null,
sortOrder: parsed.sortOrder,
},
})
: await tx.portfolioProject.create({
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
summaryAr: parsed.summaryAr,
summaryEn: parsed.summaryEn,
summaryDe: parsed.summaryDe,
clientName: parsed.clientName,
projectYear: parsed.projectYear,
serviceLabelAr: parsed.serviceLabelAr,
serviceLabelEn: parsed.serviceLabelEn,
serviceLabelDe: parsed.serviceLabelDe,
previewUrl: parsed.previewUrl || null,
coverImagePath: coverSelection.url || null,
isFeatured: parsed.isFeatured,
isPublished: parsed.isPublished,
updatedAt: new Date(),
})
.where(eq(portfolioProject.id, parsed.id))
.returning()
: await tx
.insert(portfolioProject)
.values({
...projectValues,
publishedAt: parsed.isPublished ? new Date() : null,
sortOrder: parsed.sortOrder,
},
});
})
.returning();
await tx.portfolioSection.deleteMany({
where: {
projectId: currentProject.id,
},
});
await tx.portfolioAsset.deleteMany({
where: {
projectId: currentProject.id,
},
});
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
await tx.delete(portfolioAsset).where(eq(portfolioAsset.projectId, currentProject.id));
const createdSections = [];
for (const section of sectionRows) {
const createdSection = await tx.portfolioSection.create({
data: {
const [createdSection] = await tx
.insert(portfolioSection)
.values({
projectId: currentProject.id,
type: section.type,
titleAr: section.titleAr,
@@ -445,8 +437,8 @@ export async function saveProjectAction(formData: FormData) {
imagePath: section.imagePath || null,
linkUrl: section.linkUrl || null,
sortOrder: section.sortOrder,
},
});
})
.returning();
createdSections.push(createdSection);
}
@@ -454,8 +446,9 @@ export async function saveProjectAction(formData: FormData) {
const createdAssets = [];
for (const asset of assetRows) {
const createdAsset = await tx.portfolioAsset.create({
data: {
const [createdAsset] = await tx
.insert(portfolioAsset)
.values({
projectId: currentProject.id,
kind: asset.kind,
filePath: asset.filePath,
@@ -463,8 +456,8 @@ export async function saveProjectAction(formData: FormData) {
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
},
});
})
.returning();
createdAssets.push(createdAsset);
}
@@ -536,7 +529,7 @@ export async function saveProjectAction(formData: FormData) {
const message =
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
: isUniqueViolation(error)
? "Projekt Slug muss eindeutig sein."
: error instanceof Error
? error.message
@@ -544,20 +537,8 @@ export async function saveProjectAction(formData: FormData) {
await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) {
await prisma.mediaUsage.deleteMany({
where: {
assetId: {
in: createdMediaAssetIds,
},
},
});
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: createdMediaAssetIds,
},
},
});
await db.delete(mediaUsage).where(inArray(mediaUsage.assetId, createdMediaAssetIds));
await db.delete(mediaAsset).where(inArray(mediaAsset.id, createdMediaAssetIds));
}
redirect(withFlash(redirectPath, { error: message }));
}
@@ -569,24 +550,17 @@ export async function deleteProjectAction(formData: FormData) {
const id = String(formData.get("id") ?? "");
try {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
select: {
slug: true,
},
});
const [project] = await db
.select({ slug: portfolioProject.slug })
.from(portfolioProject)
.where(eq(portfolioProject.id, id))
.limit(1);
if (!project) {
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
}
await prisma.portfolioProject.delete({
where: {
id,
},
});
await db.delete(portfolioProject).where(eq(portfolioProject.id, id));
await deleteEntityMediaUsages("portfolio-project", id);
await revalidatePortfolioPages();
+5 -9
View File
@@ -1,6 +1,6 @@
"use server";
import { MediaUsageType } from "@prisma/client";
import { inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
@@ -30,7 +30,9 @@ import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { MediaUsageType } from "@/lib/db/enums";
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
@@ -60,13 +62,7 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
if (assetIds.length > 0) {
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: Array.from(new Set(assetIds)),
},
},
});
await db.delete(mediaAsset).where(inArray(mediaAsset.id, Array.from(new Set(assetIds))));
}
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
+3 -2
View File
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
@@ -8,7 +9,7 @@ export async function GET() {
const timestamp = new Date().toISOString();
try {
await prisma.$queryRaw`SELECT 1`;
await db.execute(sql`SELECT 1`);
return NextResponse.json(
{
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import type { MediaKind } from "@/lib/db/enums";
import { FileType2, Grid2x2, ImageIcon, LayoutList, LoaderCircle, Trash2, Upload } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import {
ArrowDown,
ArrowUp,
+1 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import {
Check,
FileText,
+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 { and, eq, like, lt } from "drizzle-orm";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing";
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
@@ -137,11 +139,11 @@ function getLockoutKey(ip: string): string {
async function cleanupExpiredLockouts(): Promise<void> {
try {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await prisma.$executeRaw`
DELETE FROM "AppConfig"
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`}
AND "updatedAt" < ${cutoff}
`;
await db
.delete(appConfig)
.where(
and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)),
);
} catch {
// Non-critical — ignore cleanup errors.
}
@@ -216,11 +218,12 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const state = parseFailState(config?.value);
const rows = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const state = parseFailState(rows[0]?.value);
const now = Date.now();
if (state.lockUntil > now) {
@@ -244,23 +247,24 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts();
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const rows = await db
.select({ value: appConfig.value })
.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.
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
const attempts = baseAttempts + 1;
const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
const value = JSON.stringify({ attempts, lockUntil });
await prisma.appConfig.upsert({
where: { key },
update: { value: JSON.stringify({ attempts, lockUntil }) },
create: { key, value: JSON.stringify({ attempts, lockUntil }) },
});
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
return {
locked,
@@ -276,7 +280,7 @@ export async function resetAdminFailedAttempts(): Promise<void> {
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
await prisma.appConfig.deleteMany({ where: { key } });
await db.delete(appConfig).where(eq(appConfig.key, key));
} catch {
// Non-critical — ignore.
}
+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 {
SITE_NAME_KEY,
@@ -65,45 +68,44 @@ import {
type MarqueeSettings,
} 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> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAINTENANCE_MODE_KEY },
select: { value: true },
});
return config?.value === "true";
return (await getAppConfigValue(MAINTENANCE_MODE_KEY)) === "true";
} catch {
return false;
}
}
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAINTENANCE_MODE_KEY },
update: {
value: enabled ? "true" : "false",
},
create: {
key: MAINTENANCE_MODE_KEY,
value: enabled ? "true" : "false",
},
});
await upsertAppConfigValue(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
}
export async function getSiteSettings(): Promise<SiteSettings> {
try {
const configs = await prisma.appConfig.findMany({
where: {
key: {
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY],
},
},
select: {
key: true,
value: true,
},
});
const configs = await db
.select({ key: appConfig.key, value: appConfig.value })
.from(appConfig)
.where(inArray(appConfig.key, [SITE_SETTINGS_KEY, SITE_NAME_KEY]));
const configMap = new Map(configs.map((config) => [config.key, config.value]));
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
@@ -115,26 +117,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
}
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertAppConfigValue(SITE_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMailSettings(): Promise<MailSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
return parseMailSettingsValue(await getAppConfigValue(MAIL_SETTINGS_KEY));
} catch {
return buildDefaultMailSettings();
}
@@ -147,26 +135,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
}
export async function updateMailSettings(settings: MailSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertAppConfigValue(MAIL_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MARQUEE_SETTINGS_KEY },
select: { value: true },
});
return parseMarqueeSettingsValue(config?.value);
return parseMarqueeSettingsValue(await getAppConfigValue(MARQUEE_SETTINGS_KEY));
} catch {
return buildDefaultMarqueeSettings();
}
@@ -175,30 +149,23 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await prisma.appConfig.upsert({
where: { key: MARQUEE_SETTINGS_KEY },
update: {
value: JSON.stringify(normalizedSettings),
},
create: {
key: MARQUEE_SETTINGS_KEY,
value: JSON.stringify(normalizedSettings),
},
});
await upsertAppConfigValue(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
},
select: {
const usages = await db.query.mediaUsage.findMany({
where: and(
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
),
columns: {
fieldKey: true,
updatedAt: true,
},
with: {
asset: {
select: {
columns: {
id: 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 { MediaKind, MediaSource } from "@prisma/client";
import { MediaKind, MediaSource } from "@/lib/db/enums";
import { createMediaAsset, getMediaAssetById } from "@/lib/media";
import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage";
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { z } from "zod";
const mediaModeSchema = z.enum(["library", "external", "upload"]);
+61 -62
View File
@@ -1,12 +1,9 @@
import type {
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { and, count, desc, eq } from "drizzle-orm";
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<
MediaAsset,
@@ -52,38 +49,38 @@ function mapMediaAsset(
}
export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({
include: {
const assets = await db.query.mediaAsset.findMany({
with: {
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);
}
export async function getMediaOptions(filters?: { kind?: MediaKind }) {
const assets = await prisma.mediaAsset.findMany({
where: filters?.kind ? { kind: filters.kind } : undefined,
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
kind: true,
url: true,
label: true,
source: true,
},
});
const assets = await db
.select({
id: mediaAsset.id,
kind: mediaAsset.kind,
url: mediaAsset.url,
label: mediaAsset.label,
source: mediaAsset.source,
})
.from(mediaAsset)
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
.orderBy(desc(mediaAsset.createdAt));
return assets;
}
export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({
where: { id },
include: {
const asset = await db.query.mediaAsset.findFirst({
where: eq(mediaAsset.id, id),
with: {
usages: true,
},
});
@@ -101,8 +98,9 @@ export async function createMediaAsset(input: {
mimeType?: string | null;
size?: number | null;
}) {
return prisma.mediaAsset.create({
data: {
const [asset] = await db
.insert(mediaAsset)
.values({
source: input.source,
kind: input.kind,
url: input.url,
@@ -111,8 +109,10 @@ export async function createMediaAsset(input: {
altText: input.altText ?? null,
mimeType: input.mimeType ?? null,
size: input.size ?? null,
},
});
})
.returning();
return asset;
}
export async function replaceEntityMediaUsages(input: {
@@ -124,51 +124,49 @@ export async function replaceEntityMediaUsages(input: {
fieldKey: string;
}>;
}) {
await prisma.$transaction(async (tx) => {
await tx.mediaUsage.deleteMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
});
await db.transaction(async (tx) => {
await tx
.delete(mediaUsage)
.where(
and(
eq(mediaUsage.entityType, input.entityType),
eq(mediaUsage.entityId, input.entityId),
),
);
if (input.usages.length === 0) {
return;
}
await tx.mediaUsage.createMany({
data: input.usages.map((usage) => ({
await tx.insert(mediaUsage).values(
input.usages.map((usage) => ({
assetId: usage.assetId,
usageType: usage.usageType,
entityType: input.entityType,
entityId: input.entityId,
fieldKey: usage.fieldKey,
})),
});
);
});
}
export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({
where: {
entityType,
entityId,
},
});
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, entityType), eq(mediaUsage.entityId, entityId)));
}
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
select: {
assetId: true,
usageType: true,
fieldKey: true,
},
});
const usages = await db
.select({
assetId: mediaUsage.assetId,
usageType: mediaUsage.usageType,
fieldKey: mediaUsage.fieldKey,
})
.from(mediaUsage)
.where(
and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)),
);
return usages.reduce<PortfolioMediaBindings>(
(result, usage) => {
@@ -195,9 +193,10 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
}
export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({
where: {
assetId,
},
});
const [row] = await db
.select({ value: count() })
.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";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PortfolioSectionType } from "@prisma/client";
import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
+66 -74
View File
@@ -1,16 +1,13 @@
import type {
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
import { and, asc, desc, eq } from "drizzle-orm";
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 type { AppLocale } from "@/lib/locale";
import { prisma } from "@/lib/prisma";
type CategoryRecord = Pick<
Category,
@@ -240,132 +237,127 @@ export function getLocalizedValue(
}
export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({
include: {
_count: {
select: {
projects: true,
},
const categories = await db.query.category.findMany({
with: {
projects: {
columns: { id: true },
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
});
return categories.map((category) => ({
...mapCategory(category),
projectCount: category._count.projects,
return categories.map((record) => ({
...mapCategory(record),
projectCount: record.projects.length,
}));
}
export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({
where: {
isActive: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
const categories = await db.query.category.findMany({
where: eq(category.isActive, true),
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
});
return categories.map(mapCategory);
}
export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await prisma.category.findFirst({
where: {
slug,
isActive: true,
},
const record = await db.query.category.findFirst({
where: and(eq(category.slug, slug), eq(category.isActive, true)),
});
return category ? mapCategory(category) : null;
return record ? mapCategory(record) : null;
}
export async function getAdminPortfolioProjects(filters?: {
categoryId?: string;
status?: "all" | "draft" | "published";
}) {
const projects = await prisma.portfolioProject.findMany({
where: {
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}),
...(filters?.status === "draft"
? { isPublished: false }
: filters?.status === "published"
? { isPublished: true }
: {}),
},
include: {
const conditions = [
filters?.categoryId ? eq(portfolioProject.categoryId, filters.categoryId) : undefined,
filters?.status === "draft"
? eq(portfolioProject.isPublished, false)
: filters?.status === "published"
? eq(portfolioProject.isPublished, true)
: undefined,
].filter(Boolean);
const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined,
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
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));
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({
where: {
isPublished: true,
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
const projects = await db.query.portfolioProject.findMany({
where: eq(portfolioProject.isPublished, true),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
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) {
const project = await prisma.portfolioProject.findFirst({
where: {
slug,
isPublished: true,
category: {
isActive: true,
},
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
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) {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
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",
"lint": "eslint .",
"test": "vitest run",
"prisma:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "node prisma/seed.js"
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx lib/db/seed.ts"
},
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -28,6 +25,7 @@
"@types/nodemailer": "^7.0.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.44.5",
"framer-motion": "^12.35.0",
"gsap": "^3.15.0",
"lucide-react": "^0.577.0",
@@ -35,7 +33,7 @@
"next-intl": "^4.8.3",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.1",
"pg": "^8.20.0",
"postgres": "^3.4.5",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
@@ -44,15 +42,15 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.4",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prisma": "^7.4.2",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "^5",
"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();
});