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