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