- 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:
+565
@@ -0,0 +1,565 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
import * as schema from "./schema";
|
||||
import {
|
||||
appConfig,
|
||||
category,
|
||||
mediaAsset,
|
||||
mediaUsage,
|
||||
portfolioAsset,
|
||||
portfolioProject,
|
||||
portfolioSection,
|
||||
} from "./schema";
|
||||
|
||||
const connectionString = (
|
||||
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass"
|
||||
).split("?")[0];
|
||||
|
||||
const client = postgres(connectionString, { max: 1 });
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
type MediaAssetInput = {
|
||||
source: "UPLOAD" | "EXTERNAL";
|
||||
kind: "IMAGE" | "DOCUMENT";
|
||||
url: string;
|
||||
fileName: string;
|
||||
label: string;
|
||||
altText: string | null;
|
||||
mimeType: string | null;
|
||||
size: number | null;
|
||||
};
|
||||
|
||||
async function upsertMediaAsset(input: MediaAssetInput) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(mediaAsset)
|
||||
.where(and(eq(mediaAsset.label, input.label), eq(mediaAsset.url, input.url)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(mediaAsset)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(mediaAsset.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(mediaAsset).values(input).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function upsertAppConfig(key: string, value: string) {
|
||||
await db
|
||||
.insert(appConfig)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
async function upsertCategory(values: typeof category.$inferInsert) {
|
||||
const [row] = await db
|
||||
.insert(category)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: category.slug,
|
||||
set: {
|
||||
nameAr: values.nameAr,
|
||||
nameEn: values.nameEn,
|
||||
nameDe: values.nameDe,
|
||||
descriptionAr: values.descriptionAr,
|
||||
descriptionEn: values.descriptionEn,
|
||||
descriptionDe: values.descriptionDe,
|
||||
sortOrder: values.sortOrder,
|
||||
isActive: values.isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function syncProjectContent(
|
||||
projectId: string,
|
||||
sections: Array<Omit<typeof portfolioSection.$inferInsert, "projectId">>,
|
||||
assets: Array<Omit<typeof portfolioAsset.$inferInsert, "projectId">>,
|
||||
) {
|
||||
await db.delete(portfolioSection).where(eq(portfolioSection.projectId, projectId));
|
||||
await db.delete(portfolioAsset).where(eq(portfolioAsset.projectId, projectId));
|
||||
|
||||
const createdSections = [];
|
||||
for (const section of sections) {
|
||||
const [row] = await db
|
||||
.insert(portfolioSection)
|
||||
.values({ projectId, ...section })
|
||||
.returning();
|
||||
createdSections.push(row);
|
||||
}
|
||||
|
||||
const createdAssets = [];
|
||||
for (const asset of assets) {
|
||||
const [row] = await db
|
||||
.insert(portfolioAsset)
|
||||
.values({ projectId, ...asset })
|
||||
.returning();
|
||||
createdAssets.push(row);
|
||||
}
|
||||
|
||||
return { createdSections, createdAssets };
|
||||
}
|
||||
|
||||
async function syncProjectMediaUsages(
|
||||
projectId: string,
|
||||
mediaMap: {
|
||||
coverAssetId: string | null | undefined;
|
||||
sectionUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
|
||||
assetUsages: Array<{ fieldKey: string; assetId: string | null | undefined }>;
|
||||
},
|
||||
) {
|
||||
await db
|
||||
.delete(mediaUsage)
|
||||
.where(and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)));
|
||||
|
||||
const usages: (typeof mediaUsage.$inferInsert)[] = [];
|
||||
|
||||
if (mediaMap.coverAssetId) {
|
||||
usages.push({
|
||||
assetId: mediaMap.coverAssetId,
|
||||
usageType: "PORTFOLIO_COVER",
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
fieldKey: "cover",
|
||||
});
|
||||
}
|
||||
|
||||
for (const sectionUsage of mediaMap.sectionUsages) {
|
||||
if (!sectionUsage.assetId) continue;
|
||||
usages.push({
|
||||
assetId: sectionUsage.assetId,
|
||||
usageType: "PORTFOLIO_SECTION",
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
fieldKey: sectionUsage.fieldKey,
|
||||
});
|
||||
}
|
||||
|
||||
for (const assetUsage of mediaMap.assetUsages) {
|
||||
if (!assetUsage.assetId) continue;
|
||||
usages.push({
|
||||
assetId: assetUsage.assetId,
|
||||
usageType: "PORTFOLIO_ASSET",
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
fieldKey: assetUsage.fieldKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (usages.length > 0) {
|
||||
await db.insert(mediaUsage).values(usages);
|
||||
}
|
||||
}
|
||||
|
||||
const SITE_SETTINGS_VALUE = JSON.stringify({
|
||||
titleTemplate: "{pageTitle} | moh-sass",
|
||||
locales: {
|
||||
ar: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
en: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
de: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await db
|
||||
.delete(mediaUsage)
|
||||
.where(eq(mediaUsage.entityType, "portfolio-project"));
|
||||
await db.delete(portfolioSection);
|
||||
await db.delete(portfolioAsset);
|
||||
await db.delete(portfolioProject);
|
||||
await db.delete(category);
|
||||
|
||||
await upsertAppConfig("siteName", "moh-sass");
|
||||
await upsertAppConfig("site_settings", SITE_SETTINGS_VALUE);
|
||||
|
||||
const brandCategory = await upsertCategory({
|
||||
slug: "branding",
|
||||
nameAr: "الهوية البصرية",
|
||||
nameEn: "Branding",
|
||||
nameDe: "Branding",
|
||||
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
|
||||
descriptionEn: "Brand identity, logo, and design system work.",
|
||||
descriptionDe: "Branding, Logos und visuelle Systeme.",
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const webCategory = await upsertCategory({
|
||||
slug: "web-experiences",
|
||||
nameAr: "تجارب الويب",
|
||||
nameEn: "Web Experiences",
|
||||
nameDe: "Web Experiences",
|
||||
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
|
||||
descriptionEn: "Websites, landing pages, and digital experiences.",
|
||||
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const commerceCategory = await upsertCategory({
|
||||
slug: "commerce",
|
||||
nameAr: "التجارة الرقمية",
|
||||
nameEn: "Commerce",
|
||||
nameDe: "Commerce",
|
||||
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
||||
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
||||
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
||||
sortOrder: 3,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const gridCover = await upsertMediaAsset({
|
||||
source: "UPLOAD",
|
||||
kind: "IMAGE",
|
||||
url: "/uploads/portfolio/demo-cover.svg",
|
||||
fileName: "demo-cover.svg",
|
||||
label: "Portfolio Grid Cover",
|
||||
altText: "Portfolio Grid Cover",
|
||||
mimeType: "image/svg+xml",
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const storyCover = await upsertMediaAsset({
|
||||
source: "UPLOAD",
|
||||
kind: "IMAGE",
|
||||
url: "/uploads/portfolio/demo-cover.svg",
|
||||
fileName: "demo-cover.svg",
|
||||
label: "Portfolio Story Cover",
|
||||
altText: "Portfolio Story Cover",
|
||||
mimeType: "image/svg+xml",
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const caseStudyCover = await upsertMediaAsset({
|
||||
source: "UPLOAD",
|
||||
kind: "IMAGE",
|
||||
url: "/uploads/portfolio/demo-cover.svg",
|
||||
fileName: "demo-cover.svg",
|
||||
label: "Portfolio Case Study Cover",
|
||||
altText: "Portfolio Case Study Cover",
|
||||
mimeType: "image/svg+xml",
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const projects = [
|
||||
{
|
||||
slug: "grid-product-launch",
|
||||
categoryId: commerceCategory.id,
|
||||
viewMode: "GRID" as const,
|
||||
titleAr: "إطلاق منتج رقمي",
|
||||
titleEn: "Grid Product Launch",
|
||||
titleDe: "Grid Product Launch",
|
||||
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
|
||||
summaryEn: "Grid view example for a fast product launch page.",
|
||||
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
|
||||
clientName: "Launch Studio",
|
||||
projectYear: 2026,
|
||||
serviceLabelAr: "تجربة إطلاق",
|
||||
serviceLabelEn: "Launch Experience",
|
||||
serviceLabelDe: "Launch Experience",
|
||||
previewUrl: "https://example.com/preview/grid-product-launch",
|
||||
coverImagePath: gridCover.url,
|
||||
isFeatured: true,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
|
||||
sortOrder: 1,
|
||||
coverAssetId: gridCover.id,
|
||||
sections: [
|
||||
{
|
||||
type: "RICH_TEXT" as const,
|
||||
titleAr: "الفكرة",
|
||||
titleEn: "Concept",
|
||||
titleDe: "Konzept",
|
||||
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
|
||||
bodyEn: "A fast modular presentation focused on the main launch message.",
|
||||
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 0,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "GALLERY" as const,
|
||||
titleAr: "الصورة الرئيسية",
|
||||
titleEn: "Hero Visual",
|
||||
titleDe: "Hero Visual",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: gridCover.url,
|
||||
linkUrl: null,
|
||||
sortOrder: 1,
|
||||
mediaAssetId: gridCover.id,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
kind: "IMAGE" as const,
|
||||
filePath: gridCover.url,
|
||||
altAr: "غلاف مشروع Grid",
|
||||
altEn: "Grid project cover",
|
||||
altDe: "Grid Projekt Cover",
|
||||
sortOrder: 0,
|
||||
mediaAssetId: gridCover.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "campaign-site",
|
||||
categoryId: webCategory.id,
|
||||
viewMode: "STORY" as const,
|
||||
titleAr: "موقع حملة",
|
||||
titleEn: "Campaign Site",
|
||||
titleDe: "Campaign Site",
|
||||
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
|
||||
summaryEn: "Story view example for a launch campaign website.",
|
||||
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
|
||||
clientName: "Launch Client",
|
||||
projectYear: 2024,
|
||||
serviceLabelAr: "موقع تسويقي",
|
||||
serviceLabelEn: "Marketing Website",
|
||||
serviceLabelDe: "Marketing Website",
|
||||
previewUrl: "https://example.com/preview/campaign-site",
|
||||
coverImagePath: storyCover.url,
|
||||
isFeatured: false,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||
sortOrder: 2,
|
||||
coverAssetId: storyCover.id,
|
||||
sections: [
|
||||
{
|
||||
type: "RICH_TEXT" as const,
|
||||
titleAr: "السياق",
|
||||
titleEn: "Context",
|
||||
titleDe: "Kontext",
|
||||
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
|
||||
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
|
||||
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 0,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "GALLERY" as const,
|
||||
titleAr: "العرض البصري",
|
||||
titleEn: "Visual Flow",
|
||||
titleDe: "Visueller Ablauf",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: storyCover.url,
|
||||
linkUrl: null,
|
||||
sortOrder: 1,
|
||||
mediaAssetId: storyCover.id,
|
||||
},
|
||||
{
|
||||
type: "LINK" as const,
|
||||
titleAr: "المعاينة",
|
||||
titleEn: "Preview",
|
||||
titleDe: "Vorschau",
|
||||
bodyAr: "رابط العرض المباشر.",
|
||||
bodyEn: "Direct preview link.",
|
||||
bodyDe: "Direkter Vorschau-Link.",
|
||||
imagePath: null,
|
||||
linkUrl: "https://example.com/preview/campaign-site",
|
||||
sortOrder: 2,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
kind: "IMAGE" as const,
|
||||
filePath: storyCover.url,
|
||||
altAr: "غلاف مشروع Story",
|
||||
altEn: "Story project cover",
|
||||
altDe: "Story Projekt Cover",
|
||||
sortOrder: 0,
|
||||
mediaAssetId: storyCover.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "brand-redesign",
|
||||
categoryId: brandCategory.id,
|
||||
viewMode: "CASE_STUDY" as const,
|
||||
titleAr: "إعادة تصميم الهوية",
|
||||
titleEn: "Brand Redesign",
|
||||
titleDe: "Brand Redesign",
|
||||
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
|
||||
summaryEn: "Case study example focused on challenge, solution, and outcome.",
|
||||
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
|
||||
clientName: "Studio Client",
|
||||
projectYear: 2025,
|
||||
serviceLabelAr: "هوية بصرية",
|
||||
serviceLabelEn: "Brand Identity",
|
||||
serviceLabelDe: "Brand Identity",
|
||||
previewUrl: "https://example.com/preview/brand-redesign",
|
||||
coverImagePath: caseStudyCover.url,
|
||||
isFeatured: true,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||
sortOrder: 3,
|
||||
coverAssetId: caseStudyCover.id,
|
||||
sections: [
|
||||
{
|
||||
type: "RICH_TEXT" as const,
|
||||
titleAr: "التحدي",
|
||||
titleEn: "Challenge",
|
||||
titleDe: "Herausforderung",
|
||||
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
||||
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
||||
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 0,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "RICH_TEXT" as const,
|
||||
titleAr: "الحل",
|
||||
titleEn: "Solution",
|
||||
titleDe: "Loesung",
|
||||
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
||||
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
||||
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 1,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "GALLERY" as const,
|
||||
titleAr: "التنفيذ البصري",
|
||||
titleEn: "Visual Execution",
|
||||
titleDe: "Visuelle Umsetzung",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: caseStudyCover.url,
|
||||
linkUrl: null,
|
||||
sortOrder: 2,
|
||||
mediaAssetId: caseStudyCover.id,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
kind: "IMAGE" as const,
|
||||
filePath: caseStudyCover.url,
|
||||
altAr: "غلاف مشروع Case Study",
|
||||
altEn: "Case study project cover",
|
||||
altDe: "Case Study Projekt Cover",
|
||||
sortOrder: 0,
|
||||
mediaAssetId: caseStudyCover.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const projectConfig of projects) {
|
||||
const { sections, assets, coverAssetId, ...projectValues } = projectConfig;
|
||||
|
||||
const [project] = await db
|
||||
.insert(portfolioProject)
|
||||
.values(projectValues)
|
||||
.onConflictDoUpdate({
|
||||
target: portfolioProject.slug,
|
||||
set: {
|
||||
categoryId: projectValues.categoryId,
|
||||
viewMode: projectValues.viewMode,
|
||||
titleAr: projectValues.titleAr,
|
||||
titleEn: projectValues.titleEn,
|
||||
titleDe: projectValues.titleDe,
|
||||
summaryAr: projectValues.summaryAr,
|
||||
summaryEn: projectValues.summaryEn,
|
||||
summaryDe: projectValues.summaryDe,
|
||||
clientName: projectValues.clientName,
|
||||
projectYear: projectValues.projectYear,
|
||||
serviceLabelAr: projectValues.serviceLabelAr,
|
||||
serviceLabelEn: projectValues.serviceLabelEn,
|
||||
serviceLabelDe: projectValues.serviceLabelDe,
|
||||
previewUrl: projectValues.previewUrl,
|
||||
coverImagePath: projectValues.coverImagePath,
|
||||
isFeatured: projectValues.isFeatured,
|
||||
isPublished: projectValues.isPublished,
|
||||
publishedAt: projectValues.publishedAt,
|
||||
sortOrder: projectValues.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
const created = await syncProjectContent(
|
||||
project.id,
|
||||
sections.map((section) => ({
|
||||
type: section.type,
|
||||
titleAr: section.titleAr,
|
||||
titleEn: section.titleEn,
|
||||
titleDe: section.titleDe,
|
||||
bodyAr: section.bodyAr,
|
||||
bodyEn: section.bodyEn,
|
||||
bodyDe: section.bodyDe,
|
||||
imagePath: section.imagePath,
|
||||
linkUrl: section.linkUrl,
|
||||
sortOrder: section.sortOrder,
|
||||
})),
|
||||
assets.map((asset) => ({
|
||||
kind: asset.kind,
|
||||
filePath: asset.filePath,
|
||||
altAr: asset.altAr,
|
||||
altEn: asset.altEn,
|
||||
altDe: asset.altDe,
|
||||
sortOrder: asset.sortOrder,
|
||||
})),
|
||||
);
|
||||
|
||||
await syncProjectMediaUsages(project.id, {
|
||||
coverAssetId,
|
||||
sectionUsages: created.createdSections.map((sectionRow, index) => ({
|
||||
fieldKey: sectionRow.id,
|
||||
assetId: sections[index]?.mediaAssetId,
|
||||
})),
|
||||
assetUsages: created.createdAssets.map((assetRow, index) => ({
|
||||
fieldKey: assetRow.id,
|
||||
assetId: assets[index]?.mediaAssetId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await client.end();
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("Seed failed:", error);
|
||||
await client.end();
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user