Initial commit: Meshkee CMS API

NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
import { ContentStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
export class ListPortfoliosDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
title?: string;
}
export class ListPublicPortfoliosDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsString()
title?: string;
}
export class CreatePortfolioDto {
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
abstract?: string;
@IsOptional()
@IsString()
mainTextHtml?: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
/** Title image (recommended 3:2 aspect ratio). */
@IsOptional()
@IsString()
featuredMediaId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
galleryMediaIds?: string[];
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
}
export class UpdatePortfolioDto {
@IsOptional()
@IsString()
@MinLength(2)
title?: string;
@IsOptional()
@IsString()
abstract?: string | null;
@IsOptional()
@IsString()
mainTextHtml?: string | null;
@IsOptional()
@IsString()
categoryId?: string | null;
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
@IsOptional()
@IsString()
featuredMediaId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
galleryMediaIds?: string[];
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
}
export class CreatePortfolioCommentDto {
@IsString()
@MinLength(2)
authorName!: string;
@IsOptional()
@IsString()
authorEmail?: string;
@IsString()
@MinLength(1)
text!: string;
}
+123
View File
@@ -0,0 +1,123 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
CreatePortfolioCommentDto,
CreatePortfolioDto,
ListPortfoliosDto,
ListPublicPortfoliosDto,
UpdatePortfolioDto,
} from './dto/portfolio.dto';
import { PortfoliosService } from './portfolios.service';
@Controller('businesses/:businessId/portfolios')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class PortfoliosController {
constructor(private readonly service: PortfoliosService) {}
@Get()
@RequireBusinessPermission('portfolios.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListPortfoliosDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':portfolioId')
@RequireBusinessPermission('portfolios.read')
getOne(
@Param('businessId') businessId: string,
@Param('portfolioId') portfolioId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, portfolioId, user);
}
@Post()
@RequireBusinessPermission('portfolios.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreatePortfolioDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Patch(':portfolioId')
@RequireBusinessPermission('portfolios.update')
update(
@Param('businessId') businessId: string,
@Param('portfolioId') portfolioId: string,
@Body() dto: UpdatePortfolioDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, portfolioId, dto, user);
}
@Delete(':portfolioId')
@RequireBusinessPermission('portfolios.delete')
remove(
@Param('businessId') businessId: string,
@Param('portfolioId') portfolioId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, portfolioId, user);
}
@Get(':portfolioId/comments')
@RequireBusinessPermission('comments.read')
listComments(
@Param('businessId') businessId: string,
@Param('portfolioId') portfolioId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listCommentsAdmin(businessId, portfolioId, user);
}
}
@Controller('tenants/:host/portfolios')
export class PublicPortfoliosController {
constructor(private readonly service: PortfoliosService) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
return this.service.listPublic(host, query);
}
@Get(':portfolioId/comments')
listComments(
@Param('host') host: string,
@Param('portfolioId') portfolioId: string,
) {
return this.service.listCommentsPublic(host, portfolioId);
}
@Post(':portfolioId/comments')
createComment(
@Param('host') host: string,
@Param('portfolioId') portfolioId: string,
@Body() dto: CreatePortfolioCommentDto,
) {
return this.service.createCommentPublic(host, portfolioId, dto);
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { TenantModule } from '../tenant/tenant.module';
import {
PortfoliosController,
PublicPortfoliosController,
} from './portfolios.controller';
import { PortfoliosService } from './portfolios.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, TenantModule],
controllers: [PortfoliosController, PublicPortfoliosController],
providers: [PortfoliosService],
})
export class PortfoliosModule {}
+832
View File
@@ -0,0 +1,832 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContentStatus,
MediaEntityType,
Prisma,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreatePortfolioCommentDto,
CreatePortfolioDto,
ListPortfoliosDto,
ListPublicPortfoliosDto,
UpdatePortfolioDto,
} from './dto/portfolio.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'portfolio'
);
}
type PortfolioWithRelations = Prisma.portfoliosGetPayload<{
include: { media: true };
}>;
const portfolioInclude = {
media: true,
} satisfies Prisma.portfoliosInclude;
@Injectable()
export class PortfoliosService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
) {}
async list(businessIdRaw: string, query: ListPortfoliosDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'portfolios.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, query);
const [items, total] = await Promise.all([
this.prisma.portfolios.findMany({
where,
orderBy: [
{ sort_order: 'asc' },
{ published_at: 'desc' },
{ created_at: 'desc' },
],
skip,
take: pageSize,
include: portfolioInclude,
}),
this.prisma.portfolios.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializePortfolio(item, { includeComments: true }),
),
);
return { items: serialized, total, page, pageSize };
}
async getOne(
businessIdRaw: string,
portfolioIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const portfolioId = BigInt(portfolioIdRaw);
await this.assertPermission(businessId, actor.id, 'portfolios.read');
const portfolio = await this.findPortfolioOrThrow(businessId, portfolioId);
return {
portfolio: await this.serializePortfolio(portfolio, {
includeComments: true,
}),
};
}
async create(
businessIdRaw: string,
dto: CreatePortfolioDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'portfolios.create');
const slug = await this.ensureUniqueSlug(
businessId,
dto.slug ?? slugify(dto.title),
);
const status = dto.status ?? ContentStatus.draft;
const featuredMediaId = dto.featuredMediaId
? BigInt(dto.featuredMediaId)
: null;
if (featuredMediaId) {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
const galleryMediaIds = await this.resolveGalleryMediaIds(
businessId,
dto.galleryMediaIds ?? [],
);
if (dto.categoryId) {
await this.assertCategoryBelongsToBusiness(
businessId,
BigInt(dto.categoryId),
);
}
const created = await this.prisma.$transaction(async (tx) => {
const portfolio = await tx.portfolios.create({
data: {
business_id: businessId,
title: dto.title.trim(),
slug,
description: dto.abstract?.trim() || null,
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
status,
featured_media_id: featuredMediaId,
sort_order: dto.sortOrder ?? 0,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
},
include: portfolioInclude,
});
if (dto.categoryId) {
await tx.categoryAssignment.create({
data: {
businessId,
categoryId: BigInt(dto.categoryId),
entityType: MediaEntityType.portfolio,
entityId: portfolio.id,
},
});
}
await this.syncGalleryAttachments(
tx,
businessId,
portfolio.id,
galleryMediaIds,
);
return portfolio;
});
return {
message: 'Portfolio created successfully',
portfolio: await this.serializePortfolio(created),
};
}
async update(
businessIdRaw: string,
portfolioIdRaw: string,
dto: UpdatePortfolioDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const portfolioId = BigInt(portfolioIdRaw);
await this.assertPermission(businessId, actor.id, 'portfolios.update');
const existing = await this.prisma.portfolios.findFirst({
where: { id: portfolioId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Portfolio not found');
}
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueSlug(businessId, dto.slug, portfolioId);
} else if (dto.title && dto.title !== existing.title) {
slug = await this.ensureUniqueSlug(
businessId,
slugify(dto.title),
portfolioId,
);
}
let featuredMediaId: bigint | null | undefined = undefined;
if (dto.featuredMediaId !== undefined) {
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
featuredMediaId = null;
} else {
featuredMediaId = BigInt(dto.featuredMediaId);
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
}
}
const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata);
const nextContent = { ...existingContent };
if (dto.mainTextHtml !== undefined) {
nextContent.html = dto.mainTextHtml ?? '';
}
const nextMetadata = { ...existingMetadata };
if (dto.tags !== undefined) {
nextMetadata.tags = dto.tags;
}
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
if (
dto.status === ContentStatus.published &&
existing.status !== ContentStatus.published
) {
publishedAt = new Date();
}
if (dto.status !== ContentStatus.published) {
publishedAt = null;
}
}
const updated = await this.prisma.$transaction(async (tx) => {
const portfolio = await tx.portfolios.update({
where: { id: portfolioId },
data: {
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.abstract !== undefined
? { description: dto.abstract?.trim() || null }
: {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined
? { featured_media_id: featuredMediaId }
: {}),
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
include: portfolioInclude,
});
if (dto.categoryId !== undefined) {
await tx.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
},
});
if (dto.categoryId) {
const categoryId = BigInt(dto.categoryId);
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
await tx.categoryAssignment.create({
data: {
businessId,
categoryId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
},
});
}
}
if (dto.galleryMediaIds !== undefined) {
const galleryMediaIds = await this.resolveGalleryMediaIds(
businessId,
dto.galleryMediaIds,
);
await this.syncGalleryAttachments(
tx,
businessId,
portfolioId,
galleryMediaIds,
);
}
return portfolio;
});
return {
message: 'Portfolio updated successfully',
portfolio: await this.serializePortfolio(updated),
};
}
async remove(
businessIdRaw: string,
portfolioIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const portfolioId = BigInt(portfolioIdRaw);
await this.assertPermission(businessId, actor.id, 'portfolios.delete');
const existing = await this.prisma.portfolios.findFirst({
where: { id: portfolioId, business_id: businessId },
});
if (!existing) {
throw new NotFoundException('Portfolio not found');
}
await this.prisma.$transaction([
this.prisma.comment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
},
}),
this.prisma.categoryAssignment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
},
}),
this.prisma.mediaAttachment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
},
}),
this.prisma.portfolios.delete({ where: { id: portfolioId } }),
]);
return { message: 'Portfolio deleted successfully' };
}
async listPublic(host: string, query: ListPublicPortfoliosDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const skip = (page - 1) * pageSize;
const where = await this.buildWhere(businessId, {
...query,
status: ContentStatus.published,
});
const [items, total] = await Promise.all([
this.prisma.portfolios.findMany({
where,
orderBy: [
{ sort_order: 'asc' },
{ published_at: 'desc' },
{ created_at: 'desc' },
],
skip,
take: pageSize,
include: portfolioInclude,
}),
this.prisma.portfolios.count({ where }),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializePortfolio(item, {
includeComments: true,
approvedCommentsOnly: true,
}),
),
);
return { items: serialized, total, page, pageSize };
}
async getPublicBySlug(host: string, slug: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const portfolio = await this.prisma.portfolios.findFirst({
where: {
business_id: businessId,
slug,
status: ContentStatus.published,
},
include: portfolioInclude,
});
if (!portfolio) {
throw new NotFoundException('Portfolio not found');
}
return {
portfolio: await this.serializePortfolio(portfolio, {
includeComments: true,
approvedCommentsOnly: true,
}),
};
}
async listCommentsPublic(host: string, portfolioIdRaw: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const portfolioId = BigInt(portfolioIdRaw);
await this.assertPublishedPortfolioExists(businessId, portfolioId);
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
isApproved: true,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
async createCommentPublic(
host: string,
portfolioIdRaw: string,
dto: CreatePortfolioCommentDto,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const portfolioId = BigInt(portfolioIdRaw);
await this.assertPublishedPortfolioExists(businessId, portfolioId);
const autoApprove =
await this.businessSettings.isCommentsAutoApprove(businessId);
const approvedAt = autoApprove ? new Date() : null;
const created = await this.prisma.comment.create({
data: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
authorName: dto.authorName.trim(),
authorEmail: dto.authorEmail?.trim() || null,
text: dto.text.trim(),
isApproved: autoApprove,
approvedAt,
},
include: { approver: true },
});
return {
comment: this.serializeComment(created),
message: autoApprove
? 'Comment submitted and is approved'
: 'Comment submitted and is pending approval',
};
}
async listCommentsAdmin(
businessIdRaw: string,
portfolioIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const portfolioId = BigInt(portfolioIdRaw);
await this.assertPermission(businessId, actor.id, 'comments.read');
const portfolio = await this.prisma.portfolios.findFirst({
where: { id: portfolioId, business_id: businessId },
select: { id: true },
});
if (!portfolio) {
throw new NotFoundException('Portfolio not found');
}
const items = await this.prisma.comment.findMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
},
orderBy: { createdAt: 'desc' },
include: { approver: true },
});
return { items: items.map((item) => this.serializeComment(item)) };
}
private async buildWhere(
businessId: bigint,
query: (ListPortfoliosDto | ListPublicPortfoliosDto) & {
status?: ContentStatus;
},
): Promise<Prisma.portfoliosWhereInput> {
let entityIds: bigint[] | undefined;
if (query.categoryId) {
const assignments = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
categoryId: BigInt(query.categoryId),
entityType: MediaEntityType.portfolio,
},
select: { entityId: true },
});
entityIds = assignments.map((item) => item.entityId);
if (entityIds.length === 0) {
return { id: { in: [] } };
}
}
return {
business_id: businessId,
...(query.status ? { status: query.status } : {}),
...(entityIds ? { id: { in: entityIds } } : {}),
...(query.title?.trim()
? {
title: { contains: query.title.trim(), mode: 'insensitive' },
}
: {}),
};
}
private async findPortfolioOrThrow(businessId: bigint, portfolioId: bigint) {
const portfolio = await this.prisma.portfolios.findFirst({
where: { id: portfolioId, business_id: businessId },
include: portfolioInclude,
});
if (!portfolio) {
throw new NotFoundException('Portfolio not found');
}
return portfolio;
}
private async assertPublishedPortfolioExists(
businessId: bigint,
portfolioId: bigint,
) {
const portfolio = await this.prisma.portfolios.findFirst({
where: {
id: portfolioId,
business_id: businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!portfolio) {
throw new NotFoundException('Portfolio not found');
}
}
private async serializePortfolio(
portfolio: PortfolioWithRelations,
options: {
includeComments?: boolean;
approvedCommentsOnly?: boolean;
} = {},
) {
const content = this.asRecord(portfolio.content);
const metadata = this.asRecord(portfolio.metadata);
const [categoryAssignment, galleryAttachments, commentData] =
await Promise.all([
this.prisma.categoryAssignment.findFirst({
where: {
businessId: portfolio.business_id,
entityType: MediaEntityType.portfolio,
entityId: portfolio.id,
},
include: { category: true },
}),
this.prisma.mediaAttachment.findMany({
where: {
businessId: portfolio.business_id,
entityType: MediaEntityType.portfolio,
entityId: portfolio.id,
isFeatured: false,
},
orderBy: { sortOrder: 'asc' },
include: { media: true },
}),
options.includeComments
? this.loadComments(
portfolio.business_id,
portfolio.id,
options.approvedCommentsOnly,
)
: Promise.resolve({ commentCount: 0, comments: [] }),
]);
return {
id: portfolio.id.toString(),
businessId: portfolio.business_id.toString(),
title: portfolio.title,
slug: portfolio.slug,
abstract: portfolio.description ?? '',
mainTextHtml: (content.html as string | undefined) ?? '',
status: portfolio.status,
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
titleImageUrl:
portfolio.media?.publicUrl ??
galleryAttachments[0]?.media.publicUrl ??
null,
featuredMediaId: portfolio.featured_media_id?.toString() ?? null,
gallery: galleryAttachments.map((item) => ({
mediaId: item.mediaId.toString(),
url: item.media.publicUrl,
})),
galleryMediaIds: galleryAttachments.map((item) =>
item.mediaId.toString(),
),
sortOrder: portfolio.sort_order,
commentCount: commentData.commentCount,
comments: commentData.comments,
publishedAt: portfolio.published_at,
createdAt: portfolio.created_at,
updatedAt: portfolio.updated_at,
};
}
private async loadComments(
businessId: bigint,
portfolioId: bigint,
approvedOnly?: boolean,
) {
const where: Prisma.CommentWhereInput = {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
...(approvedOnly ? { isApproved: true } : {}),
};
const [commentCount, comments] = await Promise.all([
this.prisma.comment.count({ where }),
this.prisma.comment.findMany({
where,
orderBy: { createdAt: 'desc' },
take: approvedOnly ? 50 : undefined,
include: { approver: true },
}),
]);
return {
commentCount,
comments: comments.map((item) => this.serializeComment(item)),
};
}
private serializeComment(
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
) {
return {
id: comment.id.toString(),
businessId: comment.businessId.toString(),
entityType: comment.entityType,
entityId: comment.entityId.toString(),
authorName: comment.authorName,
authorEmail: comment.authorEmail,
text: comment.text,
isApproved: comment.isApproved,
approvedAt: comment.approvedAt,
approvedBy: comment.approvedBy?.toString() ?? null,
approver: comment.approver
? {
id: comment.approver.id.toString(),
firstName: comment.approver.firstName,
lastName: comment.approver.lastName,
}
: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
private buildContent(mainTextHtml?: string) {
return {
html: mainTextHtml ?? '',
};
}
private buildMetadata(tags?: string[]) {
return {
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
};
}
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
private async syncGalleryAttachments(
tx: Prisma.TransactionClient,
businessId: bigint,
portfolioId: bigint,
mediaIds: bigint[],
) {
await tx.mediaAttachment.deleteMany({
where: {
businessId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
isFeatured: false,
},
});
for (const [index, mediaId] of mediaIds.entries()) {
await tx.mediaAttachment.create({
data: {
businessId,
mediaId,
entityType: MediaEntityType.portfolio,
entityId: portfolioId,
sortOrder: index,
isFeatured: false,
},
});
}
}
private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) {
const ids = rawIds.map((id) => BigInt(id));
for (const mediaId of ids) {
await this.assertMediaBelongsToBusiness(businessId, mediaId);
}
return ids;
}
private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Media not found for this business');
}
}
private async assertCategoryBelongsToBusiness(
businessId: bigint,
categoryId: bigint,
) {
const category = await this.prisma.category.findFirst({
where: {
id: categoryId,
businessId,
entityType: MediaEntityType.portfolio,
isActive: true,
},
});
if (!category) {
throw new BadRequestException(
'Portfolio category not found for this business',
);
}
}
private async ensureUniqueSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.portfolios.findFirst({
where: {
business_id: businessId,
slug,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!existing) {
return slug;
}
suffix += 1;
slug = `${baseSlug}-${suffix}`;
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}