- Add lib/db (schema, postgres.js client, enums, seed, migrations) on Drizzle - Rewrite all lib and admin action queries from Prisma to Drizzle - Keep existing table/column names so no data migration is needed - Preserve signed-cookie admin auth unchanged - Map unique-violation handling from Prisma P2002 to SQLSTATE 23505 - Swap deps, scripts, Makefile, and Dockerfile from Prisma to Drizzle
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { MediaKind } from "@prisma/client";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||
@@ -11,7 +11,9 @@ import { withFlash } from "@/lib/admin-feedback";
|
||||
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
|
||||
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
|
||||
import { isManagedMediaFilePath } from "@/lib/media-storage";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { MediaKind } from "@/lib/db/enums";
|
||||
|
||||
async function ensureAdmin() {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
@@ -71,11 +73,7 @@ export async function deleteMediaAssetAction(formData: FormData) {
|
||||
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
|
||||
}
|
||||
|
||||
await prisma.mediaAsset.delete({
|
||||
where: {
|
||||
id: asset.id,
|
||||
},
|
||||
});
|
||||
await db.delete(mediaAsset).where(eq(mediaAsset.id, asset.id));
|
||||
|
||||
if (isManagedMediaFilePath(asset.url)) {
|
||||
await deleteMediaAssetAndFile({
|
||||
|
||||
+105
-131
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { MediaUsageType, Prisma } from "@prisma/client";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||
@@ -15,7 +15,16 @@ import { resolveMediaSelection } from "@/lib/media-service";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
category,
|
||||
mediaAsset,
|
||||
mediaUsage,
|
||||
portfolioAsset,
|
||||
portfolioProject,
|
||||
portfolioSection,
|
||||
} from "@/lib/db/schema";
|
||||
import { MediaUsageType } from "@/lib/db/enums";
|
||||
import { isCheckedFormValue } from "@/lib/form-data";
|
||||
import { getSiteSettings } from "@/lib/app-config";
|
||||
import {
|
||||
@@ -81,6 +90,16 @@ function parseZodError(error: ZodError) {
|
||||
return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
|
||||
}
|
||||
|
||||
// Postgres unique-violation SQLSTATE (was Prisma's P2002).
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: string }).code === "23505"
|
||||
);
|
||||
}
|
||||
|
||||
async function revalidatePortfolioPages() {
|
||||
revalidatePath(toInternalAdminPath("/"));
|
||||
revalidatePath(toInternalAdminPath("/media"));
|
||||
@@ -120,17 +139,15 @@ export async function upsertCategoryAction(formData: FormData) {
|
||||
isActive: normalizeCheckboxValue(formData, "isActive"),
|
||||
});
|
||||
|
||||
if (parsed.id) {
|
||||
await prisma.category.update({
|
||||
where: {
|
||||
id: parsed.id,
|
||||
},
|
||||
data: parsed,
|
||||
});
|
||||
const { id: categoryId, ...categoryValues } = parsed;
|
||||
|
||||
if (categoryId) {
|
||||
await db
|
||||
.update(category)
|
||||
.set({ ...categoryValues, updatedAt: new Date() })
|
||||
.where(eq(category.id, categoryId));
|
||||
} else {
|
||||
await prisma.category.create({
|
||||
data: parsed,
|
||||
});
|
||||
await db.insert(category).values(categoryValues);
|
||||
}
|
||||
|
||||
await revalidatePortfolioPages();
|
||||
@@ -143,7 +160,7 @@ export async function upsertCategoryAction(formData: FormData) {
|
||||
const message =
|
||||
error instanceof ZodError
|
||||
? parseZodError(error)
|
||||
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||
: isUniqueViolation(error)
|
||||
? "Kategorie Slug muss eindeutig sein."
|
||||
: "Kategorie konnte nicht gespeichert werden.";
|
||||
|
||||
@@ -158,21 +175,16 @@ export async function deleteCategoryAction(formData: FormData) {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
|
||||
try {
|
||||
const projectCount = await prisma.portfolioProject.count({
|
||||
where: {
|
||||
categoryId: id,
|
||||
},
|
||||
});
|
||||
const [projectCountRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(portfolioProject)
|
||||
.where(eq(portfolioProject.categoryId, id));
|
||||
|
||||
if (projectCount > 0) {
|
||||
if ((projectCountRow?.value ?? 0) > 0) {
|
||||
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
|
||||
}
|
||||
|
||||
await prisma.category.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
await db.delete(category).where(eq(category.id, id));
|
||||
|
||||
await revalidatePortfolioPages();
|
||||
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
|
||||
@@ -241,15 +253,16 @@ export async function saveProjectAction(formData: FormData) {
|
||||
});
|
||||
|
||||
const existingProject = parsed.id
|
||||
? await prisma.portfolioProject.findUnique({
|
||||
where: {
|
||||
id: parsed.id,
|
||||
},
|
||||
select: {
|
||||
isPublished: true,
|
||||
publishedAt: true,
|
||||
},
|
||||
})
|
||||
? (
|
||||
await db
|
||||
.select({
|
||||
isPublished: portfolioProject.isPublished,
|
||||
publishedAt: portfolioProject.publishedAt,
|
||||
})
|
||||
.from(portfolioProject)
|
||||
.where(eq(portfolioProject.id, parsed.id))
|
||||
.limit(1)
|
||||
)[0] ?? null
|
||||
: null;
|
||||
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
|
||||
|
||||
@@ -359,81 +372,60 @@ export async function saveProjectAction(formData: FormData) {
|
||||
});
|
||||
}
|
||||
|
||||
const projectResult = await prisma.$transaction(async (tx) => {
|
||||
const currentProject = parsed.id
|
||||
? await tx.portfolioProject.update({
|
||||
where: {
|
||||
id: parsed.id,
|
||||
},
|
||||
data: {
|
||||
categoryId: parsed.categoryId,
|
||||
slug: parsed.slug,
|
||||
viewMode: parsed.viewMode,
|
||||
titleAr: parsed.titleAr,
|
||||
titleEn: parsed.titleEn,
|
||||
titleDe: parsed.titleDe,
|
||||
summaryAr: parsed.summaryAr,
|
||||
summaryEn: parsed.summaryEn,
|
||||
summaryDe: parsed.summaryDe,
|
||||
clientName: parsed.clientName,
|
||||
projectYear: parsed.projectYear,
|
||||
serviceLabelAr: parsed.serviceLabelAr,
|
||||
serviceLabelEn: parsed.serviceLabelEn,
|
||||
serviceLabelDe: parsed.serviceLabelDe,
|
||||
previewUrl: parsed.previewUrl || null,
|
||||
coverImagePath: coverSelection.url || null,
|
||||
isFeatured: parsed.isFeatured,
|
||||
isPublished: parsed.isPublished,
|
||||
const projectResult = await db.transaction(async (tx) => {
|
||||
const projectValues = {
|
||||
categoryId: parsed.categoryId,
|
||||
slug: parsed.slug,
|
||||
viewMode: parsed.viewMode,
|
||||
titleAr: parsed.titleAr,
|
||||
titleEn: parsed.titleEn,
|
||||
titleDe: parsed.titleDe,
|
||||
summaryAr: parsed.summaryAr,
|
||||
summaryEn: parsed.summaryEn,
|
||||
summaryDe: parsed.summaryDe,
|
||||
clientName: parsed.clientName,
|
||||
projectYear: parsed.projectYear,
|
||||
serviceLabelAr: parsed.serviceLabelAr,
|
||||
serviceLabelEn: parsed.serviceLabelEn,
|
||||
serviceLabelDe: parsed.serviceLabelDe,
|
||||
previewUrl: parsed.previewUrl || null,
|
||||
coverImagePath: coverSelection.url || null,
|
||||
isFeatured: parsed.isFeatured,
|
||||
isPublished: parsed.isPublished,
|
||||
sortOrder: parsed.sortOrder,
|
||||
};
|
||||
|
||||
const [currentProject] = parsed.id
|
||||
? await tx
|
||||
.update(portfolioProject)
|
||||
.set({
|
||||
...projectValues,
|
||||
publishedAt: parsed.isPublished
|
||||
? shouldPublishNow
|
||||
? new Date()
|
||||
: existingProject?.publishedAt ?? new Date()
|
||||
: null,
|
||||
sortOrder: parsed.sortOrder,
|
||||
},
|
||||
})
|
||||
: await tx.portfolioProject.create({
|
||||
data: {
|
||||
categoryId: parsed.categoryId,
|
||||
slug: parsed.slug,
|
||||
viewMode: parsed.viewMode,
|
||||
titleAr: parsed.titleAr,
|
||||
titleEn: parsed.titleEn,
|
||||
titleDe: parsed.titleDe,
|
||||
summaryAr: parsed.summaryAr,
|
||||
summaryEn: parsed.summaryEn,
|
||||
summaryDe: parsed.summaryDe,
|
||||
clientName: parsed.clientName,
|
||||
projectYear: parsed.projectYear,
|
||||
serviceLabelAr: parsed.serviceLabelAr,
|
||||
serviceLabelEn: parsed.serviceLabelEn,
|
||||
serviceLabelDe: parsed.serviceLabelDe,
|
||||
previewUrl: parsed.previewUrl || null,
|
||||
coverImagePath: coverSelection.url || null,
|
||||
isFeatured: parsed.isFeatured,
|
||||
isPublished: parsed.isPublished,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(portfolioProject.id, parsed.id))
|
||||
.returning()
|
||||
: await tx
|
||||
.insert(portfolioProject)
|
||||
.values({
|
||||
...projectValues,
|
||||
publishedAt: parsed.isPublished ? new Date() : null,
|
||||
sortOrder: parsed.sortOrder,
|
||||
},
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx.portfolioSection.deleteMany({
|
||||
where: {
|
||||
projectId: currentProject.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.portfolioAsset.deleteMany({
|
||||
where: {
|
||||
projectId: currentProject.id,
|
||||
},
|
||||
});
|
||||
await tx.delete(portfolioSection).where(eq(portfolioSection.projectId, currentProject.id));
|
||||
await tx.delete(portfolioAsset).where(eq(portfolioAsset.projectId, currentProject.id));
|
||||
|
||||
const createdSections = [];
|
||||
|
||||
for (const section of sectionRows) {
|
||||
const createdSection = await tx.portfolioSection.create({
|
||||
data: {
|
||||
const [createdSection] = await tx
|
||||
.insert(portfolioSection)
|
||||
.values({
|
||||
projectId: currentProject.id,
|
||||
type: section.type,
|
||||
titleAr: section.titleAr,
|
||||
@@ -445,8 +437,8 @@ export async function saveProjectAction(formData: FormData) {
|
||||
imagePath: section.imagePath || null,
|
||||
linkUrl: section.linkUrl || null,
|
||||
sortOrder: section.sortOrder,
|
||||
},
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
createdSections.push(createdSection);
|
||||
}
|
||||
@@ -454,8 +446,9 @@ export async function saveProjectAction(formData: FormData) {
|
||||
const createdAssets = [];
|
||||
|
||||
for (const asset of assetRows) {
|
||||
const createdAsset = await tx.portfolioAsset.create({
|
||||
data: {
|
||||
const [createdAsset] = await tx
|
||||
.insert(portfolioAsset)
|
||||
.values({
|
||||
projectId: currentProject.id,
|
||||
kind: asset.kind,
|
||||
filePath: asset.filePath,
|
||||
@@ -463,8 +456,8 @@ export async function saveProjectAction(formData: FormData) {
|
||||
altEn: asset.altEn,
|
||||
altDe: asset.altDe,
|
||||
sortOrder: asset.sortOrder,
|
||||
},
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
createdAssets.push(createdAsset);
|
||||
}
|
||||
@@ -536,7 +529,7 @@ export async function saveProjectAction(formData: FormData) {
|
||||
const message =
|
||||
error instanceof ZodError
|
||||
? parseZodError(error)
|
||||
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||
: isUniqueViolation(error)
|
||||
? "Projekt Slug muss eindeutig sein."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
@@ -544,20 +537,8 @@ export async function saveProjectAction(formData: FormData) {
|
||||
|
||||
await removeManagedPaths(uploadedPaths);
|
||||
if (createdMediaAssetIds.length > 0) {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
assetId: {
|
||||
in: createdMediaAssetIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.mediaAsset.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdMediaAssetIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.delete(mediaUsage).where(inArray(mediaUsage.assetId, createdMediaAssetIds));
|
||||
await db.delete(mediaAsset).where(inArray(mediaAsset.id, createdMediaAssetIds));
|
||||
}
|
||||
redirect(withFlash(redirectPath, { error: message }));
|
||||
}
|
||||
@@ -569,24 +550,17 @@ export async function deleteProjectAction(formData: FormData) {
|
||||
const id = String(formData.get("id") ?? "");
|
||||
|
||||
try {
|
||||
const project = await prisma.portfolioProject.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
const [project] = await db
|
||||
.select({ slug: portfolioProject.slug })
|
||||
.from(portfolioProject)
|
||||
.where(eq(portfolioProject.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
|
||||
}
|
||||
|
||||
await prisma.portfolioProject.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
await db.delete(portfolioProject).where(eq(portfolioProject.id, id));
|
||||
await deleteEntityMediaUsages("portfolio-project", id);
|
||||
|
||||
await revalidatePortfolioPages();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { MediaUsageType } from "@prisma/client";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||
@@ -30,7 +30,9 @@ import { routing } from "@/i18n/routing";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaAsset } from "@/lib/db/schema";
|
||||
import { MediaUsageType } from "@/lib/db/enums";
|
||||
|
||||
async function ensureAdmin() {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
@@ -60,13 +62,7 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
|
||||
|
||||
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
|
||||
if (assetIds.length > 0) {
|
||||
await prisma.mediaAsset.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(new Set(assetIds)),
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.delete(mediaAsset).where(inArray(mediaAsset.id, Array.from(new Set(assetIds))));
|
||||
}
|
||||
|
||||
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MediaKind } from "@prisma/client";
|
||||
import { MediaKind } from "@/lib/db/enums";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -8,7 +9,7 @@ export async function GET() {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await db.execute(sql`SELECT 1`);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user