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