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
+26 -22
View File
@@ -1,8 +1,10 @@
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { and, eq, like, lt } from "drizzle-orm";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "./prisma";
import { db } from "./db";
import { appConfig } from "./db/schema";
import { getAdminAppPath } from "./admin-routing";
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
@@ -137,11 +139,11 @@ function getLockoutKey(ip: string): string {
async function cleanupExpiredLockouts(): Promise<void> {
try {
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
await prisma.$executeRaw`
DELETE FROM "AppConfig"
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`}
AND "updatedAt" < ${cutoff}
`;
await db
.delete(appConfig)
.where(
and(like(appConfig.key, `${ADMIN_LOCKOUT_KEY_PREFIX}:%`), lt(appConfig.updatedAt, cutoff)),
);
} catch {
// Non-critical — ignore cleanup errors.
}
@@ -216,11 +218,12 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const state = parseFailState(config?.value);
const rows = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const state = parseFailState(rows[0]?.value);
const now = Date.now();
if (state.lockUntil > now) {
@@ -244,23 +247,24 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
await cleanupExpiredLockouts();
const config = await prisma.appConfig.findUnique({
where: { key },
select: { value: true },
});
const rows = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
const current = parseFailState(config?.value);
const current = parseFailState(rows[0]?.value);
// If a previous lockout has expired, reset the counter.
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
const attempts = baseAttempts + 1;
const locked = attempts >= MAX_FAILED_ATTEMPTS;
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
const value = JSON.stringify({ attempts, lockUntil });
await prisma.appConfig.upsert({
where: { key },
update: { value: JSON.stringify({ attempts, lockUntil }) },
create: { key, value: JSON.stringify({ attempts, lockUntil }) },
});
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({ target: appConfig.key, set: { value, updatedAt: new Date() } });
return {
locked,
@@ -276,7 +280,7 @@ export async function resetAdminFailedAttempts(): Promise<void> {
try {
const ip = await getClientIp();
const key = getLockoutKey(ip);
await prisma.appConfig.deleteMany({ where: { key } });
await db.delete(appConfig).where(eq(appConfig.key, key));
} catch {
// Non-critical — ignore.
}
+44 -77
View File
@@ -1,4 +1,7 @@
import { prisma } from "./prisma";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "./db";
import { appConfig, mediaUsage } from "./db/schema";
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
export {
SITE_NAME_KEY,
@@ -65,45 +68,44 @@ import {
type MarqueeSettings,
} from "./marquee-settings";
async function getAppConfigValue(key: string): Promise<string | undefined> {
const rows = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return rows[0]?.value;
}
async function upsertAppConfigValue(key: string, value: string): Promise<void> {
await db
.insert(appConfig)
.values({ key, value })
.onConflictDoUpdate({
target: appConfig.key,
set: { value, updatedAt: new Date() },
});
}
export async function getMaintenanceMode(): Promise<boolean> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAINTENANCE_MODE_KEY },
select: { value: true },
});
return config?.value === "true";
return (await getAppConfigValue(MAINTENANCE_MODE_KEY)) === "true";
} catch {
return false;
}
}
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAINTENANCE_MODE_KEY },
update: {
value: enabled ? "true" : "false",
},
create: {
key: MAINTENANCE_MODE_KEY,
value: enabled ? "true" : "false",
},
});
await upsertAppConfigValue(MAINTENANCE_MODE_KEY, enabled ? "true" : "false");
}
export async function getSiteSettings(): Promise<SiteSettings> {
try {
const configs = await prisma.appConfig.findMany({
where: {
key: {
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY],
},
},
select: {
key: true,
value: true,
},
});
const configs = await db
.select({ key: appConfig.key, value: appConfig.value })
.from(appConfig)
.where(inArray(appConfig.key, [SITE_SETTINGS_KEY, SITE_NAME_KEY]));
const configMap = new Map(configs.map((config) => [config.key, config.value]));
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
@@ -115,26 +117,12 @@ export async function getSiteSettings(): Promise<SiteSettings> {
}
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertAppConfigValue(SITE_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMailSettings(): Promise<MailSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MAIL_SETTINGS_KEY },
select: { value: true },
});
return parseMailSettingsValue(config?.value);
return parseMailSettingsValue(await getAppConfigValue(MAIL_SETTINGS_KEY));
} catch {
return buildDefaultMailSettings();
}
@@ -147,26 +135,12 @@ export async function getMailSettingsFormValues(): Promise<MailSettingsFormValue
}
export async function updateMailSettings(settings: MailSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: MAIL_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: MAIL_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
await upsertAppConfigValue(MAIL_SETTINGS_KEY, JSON.stringify(settings));
}
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: MARQUEE_SETTINGS_KEY },
select: { value: true },
});
return parseMarqueeSettingsValue(config?.value);
return parseMarqueeSettingsValue(await getAppConfigValue(MARQUEE_SETTINGS_KEY));
} catch {
return buildDefaultMarqueeSettings();
}
@@ -175,30 +149,23 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
await prisma.appConfig.upsert({
where: { key: MARQUEE_SETTINGS_KEY },
update: {
value: JSON.stringify(normalizedSettings),
},
create: {
key: MARQUEE_SETTINGS_KEY,
value: JSON.stringify(normalizedSettings),
},
});
await upsertAppConfigValue(MARQUEE_SETTINGS_KEY, JSON.stringify(normalizedSettings));
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
},
select: {
const usages = await db.query.mediaUsage.findMany({
where: and(
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
),
columns: {
fieldKey: true,
updatedAt: true,
},
with: {
asset: {
select: {
columns: {
id: true,
url: true,
},
+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);
});
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "path";
import { MediaKind, MediaSource } from "@prisma/client";
import { MediaKind, MediaSource } from "@/lib/db/enums";
import { createMediaAsset, getMediaAssetById } from "@/lib/media";
import { getExtensionForMimeType, removeManagedMediaFile, saveMediaUpload } from "@/lib/media-storage";
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaKind } from "@prisma/client";
import { MediaKind } from "@/lib/db/enums";
import { z } from "zod";
const mediaModeSchema = z.enum(["library", "external", "upload"]);
+61 -62
View File
@@ -1,12 +1,9 @@
import type {
MediaAsset,
MediaKind,
MediaSource,
MediaUsage,
MediaUsageType,
} from "@prisma/client";
import { and, count, desc, eq } from "drizzle-orm";
import { prisma } from "@/lib/prisma";
import { db } from "@/lib/db";
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
import type { MediaAsset, MediaUsage } from "@/lib/db/schema";
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
export type MediaAssetView = Pick<
MediaAsset,
@@ -52,38 +49,38 @@ function mapMediaAsset(
}
export async function getAdminMediaAssets() {
const assets = await prisma.mediaAsset.findMany({
include: {
const assets = await db.query.mediaAsset.findMany({
with: {
usages: {
orderBy: [{ createdAt: "desc" }],
orderBy: (usage, { desc: descOrder }) => [descOrder(usage.createdAt)],
},
},
orderBy: [{ createdAt: "desc" }],
orderBy: (asset, { desc: descOrder }) => [descOrder(asset.createdAt)],
});
return assets.map(mapMediaAsset);
}
export async function getMediaOptions(filters?: { kind?: MediaKind }) {
const assets = await prisma.mediaAsset.findMany({
where: filters?.kind ? { kind: filters.kind } : undefined,
orderBy: [{ createdAt: "desc" }],
select: {
id: true,
kind: true,
url: true,
label: true,
source: true,
},
});
const assets = await db
.select({
id: mediaAsset.id,
kind: mediaAsset.kind,
url: mediaAsset.url,
label: mediaAsset.label,
source: mediaAsset.source,
})
.from(mediaAsset)
.where(filters?.kind ? eq(mediaAsset.kind, filters.kind) : undefined)
.orderBy(desc(mediaAsset.createdAt));
return assets;
}
export async function getMediaAssetById(id: string) {
const asset = await prisma.mediaAsset.findUnique({
where: { id },
include: {
const asset = await db.query.mediaAsset.findFirst({
where: eq(mediaAsset.id, id),
with: {
usages: true,
},
});
@@ -101,8 +98,9 @@ export async function createMediaAsset(input: {
mimeType?: string | null;
size?: number | null;
}) {
return prisma.mediaAsset.create({
data: {
const [asset] = await db
.insert(mediaAsset)
.values({
source: input.source,
kind: input.kind,
url: input.url,
@@ -111,8 +109,10 @@ export async function createMediaAsset(input: {
altText: input.altText ?? null,
mimeType: input.mimeType ?? null,
size: input.size ?? null,
},
});
})
.returning();
return asset;
}
export async function replaceEntityMediaUsages(input: {
@@ -124,51 +124,49 @@ export async function replaceEntityMediaUsages(input: {
fieldKey: string;
}>;
}) {
await prisma.$transaction(async (tx) => {
await tx.mediaUsage.deleteMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
});
await db.transaction(async (tx) => {
await tx
.delete(mediaUsage)
.where(
and(
eq(mediaUsage.entityType, input.entityType),
eq(mediaUsage.entityId, input.entityId),
),
);
if (input.usages.length === 0) {
return;
}
await tx.mediaUsage.createMany({
data: input.usages.map((usage) => ({
await tx.insert(mediaUsage).values(
input.usages.map((usage) => ({
assetId: usage.assetId,
usageType: usage.usageType,
entityType: input.entityType,
entityId: input.entityId,
fieldKey: usage.fieldKey,
})),
});
);
});
}
export async function deleteEntityMediaUsages(entityType: string, entityId: string) {
await prisma.mediaUsage.deleteMany({
where: {
entityType,
entityId,
},
});
await db
.delete(mediaUsage)
.where(and(eq(mediaUsage.entityType, entityType), eq(mediaUsage.entityId, entityId)));
}
export async function getPortfolioMediaBindings(projectId: string): Promise<PortfolioMediaBindings> {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
select: {
assetId: true,
usageType: true,
fieldKey: true,
},
});
const usages = await db
.select({
assetId: mediaUsage.assetId,
usageType: mediaUsage.usageType,
fieldKey: mediaUsage.fieldKey,
})
.from(mediaUsage)
.where(
and(eq(mediaUsage.entityType, "portfolio-project"), eq(mediaUsage.entityId, projectId)),
);
return usages.reduce<PortfolioMediaBindings>(
(result, usage) => {
@@ -195,9 +193,10 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
}
export async function countMediaUsageReferences(assetId: string) {
return prisma.mediaUsage.count({
where: {
assetId,
},
});
const [row] = await db
.select({ value: count() })
.from(mediaUsage)
.where(eq(mediaUsage.assetId, assetId));
return row?.value ?? 0;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PortfolioSectionType } from "@prisma/client";
import { PortfolioSectionType } from "@/lib/db/enums";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
+66 -74
View File
@@ -1,16 +1,13 @@
import type {
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
import { and, asc, desc, eq } from "drizzle-orm";
import { cache } from "react";
import { db } from "@/lib/db";
import { category, portfolioProject } from "@/lib/db/schema";
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@/lib/db/schema";
import type { PortfolioProjectViewMode } from "@/lib/db/enums";
import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale";
import { prisma } from "@/lib/prisma";
type CategoryRecord = Pick<
Category,
@@ -240,132 +237,127 @@ export function getLocalizedValue(
}
export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({
include: {
_count: {
select: {
projects: true,
},
const categories = await db.query.category.findMany({
with: {
projects: {
columns: { id: true },
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
});
return categories.map((category) => ({
...mapCategory(category),
projectCount: category._count.projects,
return categories.map((record) => ({
...mapCategory(record),
projectCount: record.projects.length,
}));
}
export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({
where: {
isActive: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
const categories = await db.query.category.findMany({
where: eq(category.isActive, true),
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
});
return categories.map(mapCategory);
}
export async function getActivePortfolioCategoryBySlug(slug: string) {
const category = await prisma.category.findFirst({
where: {
slug,
isActive: true,
},
const record = await db.query.category.findFirst({
where: and(eq(category.slug, slug), eq(category.isActive, true)),
});
return category ? mapCategory(category) : null;
return record ? mapCategory(record) : null;
}
export async function getAdminPortfolioProjects(filters?: {
categoryId?: string;
status?: "all" | "draft" | "published";
}) {
const projects = await prisma.portfolioProject.findMany({
where: {
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}),
...(filters?.status === "draft"
? { isPublished: false }
: filters?.status === "published"
? { isPublished: true }
: {}),
},
include: {
const conditions = [
filters?.categoryId ? eq(portfolioProject.categoryId, filters.categoryId) : undefined,
filters?.status === "draft"
? eq(portfolioProject.isPublished, false)
: filters?.status === "published"
? eq(portfolioProject.isPublished, true)
: undefined,
].filter(Boolean);
const projects = await db.query.portfolioProject.findMany({
where: conditions.length ? and(...conditions) : undefined,
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
});
return projects.map((project) => mapProject(project));
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({
where: {
isPublished: true,
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
const projects = await db.query.portfolioProject.findMany({
where: eq(portfolioProject.isPublished, true),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
orderBy: [
asc(portfolioProject.sortOrder),
desc(portfolioProject.publishedAt),
desc(portfolioProject.createdAt),
],
});
return projects.map((project) => mapProject(project));
return projects
.filter(
(project) =>
project.category.isActive &&
(!filters?.categorySlug || project.category.slug === filters.categorySlug),
)
.map((project) => mapProject(project));
}
export const getPublishedPortfolioProjectBySlug = cache(async function (slug: string) {
const project = await prisma.portfolioProject.findFirst({
where: {
slug,
isPublished: true,
category: {
isActive: true,
},
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
});
return project ? mapProject(project) : null;
if (!project || !project.category.isActive) {
return null;
}
return mapProject(project);
});
export async function getAdminPortfolioProjectById(id: string) {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
include: {
const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id),
with: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
});
-32
View File
@@ -1,32 +0,0 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
prismaPool: Pool | undefined;
};
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
const pool =
globalForPrisma.prismaPool ??
new Pool({
connectionString,
});
const adapter = new PrismaPg(pool);
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log: ["warn", "error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prismaPool = pool;
globalForPrisma.prisma = prisma;
}