import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Query, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { FileInterceptor } from '@nestjs/platform-express'; import { ProductType } from '@prisma/client'; import { diskStorage } from 'multer'; import { extname } from 'path'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { PermissionsGuard } from '../auth/permissions.guard'; import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { ANY_TENANT_READ, productAnyReadPermissions, productReadPermission, productUiWritePermission, productWritePermission, userHasAnyPermission, userHasPermission, } from '../auth/permissions'; import { AVATARS_DIR, ensureUploadsDirs, publicApiBase, } from '../uploads/uploads.util'; import { CreateTenantDto } from './dto/create-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto'; import { TenantsService } from './tenants.service'; const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i; /** Fields edited from the appearance pages; guarded by `.ui:write`. */ const APPEARANCE_FIELDS = [ 'appUiConfig', 'uiVariant', 'primaryColor', 'secondaryColor', 'backgroundColor', ] as const; type AuthUser = { id: string; role?: string; permissions?: unknown }; @Controller('tenants') @UseGuards(JwtAuthGuard, PermissionsGuard) export class TenantsController { constructor( private readonly tenantsService: TenantsService, private readonly config: ConfigService, ) {} @Get() @RequirePermissions(...ANY_TENANT_READ, { mode: 'any' }) async findAll( @Query('product') product?: ProductType, @CurrentUser() user?: AuthUser, ) { if (product) { this.assertAnyProductRead(user, product); return this.tenantsService.findAll(product); } const rows = await this.tenantsService.findAll(); return rows.filter((row) => userHasAnyPermission(user || {}, productAnyReadPermissions(row.product)), ); } @Post('upload-avatar') @RequirePermissions('hamkar:write', 'khanegi:write', 'others:write', { mode: 'any' }) @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ destination: (_req, _file, cb) => { ensureUploadsDirs(); cb(null, AVATARS_DIR); }, filename: (_req, file, cb) => { const safeExt = extname(file.originalname).toLowerCase() || '.png'; const name = `avatar_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${safeExt}`; cb(null, name); }, }), limits: { fileSize: 2 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (!IMAGE_MIME.test(file.mimetype)) { cb( new BadRequestException( 'فقط تصویر (jpg/png/gif/webp/svg) مجاز است', ) as any, false, ); return; } cb(null, true); }, }), ) uploadAvatar(@UploadedFile() file: Express.Multer.File) { if (!file) { throw new BadRequestException('فایل ارسال نشده است'); } const base = publicApiBase(this.config); return { url: `${base}/uploads/avatars/${file.filename}` }; } @Get(':id') @RequirePermissions(...ANY_TENANT_READ, { mode: 'any' }) async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) { const tenant = await this.tenantsService.findOne(id); this.assertAnyProductRead(user, tenant.product); return tenant; } @Post() @RequirePermissions('hamkar:write', 'khanegi:write', 'others:write', { mode: 'any' }) create(@Body() dto: CreateTenantDto, @CurrentUser() user: AuthUser) { this.assertProductAccess(user, dto.product, 'write'); return this.tenantsService.create(dto, user.id); } @Patch(':id') @RequirePermissions( 'hamkar:write', 'hamkar.ui:write', 'khanegi:write', 'khanegi.ui:write', 'others:write', { mode: 'any' }, ) async update( @Param('id') id: string, @Body() dto: UpdateTenantDto, @CurrentUser() user: AuthUser, ) { const tenant = await this.tenantsService.findOne(id); const before = tenant as unknown as Record; const body = dto as Record; const appearanceChanged = APPEARANCE_FIELDS.some((key) => { if (body[key] === undefined) return false; return JSON.stringify(body[key]) !== JSON.stringify(before[key] ?? null); }); const touchesIdentity = Object.keys(body).some( (key) => body[key] !== undefined && !(APPEARANCE_FIELDS as readonly string[]).includes(key), ); if (touchesIdentity) { this.assertProductAccess(user, tenant.product, 'write'); } if (appearanceChanged) { const uiCode = productUiWritePermission(tenant.product); const allowed = uiCode ? userHasPermission(user || {}, uiCode) : userHasPermission(user || {}, productWritePermission(tenant.product)); if (!allowed) { throw new ForbiddenException('شما به ظاهر این محصول دسترسی ندارید'); } } if (!touchesIdentity && !appearanceChanged) { this.assertAnyProductRead(user, tenant.product); } return this.tenantsService.update(id, dto, user.id); } @Delete(':id') @RequirePermissions('hamkar:write', 'khanegi:write', 'others:write', { mode: 'any' }) async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) { const tenant = await this.tenantsService.findOne(id); this.assertProductAccess(user, tenant.product, 'write'); return this.tenantsService.remove(id, user.id); } private assertProductAccess( user: AuthUser | undefined, product: string, mode: 'read' | 'write', ) { const code = mode === 'read' ? productReadPermission(product) : productWritePermission(product); if (!userHasPermission(user || {}, code)) { throw new ForbiddenException('شما به این محصول دسترسی ندارید'); } } private assertAnyProductRead(user: AuthUser | undefined, product: string) { if (!userHasAnyPermission(user || {}, productAnyReadPermissions(product))) { throw new ForbiddenException('شما به این محصول دسترسی ندارید'); } } }