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
+75
View File
@@ -0,0 +1,75 @@
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 { BrandsService } from './brands.service';
import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto';
@Controller('businesses/:businessId/brands')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class BrandsController {
constructor(private readonly service: BrandsService) {}
@Get()
@RequireBusinessPermission('brands.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListBrandsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':brandId')
@RequireBusinessPermission('brands.read')
getOne(
@Param('businessId') businessId: string,
@Param('brandId') brandId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, brandId, user);
}
@Post()
@RequireBusinessPermission('brands.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateBrandDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Patch(':brandId')
@RequireBusinessPermission('brands.update')
update(
@Param('businessId') businessId: string,
@Param('brandId') brandId: string,
@Body() dto: UpdateBrandDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, brandId, dto, user);
}
@Delete(':brandId')
@RequireBusinessPermission('brands.delete')
remove(
@Param('businessId') businessId: string,
@Param('brandId') brandId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, brandId, user);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BrandsController } from './brands.controller';
import { BrandsService } from './brands.service';
@Module({
imports: [AuthModule],
controllers: [BrandsController],
providers: [BrandsService],
exports: [BrandsService],
})
export class BrandsModule {}
+284
View File
@@ -0,0 +1,284 @@
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`,
);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
export class ListBrandsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
name?: string;
}
export class CreateBrandDto {
@IsString()
@MinLength(2)
nameEn!: string;
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
imageMediaId?: string;
@IsOptional()
@IsString()
about?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
}
export class UpdateBrandDto {
@IsOptional()
@IsString()
@MinLength(2)
nameEn?: string;
@IsOptional()
@IsString()
nameFa?: string | null;
@IsOptional()
@IsString()
imageMediaId?: string | null;
@IsOptional()
@IsString()
about?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
}