NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
285 lines
7.7 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|