REFACTORED - Replace Prisma with Drizzle ORM
CI / quality (push) Waiting to run

- 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:
Moh
2026-08-08 02:09:03 +02:00
parent 0f48381894
commit ba63f75ea8
38 changed files with 3964 additions and 2520 deletions
+56
View File
@@ -0,0 +1,56 @@
// Shared enum values + types. NO server/ORM imports here — this file is safe to
// import from client components (replaces the old `@prisma/client` enum imports).
//
// Defined as `const object + union type` (the same shape Prisma generated) rather
// than a TS `enum`, so bare string literals like "IMAGE" stay assignable and
// `z.nativeEnum(...)` keeps working.
export const PortfolioSectionType = {
RICH_TEXT: "RICH_TEXT",
GALLERY: "GALLERY",
STATS: "STATS",
DELIVERABLES: "DELIVERABLES",
LINK: "LINK",
} as const;
export type PortfolioSectionType = (typeof PortfolioSectionType)[keyof typeof PortfolioSectionType];
export const PortfolioAssetKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type PortfolioAssetKind = (typeof PortfolioAssetKind)[keyof typeof PortfolioAssetKind];
export const PortfolioProjectViewMode = {
GRID: "GRID",
STORY: "STORY",
CASE_STUDY: "CASE_STUDY",
} as const;
export type PortfolioProjectViewMode =
(typeof PortfolioProjectViewMode)[keyof typeof PortfolioProjectViewMode];
export const MediaSource = {
UPLOAD: "UPLOAD",
EXTERNAL: "EXTERNAL",
} as const;
export type MediaSource = (typeof MediaSource)[keyof typeof MediaSource];
export const MediaKind = {
IMAGE: "IMAGE",
DOCUMENT: "DOCUMENT",
} as const;
export type MediaKind = (typeof MediaKind)[keyof typeof MediaKind];
export const MediaUsageType = {
PORTFOLIO_COVER: "PORTFOLIO_COVER",
PORTFOLIO_SECTION: "PORTFOLIO_SECTION",
PORTFOLIO_ASSET: "PORTFOLIO_ASSET",
GENERIC: "GENERIC",
} as const;
export type MediaUsageType = (typeof MediaUsageType)[keyof typeof MediaUsageType];
// Helper: enum-object -> tuple of its string values, for Drizzle pgEnum(...).
// Preserves the literal union (not widened to `string`) so pgEnum columns infer
// as the exact union type.
export function enumValues<T extends Record<string, string>>(e: T): [T[keyof T], ...T[keyof T][]] {
return Object.values(e) as [T[keyof T], ...T[keyof T][]];
}
+24
View File
@@ -0,0 +1,24 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const rawConnectionString =
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/moh_sass";
// Prisma allowed a `?schema=public` query param that postgres.js does not
// understand — strip any unknown query string; `public` is the default schema.
const connectionString = rawConnectionString.split("?")[0];
const globalForDb = globalThis as unknown as {
pgClient: ReturnType<typeof postgres> | undefined;
};
const client = globalForDb.pgClient ?? postgres(connectionString, { max: 10 });
if (process.env.NODE_ENV !== "production") {
globalForDb.pgClient = client;
}
export const db = drizzle(client, { schema });
export { schema };
@@ -0,0 +1,125 @@
CREATE TYPE "public"."MediaKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."MediaSource" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
CREATE TYPE "public"."MediaUsageType" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
CREATE TYPE "public"."PortfolioAssetKind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
CREATE TYPE "public"."PortfolioProjectViewMode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
CREATE TYPE "public"."PortfolioSectionType" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
CREATE TABLE "AppConfig" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "AppConfig_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "Category" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nameAr" text NOT NULL,
"nameEn" text NOT NULL,
"nameDe" text NOT NULL,
"descriptionAr" text NOT NULL,
"descriptionEn" text NOT NULL,
"descriptionDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"isActive" boolean DEFAULT true NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "Category_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "MediaAsset" (
"id" text PRIMARY KEY NOT NULL,
"source" "MediaSource" NOT NULL,
"kind" "MediaKind" NOT NULL,
"url" text NOT NULL,
"fileName" text NOT NULL,
"label" text NOT NULL,
"altText" text,
"mimeType" text,
"size" integer,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "MediaUsage" (
"id" text PRIMARY KEY NOT NULL,
"assetId" text NOT NULL,
"usageType" "MediaUsageType" NOT NULL,
"entityType" text NOT NULL,
"entityId" text NOT NULL,
"fieldKey" text NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioAsset" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"kind" "PortfolioAssetKind" NOT NULL,
"filePath" text NOT NULL,
"altAr" text NOT NULL,
"altEn" text NOT NULL,
"altDe" text NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "PortfolioProject" (
"id" text PRIMARY KEY NOT NULL,
"categoryId" text NOT NULL,
"slug" text NOT NULL,
"viewMode" "PortfolioProjectViewMode" DEFAULT 'GRID' NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"summaryAr" text NOT NULL,
"summaryEn" text NOT NULL,
"summaryDe" text NOT NULL,
"clientName" text NOT NULL,
"projectYear" integer NOT NULL,
"serviceLabelAr" text NOT NULL,
"serviceLabelEn" text NOT NULL,
"serviceLabelDe" text NOT NULL,
"previewUrl" text,
"coverImagePath" text,
"isFeatured" boolean DEFAULT false NOT NULL,
"isPublished" boolean DEFAULT false NOT NULL,
"publishedAt" timestamp (3),
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL,
CONSTRAINT "PortfolioProject_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "PortfolioSection" (
"id" text PRIMARY KEY NOT NULL,
"projectId" text NOT NULL,
"type" "PortfolioSectionType" NOT NULL,
"titleAr" text NOT NULL,
"titleEn" text NOT NULL,
"titleDe" text NOT NULL,
"bodyAr" text NOT NULL,
"bodyEn" text NOT NULL,
"bodyDe" text NOT NULL,
"imagePath" text,
"linkUrl" text,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp (3) DEFAULT now() NOT NULL,
"updatedAt" timestamp (3) DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_assetId_MediaAsset_id_fk" FOREIGN KEY ("assetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioAsset" ADD CONSTRAINT "PortfolioAsset_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioProject" ADD CONSTRAINT "PortfolioProject_categoryId_Category_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."Category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "PortfolioSection" ADD CONSTRAINT "PortfolioSection_projectId_PortfolioProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."PortfolioProject"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "MediaAsset_kind_createdAt_idx" ON "MediaAsset" USING btree ("kind","createdAt");--> statement-breakpoint
CREATE UNIQUE INDEX "MediaUsage_usageType_entityType_entityId_fieldKey_key" ON "MediaUsage" USING btree ("usageType","entityType","entityId","fieldKey");--> statement-breakpoint
CREATE INDEX "MediaUsage_assetId_idx" ON "MediaUsage" USING btree ("assetId");--> statement-breakpoint
CREATE INDEX "MediaUsage_entityType_entityId_idx" ON "MediaUsage" USING btree ("entityType","entityId");--> statement-breakpoint
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset" USING btree ("projectId","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("categoryId","isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject" USING btree ("isPublished","sortOrder");--> statement-breakpoint
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection" USING btree ("projectId","sortOrder");
+956
View File
@@ -0,0 +1,956 @@
{
"id": "d0f97c12-4a3b-4cf7-8f81-3d00571737f4",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.AppConfig": {
"name": "AppConfig",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"AppConfig_key_unique": {
"name": "AppConfig_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Category": {
"name": "Category",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nameAr": {
"name": "nameAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nameEn": {
"name": "nameEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nameDe": {
"name": "nameDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptionAr": {
"name": "descriptionAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptionEn": {
"name": "descriptionEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptionDe": {
"name": "descriptionDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"Category_slug_unique": {
"name": "Category_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.MediaAsset": {
"name": "MediaAsset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"source": {
"name": "source",
"type": "MediaSource",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "MediaKind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fileName": {
"name": "fileName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altText": {
"name": "altText",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mimeType": {
"name": "mimeType",
"type": "text",
"primaryKey": false,
"notNull": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"MediaAsset_kind_createdAt_idx": {
"name": "MediaAsset_kind_createdAt_idx",
"columns": [
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "createdAt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.MediaUsage": {
"name": "MediaUsage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"assetId": {
"name": "assetId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"usageType": {
"name": "usageType",
"type": "MediaUsageType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"entityType": {
"name": "entityType",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entityId": {
"name": "entityId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fieldKey": {
"name": "fieldKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"MediaUsage_usageType_entityType_entityId_fieldKey_key": {
"name": "MediaUsage_usageType_entityType_entityId_fieldKey_key",
"columns": [
{
"expression": "usageType",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entityType",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entityId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "fieldKey",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"MediaUsage_assetId_idx": {
"name": "MediaUsage_assetId_idx",
"columns": [
{
"expression": "assetId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"MediaUsage_entityType_entityId_idx": {
"name": "MediaUsage_entityType_entityId_idx",
"columns": [
{
"expression": "entityType",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entityId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"MediaUsage_assetId_MediaAsset_id_fk": {
"name": "MediaUsage_assetId_MediaAsset_id_fk",
"tableFrom": "MediaUsage",
"tableTo": "MediaAsset",
"columnsFrom": [
"assetId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PortfolioAsset": {
"name": "PortfolioAsset",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"projectId": {
"name": "projectId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "PortfolioAssetKind",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"filePath": {
"name": "filePath",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altAr": {
"name": "altAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altEn": {
"name": "altEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"altDe": {
"name": "altDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"PortfolioAsset_projectId_sortOrder_idx": {
"name": "PortfolioAsset_projectId_sortOrder_idx",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"PortfolioAsset_projectId_PortfolioProject_id_fk": {
"name": "PortfolioAsset_projectId_PortfolioProject_id_fk",
"tableFrom": "PortfolioAsset",
"tableTo": "PortfolioProject",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PortfolioProject": {
"name": "PortfolioProject",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"categoryId": {
"name": "categoryId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"viewMode": {
"name": "viewMode",
"type": "PortfolioProjectViewMode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'GRID'"
},
"titleAr": {
"name": "titleAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleEn": {
"name": "titleEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleDe": {
"name": "titleDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summaryAr": {
"name": "summaryAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summaryEn": {
"name": "summaryEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summaryDe": {
"name": "summaryDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"clientName": {
"name": "clientName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"projectYear": {
"name": "projectYear",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"serviceLabelAr": {
"name": "serviceLabelAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceLabelEn": {
"name": "serviceLabelEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceLabelDe": {
"name": "serviceLabelDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"previewUrl": {
"name": "previewUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"coverImagePath": {
"name": "coverImagePath",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isFeatured": {
"name": "isFeatured",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"isPublished": {
"name": "isPublished",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"publishedAt": {
"name": "publishedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": false
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"PortfolioProject_categoryId_isPublished_sortOrder_idx": {
"name": "PortfolioProject_categoryId_isPublished_sortOrder_idx",
"columns": [
{
"expression": "categoryId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "isPublished",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"PortfolioProject_isPublished_sortOrder_idx": {
"name": "PortfolioProject_isPublished_sortOrder_idx",
"columns": [
{
"expression": "isPublished",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"PortfolioProject_categoryId_Category_id_fk": {
"name": "PortfolioProject_categoryId_Category_id_fk",
"tableFrom": "PortfolioProject",
"tableTo": "Category",
"columnsFrom": [
"categoryId"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"PortfolioProject_slug_unique": {
"name": "PortfolioProject_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.PortfolioSection": {
"name": "PortfolioSection",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"projectId": {
"name": "projectId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "PortfolioSectionType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"titleAr": {
"name": "titleAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleEn": {
"name": "titleEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"titleDe": {
"name": "titleDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"bodyAr": {
"name": "bodyAr",
"type": "text",
"primaryKey": false,
"notNull": true
},
"bodyEn": {
"name": "bodyEn",
"type": "text",
"primaryKey": false,
"notNull": true
},
"bodyDe": {
"name": "bodyDe",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imagePath": {
"name": "imagePath",
"type": "text",
"primaryKey": false,
"notNull": false
},
"linkUrl": {
"name": "linkUrl",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sortOrder": {
"name": "sortOrder",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"createdAt": {
"name": "createdAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp (3)",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"PortfolioSection_projectId_sortOrder_idx": {
"name": "PortfolioSection_projectId_sortOrder_idx",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sortOrder",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"PortfolioSection_projectId_PortfolioProject_id_fk": {
"name": "PortfolioSection_projectId_PortfolioProject_id_fk",
"tableFrom": "PortfolioSection",
"tableTo": "PortfolioProject",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.MediaKind": {
"name": "MediaKind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.MediaSource": {
"name": "MediaSource",
"schema": "public",
"values": [
"UPLOAD",
"EXTERNAL"
]
},
"public.MediaUsageType": {
"name": "MediaUsageType",
"schema": "public",
"values": [
"PORTFOLIO_COVER",
"PORTFOLIO_SECTION",
"PORTFOLIO_ASSET",
"GENERIC"
]
},
"public.PortfolioAssetKind": {
"name": "PortfolioAssetKind",
"schema": "public",
"values": [
"IMAGE",
"DOCUMENT"
]
},
"public.PortfolioProjectViewMode": {
"name": "PortfolioProjectViewMode",
"schema": "public",
"values": [
"GRID",
"STORY",
"CASE_STUDY"
]
},
"public.PortfolioSectionType": {
"name": "PortfolioSectionType",
"schema": "public",
"values": [
"RICH_TEXT",
"GALLERY",
"STATS",
"DELIVERABLES",
"LINK"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1786146995564,
"tag": "0000_absurd_rawhide_kid",
"breakpoints": true
}
]
}
+248
View File
@@ -0,0 +1,248 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
import {
MediaKind,
MediaSource,
MediaUsageType,
PortfolioAssetKind,
PortfolioProjectViewMode,
PortfolioSectionType,
enumValues,
} from "./enums";
// Postgres enum types — names match the ones Prisma created, so no DB migration
// is needed for the ORM swap.
export const portfolioSectionTypeEnum = pgEnum("PortfolioSectionType", enumValues(PortfolioSectionType));
export const portfolioAssetKindEnum = pgEnum("PortfolioAssetKind", enumValues(PortfolioAssetKind));
export const portfolioProjectViewModeEnum = pgEnum("PortfolioProjectViewMode", enumValues(PortfolioProjectViewMode));
export const mediaSourceEnum = pgEnum("MediaSource", enumValues(MediaSource));
export const mediaKindEnum = pgEnum("MediaKind", enumValues(MediaKind));
export const mediaUsageTypeEnum = pgEnum("MediaUsageType", enumValues(MediaUsageType));
// Shared column builders (Prisma parity): cuid ids, precision-3 timestamps.
const id = () => text("id").primaryKey().$defaultFn(() => createId());
const createdAt = () => timestamp("createdAt", { precision: 3, mode: "date" }).defaultNow().notNull();
const updatedAt = () =>
timestamp("updatedAt", { precision: 3, mode: "date" })
.defaultNow()
.notNull()
.$onUpdate(() => new Date());
export const appConfig = pgTable("AppConfig", {
id: id(),
key: text("key").notNull().unique(),
value: text("value").notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
export const category = pgTable(
"Category",
{
id: id(),
slug: text("slug").notNull().unique(),
nameAr: text("nameAr").notNull(),
nameEn: text("nameEn").notNull(),
nameDe: text("nameDe").notNull(),
descriptionAr: text("descriptionAr").notNull(),
descriptionEn: text("descriptionEn").notNull(),
descriptionDe: text("descriptionDe").notNull(),
sortOrder: integer("sortOrder").notNull().default(0),
isActive: boolean("isActive").notNull().default(true),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
);
export const portfolioProject = pgTable(
"PortfolioProject",
{
id: id(),
categoryId: text("categoryId")
.notNull()
.references(() => category.id, { onDelete: "restrict" }),
slug: text("slug").notNull().unique(),
viewMode: portfolioProjectViewModeEnum("viewMode").notNull().default("GRID"),
titleAr: text("titleAr").notNull(),
titleEn: text("titleEn").notNull(),
titleDe: text("titleDe").notNull(),
summaryAr: text("summaryAr").notNull(),
summaryEn: text("summaryEn").notNull(),
summaryDe: text("summaryDe").notNull(),
clientName: text("clientName").notNull(),
projectYear: integer("projectYear").notNull(),
serviceLabelAr: text("serviceLabelAr").notNull(),
serviceLabelEn: text("serviceLabelEn").notNull(),
serviceLabelDe: text("serviceLabelDe").notNull(),
previewUrl: text("previewUrl"),
coverImagePath: text("coverImagePath"),
isFeatured: boolean("isFeatured").notNull().default(false),
isPublished: boolean("isPublished").notNull().default(false),
publishedAt: timestamp("publishedAt", { precision: 3, mode: "date" }),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [
index("PortfolioProject_categoryId_isPublished_sortOrder_idx").on(
table.categoryId,
table.isPublished,
table.sortOrder,
),
index("PortfolioProject_isPublished_sortOrder_idx").on(table.isPublished, table.sortOrder),
],
);
export const portfolioSection = pgTable(
"PortfolioSection",
{
id: id(),
projectId: text("projectId")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
type: portfolioSectionTypeEnum("type").notNull(),
titleAr: text("titleAr").notNull(),
titleEn: text("titleEn").notNull(),
titleDe: text("titleDe").notNull(),
bodyAr: text("bodyAr").notNull(),
bodyEn: text("bodyEn").notNull(),
bodyDe: text("bodyDe").notNull(),
imagePath: text("imagePath"),
linkUrl: text("linkUrl"),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [index("PortfolioSection_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
);
export const portfolioAsset = pgTable(
"PortfolioAsset",
{
id: id(),
projectId: text("projectId")
.notNull()
.references(() => portfolioProject.id, { onDelete: "cascade" }),
kind: portfolioAssetKindEnum("kind").notNull(),
filePath: text("filePath").notNull(),
altAr: text("altAr").notNull(),
altEn: text("altEn").notNull(),
altDe: text("altDe").notNull(),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [index("PortfolioAsset_projectId_sortOrder_idx").on(table.projectId, table.sortOrder)],
);
export const mediaAsset = pgTable(
"MediaAsset",
{
id: id(),
source: mediaSourceEnum("source").notNull(),
kind: mediaKindEnum("kind").notNull(),
url: text("url").notNull(),
fileName: text("fileName").notNull(),
label: text("label").notNull(),
altText: text("altText"),
mimeType: text("mimeType"),
size: integer("size"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [index("MediaAsset_kind_createdAt_idx").on(table.kind, table.createdAt)],
);
export const mediaUsage = pgTable(
"MediaUsage",
{
id: id(),
assetId: text("assetId")
.notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }),
usageType: mediaUsageTypeEnum("usageType").notNull(),
entityType: text("entityType").notNull(),
entityId: text("entityId").notNull(),
fieldKey: text("fieldKey").notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [
uniqueIndex("MediaUsage_usageType_entityType_entityId_fieldKey_key").on(
table.usageType,
table.entityType,
table.entityId,
table.fieldKey,
),
index("MediaUsage_assetId_idx").on(table.assetId),
index("MediaUsage_entityType_entityId_idx").on(table.entityType, table.entityId),
],
);
// Relations (enable db.query.* `with:` includes).
export const categoryRelations = relations(category, ({ many }) => ({
projects: many(portfolioProject),
}));
export const portfolioProjectRelations = relations(portfolioProject, ({ one, many }) => ({
category: one(category, {
fields: [portfolioProject.categoryId],
references: [category.id],
}),
sections: many(portfolioSection),
assets: many(portfolioAsset),
}));
export const portfolioSectionRelations = relations(portfolioSection, ({ one }) => ({
project: one(portfolioProject, {
fields: [portfolioSection.projectId],
references: [portfolioProject.id],
}),
}));
export const portfolioAssetRelations = relations(portfolioAsset, ({ one }) => ({
project: one(portfolioProject, {
fields: [portfolioAsset.projectId],
references: [portfolioProject.id],
}),
}));
export const mediaAssetRelations = relations(mediaAsset, ({ many }) => ({
usages: many(mediaUsage),
}));
export const mediaUsageRelations = relations(mediaUsage, ({ one }) => ({
asset: one(mediaAsset, {
fields: [mediaUsage.assetId],
references: [mediaAsset.id],
}),
}));
// Inferred row types (replace the old `@prisma/client` model type imports).
export type AppConfig = typeof appConfig.$inferSelect;
export type Category = typeof category.$inferSelect;
export type PortfolioProject = typeof portfolioProject.$inferSelect;
export type PortfolioSection = typeof portfolioSection.$inferSelect;
export type PortfolioAsset = typeof portfolioAsset.$inferSelect;
export type MediaAsset = typeof mediaAsset.$inferSelect;
export type MediaUsage = typeof mediaUsage.$inferSelect;
export {
MediaKind,
MediaSource,
MediaUsageType,
PortfolioAssetKind,
PortfolioProjectViewMode,
PortfolioSectionType,
} from "./enums";
+565
View File
@@ -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);
});