Files
backend/src/brands/brands.service.ts
T
Ali Reza bb59d5e9ba Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
2026-07-21 17:52:36 +03:30

285 lines
7.7 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'brand'
);
}
type BrandWithImage = Prisma.BrandGetPayload<{
include: { imageMedia: true };
}>;
@Injectable()
export class BrandsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(businessIdRaw: string, query: ListBrandsDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where: Prisma.BrandWhereInput = {
businessId,
...(query.name?.trim()
? {
OR: [
{ nameEn: { contains: query.name.trim(), mode: 'insensitive' } },
{ nameFa: { contains: query.name.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.brand.findMany({
where,
orderBy: [{ sort_order: 'asc' }, { nameEn: 'asc' }, { createdAt: 'desc' }],
skip,
take: pageSize,
include: { imageMedia: true },
}),
this.prisma.brand.count({ where }),
]);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
};
}
async getOne(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const brandId = BigInt(brandIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.read');
const brand = await this.findBrandOrThrow(businessId, brandId);
return { brand: this.serialize(brand) };
}
async create(businessIdRaw: string, dto: CreateBrandDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.create');
const slug = await this.ensureUniqueSlug(
businessId,
dto.slug ?? slugify(dto.nameEn),
);
let imageMediaId: bigint | null = null;
if (dto.imageMediaId) {
imageMediaId = BigInt(dto.imageMediaId);
await this.assertBrandImageMedia(businessId, imageMediaId);
}
const created = await this.prisma.brand.create({
data: {
businessId,
nameEn: dto.nameEn.trim(),
nameFa: dto.nameFa?.trim() || null,
imageMediaId,
about: dto.about?.trim() || null,
slug,
sort_order: dto.sortOrder ?? 0,
},
include: { imageMedia: true },
});
return {
message: 'Brand created successfully',
brand: this.serialize(created),
};
}
async update(
businessIdRaw: string,
brandIdRaw: string,
dto: UpdateBrandDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const brandId = BigInt(brandIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.update');
const existing = await this.findBrandOrThrow(businessId, brandId);
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueSlug(businessId, dto.slug, brandId);
} else if (dto.nameEn && dto.nameEn !== existing.nameEn) {
slug = await this.ensureUniqueSlug(
businessId,
slugify(dto.nameEn),
brandId,
);
}
let imageMediaId: bigint | null | undefined = undefined;
if (dto.imageMediaId !== undefined) {
if (dto.imageMediaId === null || dto.imageMediaId === '') {
imageMediaId = null;
} else {
imageMediaId = BigInt(dto.imageMediaId);
await this.assertBrandImageMedia(businessId, imageMediaId);
}
}
const updated = await this.prisma.brand.update({
where: { id: brandId },
data: {
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}),
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}),
...(dto.about !== undefined ? { about: dto.about?.trim() || null } : {}),
...(imageMediaId !== undefined ? { imageMediaId } : {}),
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
slug,
},
include: { imageMedia: true },
});
return {
message: 'Brand updated successfully',
brand: this.serialize(updated),
};
}
async remove(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const brandId = BigInt(brandIdRaw);
await this.assertPermission(businessId, actor.id, 'brands.delete');
await this.findBrandOrThrow(businessId, brandId);
await this.prisma.brand.delete({ where: { id: brandId } });
return { message: 'Brand deleted successfully' };
}
async assertBrandBelongsToBusiness(businessId: bigint, brandId: bigint) {
const brand = await this.prisma.brand.findFirst({
where: { id: brandId, businessId },
});
if (!brand) {
throw new BadRequestException('Brand not found for this business');
}
}
serializeBrandSummary(brand: BrandWithImage | null) {
if (!brand) {
return null;
}
return this.serialize(brand);
}
private async findBrandOrThrow(businessId: bigint, brandId: bigint) {
const brand = await this.prisma.brand.findFirst({
where: { id: brandId, businessId },
include: { imageMedia: true },
});
if (!brand) {
throw new NotFoundException('Brand not found');
}
return brand;
}
private serialize(brand: BrandWithImage) {
return {
id: brand.id.toString(),
businessId: brand.businessId.toString(),
nameEn: brand.nameEn,
nameFa: brand.nameFa,
about: brand.about,
slug: brand.slug,
imageMediaId: brand.imageMediaId?.toString() ?? null,
imageUrl: brand.imageMedia?.publicUrl ?? null,
sortOrder: brand.sort_order,
createdAt: brand.createdAt,
updatedAt: brand.updatedAt,
};
}
private async assertBrandImageMedia(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
select: { id: true, mimeType: true },
});
if (!media) {
throw new BadRequestException('Brand image media not found for this business');
}
if (media.mimeType !== 'image/png') {
throw new BadRequestException('Brand image must be a PNG file');
}
}
private async ensureUniqueSlug(
businessId: bigint,
baseSlug: string,
excludeId?: bigint,
) {
let slug = baseSlug;
let suffix = 1;
while (true) {
const existing = await this.prisma.brand.findFirst({
where: {
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`,
);
}
}
}