REFACTORED - migrate the data layer from Prisma to Drizzle (unify the stack)
- Add lib/db (Drizzle schema: 7 tables + 6 enums + relations, postgres.js client), drizzle.config.ts, lib/db/enums.ts (Prisma-compatible enum objects) - Rewrite all 14 app consumers + 4 admin components to Drizzle - Move the test DB layer to Drizzle + in-process PGlite; convert all 8 integration test files + factories (371 tests green) - Remove Prisma: deps, prisma/ schema+migrations, lib/prisma.ts, Dockerfile prisma generate; wire db:generate/migrate/push/studio to drizzle-kit; update Makefile - Preserve the old seed as scripts/legacy-prisma-seed.cjs (needs a Drizzle rewrite)
This commit is contained in:
+25
-20
@@ -2,7 +2,10 @@ import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
import { and, eq, like, lt } from "drizzle-orm";
|
||||
|
||||
import { db } from "./db";
|
||||
import { appConfig } from "./db/schema";
|
||||
import { getAdminAppPath } from "./admin-routing";
|
||||
|
||||
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||
@@ -137,11 +140,9 @@ 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,10 +217,11 @@ 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 [config] = await db
|
||||
.select({ value: appConfig.value })
|
||||
.from(appConfig)
|
||||
.where(eq(appConfig.key, key))
|
||||
.limit(1);
|
||||
const state = parseFailState(config?.value);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -244,10 +246,11 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
|
||||
|
||||
await cleanupExpiredLockouts();
|
||||
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key },
|
||||
select: { value: true },
|
||||
});
|
||||
const [config] = await db
|
||||
.select({ value: appConfig.value })
|
||||
.from(appConfig)
|
||||
.where(eq(appConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
const current = parseFailState(config?.value);
|
||||
// If a previous lockout has expired, reset the counter.
|
||||
@@ -256,11 +259,13 @@ export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; r
|
||||
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
||||
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
||||
|
||||
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: JSON.stringify({ attempts, lockUntil }) })
|
||||
.onConflictDoUpdate({
|
||||
target: appConfig.key,
|
||||
set: { value: JSON.stringify({ attempts, lockUntil }) },
|
||||
});
|
||||
|
||||
return {
|
||||
locked,
|
||||
@@ -276,7 +281,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.
|
||||
}
|
||||
|
||||
+69
-120
@@ -1,4 +1,7 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "./db";
|
||||
import { appConfig, mediaAsset, mediaUsage } from "./db/schema";
|
||||
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
||||
export {
|
||||
SITE_NAME_KEY,
|
||||
@@ -65,45 +68,42 @@ import {
|
||||
type MarqueeSettings,
|
||||
} from "./marquee-settings";
|
||||
|
||||
// Small helpers over the app_config key/value table (Drizzle).
|
||||
async function readConfigValue(key: string): Promise<string | undefined> {
|
||||
const [row] = await db
|
||||
.select({ value: appConfig.value })
|
||||
.from(appConfig)
|
||||
.where(eq(appConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
async function upsertConfig(key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.insert(appConfig)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: appConfig.key, set: { value } });
|
||||
}
|
||||
|
||||
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 readConfigValue(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 upsertConfig(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 +115,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 upsertConfig(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 readConfigValue(MAIL_SETTINGS_KEY));
|
||||
} catch {
|
||||
return buildDefaultMailSettings();
|
||||
}
|
||||
@@ -147,26 +133,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 upsertConfig(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 readConfigValue(MARQUEE_SETTINGS_KEY));
|
||||
} catch {
|
||||
return buildDefaultMarqueeSettings();
|
||||
}
|
||||
@@ -175,75 +147,52 @@ 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 upsertConfig(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: {
|
||||
fieldKey: true,
|
||||
updatedAt: true,
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const usages = await db
|
||||
.select({
|
||||
fieldKey: mediaUsage.fieldKey,
|
||||
updatedAt: mediaUsage.updatedAt,
|
||||
assetId: mediaAsset.id,
|
||||
assetUrl: mediaAsset.url,
|
||||
})
|
||||
.from(mediaUsage)
|
||||
.innerJoin(mediaAsset, eq(mediaAsset.id, mediaUsage.assetId))
|
||||
.where(
|
||||
and(
|
||||
eq(mediaUsage.entityType, SITE_SETTINGS_ENTITY_TYPE),
|
||||
eq(mediaUsage.entityId, SITE_SETTINGS_ENTITY_ID),
|
||||
),
|
||||
);
|
||||
|
||||
return usages.reduce<SiteSettingsMediaBindings>(
|
||||
(result, usage) => {
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
|
||||
result.siteLogoLight = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
return usages.reduce<SiteSettingsMediaBindings>((result, usage) => {
|
||||
const binding = {
|
||||
assetId: usage.assetId,
|
||||
url: usage.assetUrl,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
|
||||
result.siteLogoDark = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
|
||||
result.siteLogoLight = binding;
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
||||
result.favicon = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
|
||||
result.siteLogoDark = binding;
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
|
||||
result.defaultOgImage = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
||||
result.favicon = binding;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
getDefaultSiteSettingsMediaBindings(),
|
||||
);
|
||||
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
|
||||
result.defaultOgImage = binding;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, getDefaultSiteSettingsMediaBindings());
|
||||
} catch {
|
||||
return getDefaultSiteSettingsMediaBindings();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
mediaKind,
|
||||
mediaSource,
|
||||
mediaUsageType,
|
||||
portfolioAssetKind,
|
||||
portfolioProjectViewMode,
|
||||
portfolioSectionType,
|
||||
} from "./schema";
|
||||
|
||||
/**
|
||||
* Prisma-compatible enum objects + types, derived from the Drizzle pgEnums, so
|
||||
* existing consumers can keep writing `MediaKind.IMAGE` (value) and `: MediaKind`
|
||||
* (type) — only the import path changes from `@prisma/client` to `@/lib/db/enums`.
|
||||
*/
|
||||
function asEnum<T extends string>(values: readonly T[]): { [K in T]: K } {
|
||||
return Object.fromEntries(values.map((v) => [v, v])) as { [K in T]: K };
|
||||
}
|
||||
|
||||
export const MediaKind = asEnum(mediaKind.enumValues);
|
||||
export type MediaKind = (typeof mediaKind.enumValues)[number];
|
||||
|
||||
export const MediaSource = asEnum(mediaSource.enumValues);
|
||||
export type MediaSource = (typeof mediaSource.enumValues)[number];
|
||||
|
||||
export const MediaUsageType = asEnum(mediaUsageType.enumValues);
|
||||
export type MediaUsageType = (typeof mediaUsageType.enumValues)[number];
|
||||
|
||||
export const PortfolioAssetKind = asEnum(portfolioAssetKind.enumValues);
|
||||
export type PortfolioAssetKind = (typeof portfolioAssetKind.enumValues)[number];
|
||||
|
||||
export const PortfolioProjectViewMode = asEnum(portfolioProjectViewMode.enumValues);
|
||||
export type PortfolioProjectViewMode = (typeof portfolioProjectViewMode.enumValues)[number];
|
||||
|
||||
export const PortfolioSectionType = asEnum(portfolioSectionType.enumValues);
|
||||
export type PortfolioSectionType = (typeof portfolioSectionType.enumValues)[number];
|
||||
@@ -0,0 +1,26 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* The Drizzle database client (postgres.js driver), matching the house standard
|
||||
* used by the other projects. Replaces the old Prisma client (`lib/prisma.ts`).
|
||||
* A single connection is reused across hot reloads in dev.
|
||||
*/
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://postgres:postgres@localhost:5432/moh_sass?schema=public";
|
||||
|
||||
const globalForDb = globalThis as unknown as {
|
||||
dbClient: ReturnType<typeof postgres> | undefined;
|
||||
};
|
||||
|
||||
const client = globalForDb.dbClient ?? postgres(connectionString);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForDb.dbClient = client;
|
||||
}
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
export { schema };
|
||||
@@ -0,0 +1,125 @@
|
||||
CREATE TYPE "public"."media_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
|
||||
CREATE TYPE "public"."media_source" AS ENUM('UPLOAD', 'EXTERNAL');--> statement-breakpoint
|
||||
CREATE TYPE "public"."media_usage_type" AS ENUM('PORTFOLIO_COVER', 'PORTFOLIO_SECTION', 'PORTFOLIO_ASSET', 'GENERIC');--> statement-breakpoint
|
||||
CREATE TYPE "public"."portfolio_asset_kind" AS ENUM('IMAGE', 'DOCUMENT');--> statement-breakpoint
|
||||
CREATE TYPE "public"."portfolio_project_view_mode" AS ENUM('GRID', 'STORY', 'CASE_STUDY');--> statement-breakpoint
|
||||
CREATE TYPE "public"."portfolio_section_type" AS ENUM('RICH_TEXT', 'GALLERY', 'STATS', 'DELIVERABLES', 'LINK');--> statement-breakpoint
|
||||
CREATE TABLE "app_config" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "app_config_key_unique" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "category" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"name_ar" text NOT NULL,
|
||||
"name_en" text NOT NULL,
|
||||
"name_de" text NOT NULL,
|
||||
"description_ar" text NOT NULL,
|
||||
"description_en" text NOT NULL,
|
||||
"description_de" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "category_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "media_asset" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"source" "media_source" NOT NULL,
|
||||
"kind" "media_kind" NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"file_name" text NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"alt_text" text,
|
||||
"mime_type" text,
|
||||
"size" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "media_usage" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"asset_id" text NOT NULL,
|
||||
"usage_type" "media_usage_type" NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"field_key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "portfolio_asset" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"project_id" text NOT NULL,
|
||||
"kind" "portfolio_asset_kind" NOT NULL,
|
||||
"file_path" text NOT NULL,
|
||||
"alt_ar" text NOT NULL,
|
||||
"alt_en" text NOT NULL,
|
||||
"alt_de" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "portfolio_project" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"category_id" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"view_mode" "portfolio_project_view_mode" DEFAULT 'GRID' NOT NULL,
|
||||
"title_ar" text NOT NULL,
|
||||
"title_en" text NOT NULL,
|
||||
"title_de" text NOT NULL,
|
||||
"summary_ar" text NOT NULL,
|
||||
"summary_en" text NOT NULL,
|
||||
"summary_de" text NOT NULL,
|
||||
"client_name" text NOT NULL,
|
||||
"project_year" integer NOT NULL,
|
||||
"service_label_ar" text NOT NULL,
|
||||
"service_label_en" text NOT NULL,
|
||||
"service_label_de" text NOT NULL,
|
||||
"preview_url" text,
|
||||
"cover_image_path" text,
|
||||
"is_featured" boolean DEFAULT false NOT NULL,
|
||||
"is_published" boolean DEFAULT false NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "portfolio_project_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "portfolio_section" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"project_id" text NOT NULL,
|
||||
"type" "portfolio_section_type" NOT NULL,
|
||||
"title_ar" text NOT NULL,
|
||||
"title_en" text NOT NULL,
|
||||
"title_de" text NOT NULL,
|
||||
"body_ar" text NOT NULL,
|
||||
"body_en" text NOT NULL,
|
||||
"body_de" text NOT NULL,
|
||||
"image_path" text,
|
||||
"link_url" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "media_usage" ADD CONSTRAINT "media_usage_asset_id_media_asset_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."media_asset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "portfolio_asset" ADD CONSTRAINT "portfolio_asset_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "portfolio_project" ADD CONSTRAINT "portfolio_project_category_id_category_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."category"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "portfolio_section" ADD CONSTRAINT "portfolio_section_project_id_portfolio_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."portfolio_project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "media_asset_kind_created_idx" ON "media_asset" USING btree ("kind","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "media_usage_unique_slot" ON "media_usage" USING btree ("usage_type","entity_type","entity_id","field_key");--> statement-breakpoint
|
||||
CREATE INDEX "media_usage_asset_idx" ON "media_usage" USING btree ("asset_id");--> statement-breakpoint
|
||||
CREATE INDEX "media_usage_entity_idx" ON "media_usage" USING btree ("entity_type","entity_id");--> statement-breakpoint
|
||||
CREATE INDEX "portfolio_asset_project_sort_idx" ON "portfolio_asset" USING btree ("project_id","sort_order");--> statement-breakpoint
|
||||
CREATE INDEX "portfolio_project_category_published_sort_idx" ON "portfolio_project" USING btree ("category_id","is_published","sort_order");--> statement-breakpoint
|
||||
CREATE INDEX "portfolio_project_published_sort_idx" ON "portfolio_project" USING btree ("is_published","sort_order");--> statement-breakpoint
|
||||
CREATE INDEX "portfolio_section_project_sort_idx" ON "portfolio_section" USING btree ("project_id","sort_order");
|
||||
@@ -0,0 +1,956 @@
|
||||
{
|
||||
"id": "a001b9c1-c931-4e9f-b21c-50cdfbffb6a6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.app_config": {
|
||||
"name": "app_config",
|
||||
"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
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"app_config_key_unique": {
|
||||
"name": "app_config_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
|
||||
},
|
||||
"name_ar": {
|
||||
"name": "name_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name_en": {
|
||||
"name": "name_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name_de": {
|
||||
"name": "name_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description_ar": {
|
||||
"name": "description_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description_en": {
|
||||
"name": "description_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description_de": {
|
||||
"name": "description_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"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.media_asset": {
|
||||
"name": "media_asset",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "media_source",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "media_kind",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_name": {
|
||||
"name": "file_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"alt_text": {
|
||||
"name": "alt_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"media_asset_kind_created_idx": {
|
||||
"name": "media_asset_kind_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "kind",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.media_usage": {
|
||||
"name": "media_usage",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"asset_id": {
|
||||
"name": "asset_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"usage_type": {
|
||||
"name": "usage_type",
|
||||
"type": "media_usage_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"field_key": {
|
||||
"name": "field_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"media_usage_unique_slot": {
|
||||
"name": "media_usage_unique_slot",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "usage_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "entity_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "entity_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "field_key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"media_usage_asset_idx": {
|
||||
"name": "media_usage_asset_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "asset_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"media_usage_entity_idx": {
|
||||
"name": "media_usage_entity_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "entity_type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "entity_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"media_usage_asset_id_media_asset_id_fk": {
|
||||
"name": "media_usage_asset_id_media_asset_id_fk",
|
||||
"tableFrom": "media_usage",
|
||||
"tableTo": "media_asset",
|
||||
"columnsFrom": [
|
||||
"asset_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.portfolio_asset": {
|
||||
"name": "portfolio_asset",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "portfolio_asset_kind",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_path": {
|
||||
"name": "file_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"alt_ar": {
|
||||
"name": "alt_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"alt_en": {
|
||||
"name": "alt_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"alt_de": {
|
||||
"name": "alt_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"portfolio_asset_project_sort_idx": {
|
||||
"name": "portfolio_asset_project_sort_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "project_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"portfolio_asset_project_id_portfolio_project_id_fk": {
|
||||
"name": "portfolio_asset_project_id_portfolio_project_id_fk",
|
||||
"tableFrom": "portfolio_asset",
|
||||
"tableTo": "portfolio_project",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.portfolio_project": {
|
||||
"name": "portfolio_project",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"category_id": {
|
||||
"name": "category_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"view_mode": {
|
||||
"name": "view_mode",
|
||||
"type": "portfolio_project_view_mode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'GRID'"
|
||||
},
|
||||
"title_ar": {
|
||||
"name": "title_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title_en": {
|
||||
"name": "title_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title_de": {
|
||||
"name": "title_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"summary_ar": {
|
||||
"name": "summary_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"summary_en": {
|
||||
"name": "summary_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"summary_de": {
|
||||
"name": "summary_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_name": {
|
||||
"name": "client_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"project_year": {
|
||||
"name": "project_year",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"service_label_ar": {
|
||||
"name": "service_label_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"service_label_en": {
|
||||
"name": "service_label_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"service_label_de": {
|
||||
"name": "service_label_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"preview_url": {
|
||||
"name": "preview_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cover_image_path": {
|
||||
"name": "cover_image_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_featured": {
|
||||
"name": "is_featured",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"is_published": {
|
||||
"name": "is_published",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"portfolio_project_category_published_sort_idx": {
|
||||
"name": "portfolio_project_category_published_sort_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "category_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "is_published",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"portfolio_project_published_sort_idx": {
|
||||
"name": "portfolio_project_published_sort_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "is_published",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"portfolio_project_category_id_category_id_fk": {
|
||||
"name": "portfolio_project_category_id_category_id_fk",
|
||||
"tableFrom": "portfolio_project",
|
||||
"tableTo": "category",
|
||||
"columnsFrom": [
|
||||
"category_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"portfolio_project_slug_unique": {
|
||||
"name": "portfolio_project_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.portfolio_section": {
|
||||
"name": "portfolio_section",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "portfolio_section_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title_ar": {
|
||||
"name": "title_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title_en": {
|
||||
"name": "title_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title_de": {
|
||||
"name": "title_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body_ar": {
|
||||
"name": "body_ar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body_en": {
|
||||
"name": "body_en",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body_de": {
|
||||
"name": "body_de",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_path": {
|
||||
"name": "image_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"link_url": {
|
||||
"name": "link_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"portfolio_section_project_sort_idx": {
|
||||
"name": "portfolio_section_project_sort_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "project_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "sort_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"portfolio_section_project_id_portfolio_project_id_fk": {
|
||||
"name": "portfolio_section_project_id_portfolio_project_id_fk",
|
||||
"tableFrom": "portfolio_section",
|
||||
"tableTo": "portfolio_project",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.media_kind": {
|
||||
"name": "media_kind",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"IMAGE",
|
||||
"DOCUMENT"
|
||||
]
|
||||
},
|
||||
"public.media_source": {
|
||||
"name": "media_source",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"UPLOAD",
|
||||
"EXTERNAL"
|
||||
]
|
||||
},
|
||||
"public.media_usage_type": {
|
||||
"name": "media_usage_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"PORTFOLIO_COVER",
|
||||
"PORTFOLIO_SECTION",
|
||||
"PORTFOLIO_ASSET",
|
||||
"GENERIC"
|
||||
]
|
||||
},
|
||||
"public.portfolio_asset_kind": {
|
||||
"name": "portfolio_asset_kind",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"IMAGE",
|
||||
"DOCUMENT"
|
||||
]
|
||||
},
|
||||
"public.portfolio_project_view_mode": {
|
||||
"name": "portfolio_project_view_mode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"GRID",
|
||||
"STORY",
|
||||
"CASE_STUDY"
|
||||
]
|
||||
},
|
||||
"public.portfolio_section_type": {
|
||||
"name": "portfolio_section_type",
|
||||
"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": 1786049545718,
|
||||
"tag": "0000_fixed_venom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* Drizzle schema — the single source of truth for the database, replacing the
|
||||
* old Prisma schema (see docs). The database is Postgres; migrations are
|
||||
* generated with `drizzle-kit generate`. IDs are app-generated opaque strings
|
||||
* (was Prisma `cuid()`), timestamps default in the DB and bump on update.
|
||||
*/
|
||||
|
||||
// `crypto.randomUUID()` is a global in Node 20+ and browsers (no node: import),
|
||||
// so the schema stays safe to pull into a client bundle via lib/db/enums.
|
||||
const id = () =>
|
||||
text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID());
|
||||
|
||||
const createdAt = timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
||||
const updatedAt = timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date());
|
||||
|
||||
// --- Enums ------------------------------------------------------------------
|
||||
|
||||
export const portfolioSectionType = pgEnum("portfolio_section_type", [
|
||||
"RICH_TEXT",
|
||||
"GALLERY",
|
||||
"STATS",
|
||||
"DELIVERABLES",
|
||||
"LINK",
|
||||
]);
|
||||
|
||||
export const portfolioAssetKind = pgEnum("portfolio_asset_kind", ["IMAGE", "DOCUMENT"]);
|
||||
|
||||
export const portfolioProjectViewMode = pgEnum("portfolio_project_view_mode", [
|
||||
"GRID",
|
||||
"STORY",
|
||||
"CASE_STUDY",
|
||||
]);
|
||||
|
||||
export const mediaSource = pgEnum("media_source", ["UPLOAD", "EXTERNAL"]);
|
||||
|
||||
export const mediaKind = pgEnum("media_kind", ["IMAGE", "DOCUMENT"]);
|
||||
|
||||
export const mediaUsageType = pgEnum("media_usage_type", [
|
||||
"PORTFOLIO_COVER",
|
||||
"PORTFOLIO_SECTION",
|
||||
"PORTFOLIO_ASSET",
|
||||
"GENERIC",
|
||||
]);
|
||||
|
||||
// --- Tables -----------------------------------------------------------------
|
||||
|
||||
export const appConfig = pgTable("app_config", {
|
||||
id: id(),
|
||||
key: text("key").notNull().unique(),
|
||||
value: text("value").notNull(),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
export const category = pgTable("category", {
|
||||
id: id(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
nameAr: text("name_ar").notNull(),
|
||||
nameEn: text("name_en").notNull(),
|
||||
nameDe: text("name_de").notNull(),
|
||||
descriptionAr: text("description_ar").notNull(),
|
||||
descriptionEn: text("description_en").notNull(),
|
||||
descriptionDe: text("description_de").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
export const portfolioProject = pgTable(
|
||||
"portfolio_project",
|
||||
{
|
||||
id: id(),
|
||||
categoryId: text("category_id")
|
||||
.notNull()
|
||||
.references(() => category.id, { onDelete: "restrict" }),
|
||||
slug: text("slug").notNull().unique(),
|
||||
viewMode: portfolioProjectViewMode("view_mode").notNull().default("GRID"),
|
||||
titleAr: text("title_ar").notNull(),
|
||||
titleEn: text("title_en").notNull(),
|
||||
titleDe: text("title_de").notNull(),
|
||||
summaryAr: text("summary_ar").notNull(),
|
||||
summaryEn: text("summary_en").notNull(),
|
||||
summaryDe: text("summary_de").notNull(),
|
||||
clientName: text("client_name").notNull(),
|
||||
projectYear: integer("project_year").notNull(),
|
||||
serviceLabelAr: text("service_label_ar").notNull(),
|
||||
serviceLabelEn: text("service_label_en").notNull(),
|
||||
serviceLabelDe: text("service_label_de").notNull(),
|
||||
previewUrl: text("preview_url"),
|
||||
coverImagePath: text("cover_image_path"),
|
||||
isFeatured: boolean("is_featured").notNull().default(false),
|
||||
isPublished: boolean("is_published").notNull().default(false),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
(t) => [
|
||||
index("portfolio_project_category_published_sort_idx").on(t.categoryId, t.isPublished, t.sortOrder),
|
||||
index("portfolio_project_published_sort_idx").on(t.isPublished, t.sortOrder),
|
||||
],
|
||||
);
|
||||
|
||||
export const portfolioSection = pgTable(
|
||||
"portfolio_section",
|
||||
{
|
||||
id: id(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => portfolioProject.id, { onDelete: "cascade" }),
|
||||
type: portfolioSectionType("type").notNull(),
|
||||
titleAr: text("title_ar").notNull(),
|
||||
titleEn: text("title_en").notNull(),
|
||||
titleDe: text("title_de").notNull(),
|
||||
bodyAr: text("body_ar").notNull(),
|
||||
bodyEn: text("body_en").notNull(),
|
||||
bodyDe: text("body_de").notNull(),
|
||||
imagePath: text("image_path"),
|
||||
linkUrl: text("link_url"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
(t) => [index("portfolio_section_project_sort_idx").on(t.projectId, t.sortOrder)],
|
||||
);
|
||||
|
||||
export const portfolioAsset = pgTable(
|
||||
"portfolio_asset",
|
||||
{
|
||||
id: id(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => portfolioProject.id, { onDelete: "cascade" }),
|
||||
kind: portfolioAssetKind("kind").notNull(),
|
||||
filePath: text("file_path").notNull(),
|
||||
altAr: text("alt_ar").notNull(),
|
||||
altEn: text("alt_en").notNull(),
|
||||
altDe: text("alt_de").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
(t) => [index("portfolio_asset_project_sort_idx").on(t.projectId, t.sortOrder)],
|
||||
);
|
||||
|
||||
export const mediaAsset = pgTable(
|
||||
"media_asset",
|
||||
{
|
||||
id: id(),
|
||||
source: mediaSource("source").notNull(),
|
||||
kind: mediaKind("kind").notNull(),
|
||||
url: text("url").notNull(),
|
||||
fileName: text("file_name").notNull(),
|
||||
label: text("label").notNull(),
|
||||
altText: text("alt_text"),
|
||||
mimeType: text("mime_type"),
|
||||
size: integer("size"),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
(t) => [index("media_asset_kind_created_idx").on(t.kind, t.createdAt)],
|
||||
);
|
||||
|
||||
export const mediaUsage = pgTable(
|
||||
"media_usage",
|
||||
{
|
||||
id: id(),
|
||||
assetId: text("asset_id")
|
||||
.notNull()
|
||||
.references(() => mediaAsset.id, { onDelete: "cascade" }),
|
||||
usageType: mediaUsageType("usage_type").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: text("entity_id").notNull(),
|
||||
fieldKey: text("field_key").notNull(),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("media_usage_unique_slot").on(t.usageType, t.entityType, t.entityId, t.fieldKey),
|
||||
index("media_usage_asset_idx").on(t.assetId),
|
||||
index("media_usage_entity_idx").on(t.entityType, t.entityId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- Relations (for the relational query API: db.query.*.findMany({ with })) --
|
||||
|
||||
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],
|
||||
}),
|
||||
}));
|
||||
@@ -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"]);
|
||||
|
||||
+54
-79
@@ -1,20 +1,17 @@
|
||||
import type {
|
||||
MediaAsset,
|
||||
MediaKind,
|
||||
MediaSource,
|
||||
MediaUsage,
|
||||
MediaUsageType,
|
||||
} from "@prisma/client";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { db } from "@/lib/db";
|
||||
import { mediaAsset, mediaUsage } from "@/lib/db/schema";
|
||||
import type { MediaKind, MediaSource, MediaUsageType } from "@/lib/db/enums";
|
||||
|
||||
type MediaAssetRow = typeof mediaAsset.$inferSelect;
|
||||
type MediaUsageRow = typeof mediaUsage.$inferSelect;
|
||||
|
||||
export type MediaAssetView = Pick<
|
||||
MediaAsset,
|
||||
MediaAssetRow,
|
||||
"id" | "source" | "kind" | "url" | "fileName" | "label" | "altText" | "mimeType" | "size" | "createdAt"
|
||||
> & {
|
||||
usages: Array<
|
||||
Pick<MediaUsage, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">
|
||||
>;
|
||||
usages: Array<Pick<MediaUsageRow, "id" | "usageType" | "entityType" | "entityId" | "fieldKey">>;
|
||||
};
|
||||
|
||||
export type MediaOption = Pick<MediaAssetView, "id" | "kind" | "url" | "label" | "source">;
|
||||
@@ -25,11 +22,7 @@ export type PortfolioMediaBindings = {
|
||||
assetIds: Record<string, string>;
|
||||
};
|
||||
|
||||
function mapMediaAsset(
|
||||
asset: MediaAsset & {
|
||||
usages: MediaUsage[];
|
||||
},
|
||||
): MediaAssetView {
|
||||
function mapMediaAsset(asset: MediaAssetRow & { usages: MediaUsageRow[] }): MediaAssetView {
|
||||
return {
|
||||
id: asset.id,
|
||||
source: asset.source,
|
||||
@@ -52,40 +45,32 @@ function mapMediaAsset(
|
||||
}
|
||||
|
||||
export async function getAdminMediaAssets() {
|
||||
const assets = await prisma.mediaAsset.findMany({
|
||||
include: {
|
||||
usages: {
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
const assets = await db.query.mediaAsset.findMany({
|
||||
with: { usages: { orderBy: [desc(mediaUsage.createdAt)] } },
|
||||
orderBy: [desc(mediaAsset.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,
|
||||
},
|
||||
});
|
||||
|
||||
return assets;
|
||||
export async function getMediaOptions(filters?: { kind?: MediaKind }): Promise<MediaOption[]> {
|
||||
return 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));
|
||||
}
|
||||
|
||||
export async function getMediaAssetById(id: string) {
|
||||
const asset = await prisma.mediaAsset.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
usages: true,
|
||||
},
|
||||
const asset = await db.query.mediaAsset.findFirst({
|
||||
where: eq(mediaAsset.id, id),
|
||||
with: { usages: true },
|
||||
});
|
||||
|
||||
return asset ? mapMediaAsset(asset) : null;
|
||||
@@ -101,8 +86,9 @@ export async function createMediaAsset(input: {
|
||||
mimeType?: string | null;
|
||||
size?: number | null;
|
||||
}) {
|
||||
return prisma.mediaAsset.create({
|
||||
data: {
|
||||
const [created] = await db
|
||||
.insert(mediaAsset)
|
||||
.values({
|
||||
source: input.source,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
@@ -111,8 +97,10 @@ export async function createMediaAsset(input: {
|
||||
altText: input.altText ?? null,
|
||||
mimeType: input.mimeType ?? null,
|
||||
size: input.size ?? null,
|
||||
},
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function replaceEntityMediaUsages(input: {
|
||||
@@ -124,51 +112,42 @@ 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 +174,5 @@ export async function getPortfolioMediaBindings(projectId: string): Promise<Port
|
||||
}
|
||||
|
||||
export async function countMediaUsageReferences(assetId: string) {
|
||||
return prisma.mediaUsage.count({
|
||||
where: {
|
||||
assetId,
|
||||
},
|
||||
});
|
||||
return db.$count(mediaUsage, eq(mediaUsage.assetId, assetId));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
+72
-147
@@ -1,74 +1,22 @@
|
||||
import type {
|
||||
Category,
|
||||
PortfolioAsset,
|
||||
PortfolioProject,
|
||||
PortfolioProjectViewMode,
|
||||
PortfolioSection,
|
||||
} from "@prisma/client";
|
||||
|
||||
import { cache } from "react";
|
||||
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
category as categoryTable,
|
||||
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,
|
||||
| "id"
|
||||
| "slug"
|
||||
| "nameAr"
|
||||
| "nameEn"
|
||||
| "nameDe"
|
||||
| "descriptionAr"
|
||||
| "descriptionEn"
|
||||
| "descriptionDe"
|
||||
| "sortOrder"
|
||||
| "isActive"
|
||||
>;
|
||||
|
||||
type SectionRecord = Pick<
|
||||
PortfolioSection,
|
||||
| "id"
|
||||
| "type"
|
||||
| "titleAr"
|
||||
| "titleEn"
|
||||
| "titleDe"
|
||||
| "bodyAr"
|
||||
| "bodyEn"
|
||||
| "bodyDe"
|
||||
| "imagePath"
|
||||
| "linkUrl"
|
||||
| "sortOrder"
|
||||
>;
|
||||
|
||||
type AssetRecord = Pick<
|
||||
PortfolioAsset,
|
||||
"id" | "kind" | "filePath" | "altAr" | "altEn" | "altDe" | "sortOrder"
|
||||
>;
|
||||
|
||||
type ProjectRecord = Pick<
|
||||
PortfolioProject,
|
||||
| "id"
|
||||
| "slug"
|
||||
| "viewMode"
|
||||
| "titleAr"
|
||||
| "titleEn"
|
||||
| "titleDe"
|
||||
| "summaryAr"
|
||||
| "summaryEn"
|
||||
| "summaryDe"
|
||||
| "clientName"
|
||||
| "projectYear"
|
||||
| "serviceLabelAr"
|
||||
| "serviceLabelEn"
|
||||
| "serviceLabelDe"
|
||||
| "previewUrl"
|
||||
| "coverImagePath"
|
||||
| "isFeatured"
|
||||
| "isPublished"
|
||||
| "publishedAt"
|
||||
| "sortOrder"
|
||||
>;
|
||||
type CategoryRecord = typeof categoryTable.$inferSelect;
|
||||
type SectionRecord = typeof portfolioSection.$inferSelect;
|
||||
type AssetRecord = typeof portfolioAsset.$inferSelect;
|
||||
type ProjectRecord = typeof portfolioProject.$inferSelect;
|
||||
|
||||
export type LocalizedContent = {
|
||||
ar: string;
|
||||
@@ -240,40 +188,29 @@ export function getLocalizedValue(
|
||||
}
|
||||
|
||||
export async function getAdminPortfolioCategories() {
|
||||
const categories = await prisma.category.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
const categories = await db.query.category.findMany({
|
||||
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
|
||||
with: { projects: { columns: { id: true } } },
|
||||
});
|
||||
|
||||
return categories.map((category) => ({
|
||||
...mapCategory(category),
|
||||
projectCount: category._count.projects,
|
||||
projectCount: category.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(categoryTable.isActive, true),
|
||||
orderBy: [asc(categoryTable.sortOrder), asc(categoryTable.createdAt)],
|
||||
});
|
||||
|
||||
return categories.map(mapCategory);
|
||||
}
|
||||
|
||||
export async function getActivePortfolioCategoryBySlug(slug: string) {
|
||||
const category = await prisma.category.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
isActive: true,
|
||||
},
|
||||
const category = await db.query.category.findFirst({
|
||||
where: and(eq(categoryTable.slug, slug), eq(categoryTable.isActive, true)),
|
||||
});
|
||||
|
||||
return category ? mapCategory(category) : null;
|
||||
@@ -283,90 +220,78 @@ 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)] : []),
|
||||
...(filters?.status === "draft"
|
||||
? [eq(portfolioProject.isPublished, false)]
|
||||
: filters?.status === "published"
|
||||
? [eq(portfolioProject.isPublished, true)]
|
||||
: []),
|
||||
];
|
||||
|
||||
const projects = await db.query.portfolioProject.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
with: {
|
||||
category: true,
|
||||
sections: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
assets: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.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" }],
|
||||
},
|
||||
assets: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.createdAt)] },
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
|
||||
orderBy: [
|
||||
asc(portfolioProject.sortOrder),
|
||||
desc(portfolioProject.publishedAt),
|
||||
desc(portfolioProject.createdAt),
|
||||
],
|
||||
});
|
||||
|
||||
return projects.map((project) => mapProject(project));
|
||||
// Prisma filtered on the related category (active + optional slug); the
|
||||
// relational query filters the main table only, so narrow here.
|
||||
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" }],
|
||||
},
|
||||
assets: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.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" }],
|
||||
},
|
||||
assets: {
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
sections: { orderBy: [asc(portfolioSection.sortOrder), asc(portfolioSection.createdAt)] },
|
||||
assets: { orderBy: [asc(portfolioAsset.sortOrder), asc(portfolioAsset.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