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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user