- 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:
+26
-22
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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][]];
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1786146995564,
|
||||
"tag": "0000_absurd_rawhide_kid",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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,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,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
@@ -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,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,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
@@ -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)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user