Files
MOH 0a5f77d8de 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)
2026-08-07 14:18:41 +02:00

242 lines
7.3 KiB
TypeScript

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],
}),
}));