Files
sass-mohfarawati/lib/portfolio.ts
T
Moh ba63f75ea8
CI / quality (push) Waiting to run
REFACTORED - Replace Prisma with Drizzle ORM
- 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
2026-08-08 02:09:03 +02:00

373 lines
9.5 KiB
TypeScript

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";
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"
>;
export type LocalizedContent = {
ar: string;
en: string;
de: string;
};
export type PortfolioCategoryView = {
id: string;
slug: string;
name: LocalizedContent;
description: LocalizedContent;
sortOrder: number;
isActive: boolean;
};
export type PortfolioSectionView = {
id: string;
type: SectionRecord["type"];
title: LocalizedContent;
body: LocalizedContent;
imagePath: string | null;
mediaAssetId: string | null;
linkUrl: string | null;
sortOrder: number;
};
export type PortfolioAssetView = {
id: string;
kind: AssetRecord["kind"];
filePath: string;
mediaAssetId: string | null;
alt: LocalizedContent;
sortOrder: number;
};
export type PortfolioProjectView = {
id: string;
slug: string;
viewMode: PortfolioProjectViewMode;
title: LocalizedContent;
summary: LocalizedContent;
clientName: string;
projectYear: number;
serviceLabel: LocalizedContent;
previewUrl: string | null;
coverImagePath: string | null;
coverMediaAssetId: string | null;
isFeatured: boolean;
isPublished: boolean;
publishedAt: Date | null;
sortOrder: number;
category: PortfolioCategoryView;
sections: PortfolioSectionView[];
assets: PortfolioAssetView[];
};
export function resolvePortfolioProjectViewMode(
value: string | null | undefined,
): PortfolioProjectViewMode {
switch (value) {
case "STORY":
return "STORY";
case "CASE_STUDY":
return "CASE_STUDY";
case "GRID":
default:
return "GRID";
}
}
function mapLocalizedContent(record: Record<string, unknown>, prefix: string): LocalizedContent {
return {
ar: String(record[`${prefix}Ar`] ?? ""),
en: String(record[`${prefix}En`] ?? ""),
de: String(record[`${prefix}De`] ?? ""),
};
}
function mapCategory(record: CategoryRecord): PortfolioCategoryView {
return {
id: record.id,
slug: record.slug,
name: mapLocalizedContent(record, "name"),
description: mapLocalizedContent(record, "description"),
sortOrder: record.sortOrder,
isActive: record.isActive,
};
}
function mapSection(record: SectionRecord, mediaAssetId: string | null): PortfolioSectionView {
return {
id: record.id,
type: record.type,
title: mapLocalizedContent(record, "title"),
body: mapLocalizedContent(record, "body"),
imagePath: record.imagePath,
mediaAssetId,
linkUrl: record.linkUrl,
sortOrder: record.sortOrder,
};
}
function mapAsset(record: AssetRecord, mediaAssetId: string | null): PortfolioAssetView {
return {
id: record.id,
kind: record.kind,
filePath: record.filePath,
mediaAssetId,
alt: mapLocalizedContent(record, "alt"),
sortOrder: record.sortOrder,
};
}
function mapProject(
record: ProjectRecord & {
category: CategoryRecord;
sections: SectionRecord[];
assets: AssetRecord[];
},
mediaBindings?: {
coverAssetId: string | null;
sectionAssetIds: Record<string, string>;
assetIds: Record<string, string>;
},
): PortfolioProjectView {
return {
id: record.id,
slug: record.slug,
viewMode: resolvePortfolioProjectViewMode(record.viewMode),
title: mapLocalizedContent(record, "title"),
summary: mapLocalizedContent(record, "summary"),
clientName: record.clientName,
projectYear: record.projectYear,
serviceLabel: mapLocalizedContent(record, "serviceLabel"),
previewUrl: record.previewUrl,
coverImagePath: record.coverImagePath,
coverMediaAssetId: mediaBindings?.coverAssetId ?? null,
isFeatured: record.isFeatured,
isPublished: record.isPublished,
publishedAt: record.publishedAt,
sortOrder: record.sortOrder,
category: mapCategory(record.category),
sections: record.sections.map((section) =>
mapSection(section, mediaBindings?.sectionAssetIds[section.id] ?? null),
),
assets: record.assets.map((asset) => mapAsset(asset, mediaBindings?.assetIds[asset.id] ?? null)),
};
}
export function getLocalizedValue(
content: LocalizedContent,
locale: AppLocale,
fallbackLocale: AppLocale = "de",
): string {
const direct = content[locale]?.trim();
if (direct) {
return direct;
}
const fallback = content[fallbackLocale]?.trim();
if (fallback) {
return fallback;
}
return content.ar || content.en || content.de || "";
}
export async function getAdminPortfolioCategories() {
const categories = await db.query.category.findMany({
with: {
projects: {
columns: { id: true },
},
},
orderBy: [asc(category.sortOrder), asc(category.createdAt)],
});
return categories.map((record) => ({
...mapCategory(record),
projectCount: record.projects.length,
}));
}
export async function getActivePortfolioCategories() {
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 record = await db.query.category.findFirst({
where: and(eq(category.slug, slug), eq(category.isActive, true)),
});
return record ? mapCategory(record) : null;
}
export async function getAdminPortfolioProjects(filters?: {
categoryId?: string;
status?: "all" | "draft" | "published";
}) {
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: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
orderBy: [asc(portfolioProject.sortOrder), desc(portfolioProject.createdAt)],
});
return projects.map((project) => mapProject(project));
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await db.query.portfolioProject.findMany({
where: eq(portfolioProject.isPublished, true),
with: {
category: true,
sections: {
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
orderBy: [
asc(portfolioProject.sortOrder),
desc(portfolioProject.publishedAt),
desc(portfolioProject.createdAt),
],
});
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 db.query.portfolioProject.findFirst({
where: and(eq(portfolioProject.slug, slug), eq(portfolioProject.isPublished, true)),
with: {
category: true,
sections: {
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
});
if (!project || !project.category.isActive) {
return null;
}
return mapProject(project);
});
export async function getAdminPortfolioProjectById(id: string) {
const project = await db.query.portfolioProject.findFirst({
where: eq(portfolioProject.id, id),
with: {
category: true,
sections: {
orderBy: (section, { asc: ascOrder }) => [ascOrder(section.sortOrder), ascOrder(section.createdAt)],
},
assets: {
orderBy: (asset, { asc: ascOrder }) => [ascOrder(asset.sortOrder), ascOrder(asset.createdAt)],
},
},
});
if (!project) {
return null;
}
const mediaBindings = await getPortfolioMediaBindings(project.id);
return mapProject(project, mediaBindings);
}