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