From 864c84d2e51780f167bcd95ddfba9e2e4c1ad8fe Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 21 Apr 2026 13:55:12 +0200 Subject: [PATCH] feat: implement AI categorization for products and add premium access guard --- backend/src/auth/premium-or-admin.guard.ts | 18 +++++++ .../products/dto/ai-categorize-bulk.dto.ts | 8 +++ .../products/dto/set-product-status.dto.ts | 6 +++ backend/src/products/products.controller.ts | 54 ++++++------------- backend/src/products/products.service.ts | 27 +++++++++- 5 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 backend/src/auth/premium-or-admin.guard.ts create mode 100644 backend/src/products/dto/ai-categorize-bulk.dto.ts create mode 100644 backend/src/products/dto/set-product-status.dto.ts diff --git a/backend/src/auth/premium-or-admin.guard.ts b/backend/src/auth/premium-or-admin.guard.ts new file mode 100644 index 00000000..5a98a218 --- /dev/null +++ b/backend/src/auth/premium-or-admin.guard.ts @@ -0,0 +1,18 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; + +/** + * Tillåter åtkomst om användaren är admin eller har isPremium = true. + * Används som alternativ till @Roles('admin') där premium-användare ska ha samma rättighet. + */ +@Injectable() +export class PremiumOrAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const { user } = context.switchToHttp().getRequest(); + + if (user?.role === 'admin' || user?.isPremium === true) { + return true; + } + + throw new ForbiddenException('Denna funktion kräver premiumkonto eller admin-behörighet.'); + } +} diff --git a/backend/src/products/dto/ai-categorize-bulk.dto.ts b/backend/src/products/dto/ai-categorize-bulk.dto.ts new file mode 100644 index 00000000..9d7a10c6 --- /dev/null +++ b/backend/src/products/dto/ai-categorize-bulk.dto.ts @@ -0,0 +1,8 @@ +import { IsArray, IsInt, IsOptional } from 'class-validator'; + +export class AiCategorizeBulkDto { + @IsOptional() + @IsArray() + @IsInt({ each: true }) + productIds?: number[]; +} diff --git a/backend/src/products/dto/set-product-status.dto.ts b/backend/src/products/dto/set-product-status.dto.ts new file mode 100644 index 00000000..c0ef5acf --- /dev/null +++ b/backend/src/products/dto/set-product-status.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class SetProductStatusDto { + @IsIn(['active', 'rejected']) + status: string; +} diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 25cc595d..37d22051 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, Delete, - ForbiddenException, Get, HttpCode, Param, @@ -12,6 +11,7 @@ import { Put, Query, Request, + UseGuards, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { Public } from '../auth/decorators/public.decorator'; @@ -23,22 +23,12 @@ import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto'; import { SetTagsDto } from './dto/set-tags.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto'; +import { AiCategorizeBulkDto } from './dto/ai-categorize-bulk.dto'; +import { SetProductStatusDto } from './dto/set-product-status.dto'; import { Roles } from '../auth/decorators/roles.decorator'; import { AiService } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; -import { IsArray, IsIn, IsInt, IsOptional } from 'class-validator'; - -class AiCategorizeBulkDto { - @IsOptional() - @IsArray() - @IsInt({ each: true }) - productIds?: number[]; -} - -class SetProductStatusDto { - @IsIn(['active', 'rejected']) - status: string; -} +import { PremiumOrAdminGuard } from '../auth/premium-or-admin.guard'; @Controller('products') export class ProductsController { @@ -94,37 +84,28 @@ export class ProductsController { @Post('ai-categorize-bulk') @Throttle({ default: { ttl: 60_000, limit: 5 } }) @HttpCode(200) - async aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) { - const categories = await this.categoriesService.findFlattened(); - let products: { id: number; name: string }[]; - if (body.productIds && body.productIds.length > 0) { - const found = await Promise.all(body.productIds.map((id) => this.productsService.findOne(id))); - products = found.map((p) => ({ id: p.id, name: p.canonicalName ?? p.name })); - } else { - products = await this.productsService.findUncategorized(); - } - const results: { productId: number; productName: string; suggestion: object }[] = []; - for (const product of products) { - const suggestion = await this.aiService.suggestCategory(product.name, categories); - results.push({ productId: product.id, productName: product.name, suggestion }); - } - return results; + aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) { + return this.productsService.aiCategorizeBulk(body.productIds); } + @Roles('admin') + @Get('deleted') + findDeleted() { + return this.productsService.findDeleted(); + } + + // Tillgänglig för alla inloggade användare @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.productsService.findOne(id); } + @UseGuards(PremiumOrAdminGuard) @Get(':id/suggest-category') @Throttle({ default: { ttl: 60_000, limit: 20 } }) async suggestCategory( @Param('id', ParseIntPipe) id: number, - @Request() req: { user: { role: string; isPremium: boolean } }, ) { - if (req.user.role !== 'admin' && !req.user.isPremium) { - throw new ForbiddenException('Denna funktion kräver premiumkonto'); - } const product = await this.productsService.findOne(id); const categories = await this.categoriesService.findFlattened(); return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories); @@ -136,6 +117,7 @@ export class ProductsController { return this.productsService.create(body); } + // Tillgänglig för alla inloggade användare — req.user.id injiceras av JWT-guard @Post('pending') createPending( @Body() body: CreateProductDto, @@ -186,12 +168,6 @@ export class ProductsController { return this.productsService.update(id, body); } - @Roles('admin') - @Get('deleted') - findDeleted() { - return this.productsService.findDeleted(); - } - @Roles('admin') @Delete(':id/permanent') permanentDelete(@Param('id', ParseIntPipe) id: number) { diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 5265eac1..b9cc368d 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -4,10 +4,16 @@ import { normalizeName } from '../common/utils/normalize-name'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; +import { AiService } from '../ai/ai.service'; +import { CategoriesService } from '../categories/categories.service'; @Injectable() export class ProductsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly aiService: AiService, + private readonly categoriesService: CategoriesService, + ) {} async findAll(filters?: { tag?: string; subcategory?: string }) { return this.prisma.product.findMany({ @@ -423,6 +429,25 @@ export class ProductsService { }); } + async aiCategorizeBulk(productIds?: number[]): Promise<{ productId: number; productName: string; suggestion: object }[]> { + const categories = await this.categoriesService.findFlattened(); + let products: { id: number; name: string }[]; + + if (productIds && productIds.length > 0) { + const found = await Promise.all(productIds.map((id) => this.findOne(id))); + products = found.map((p) => ({ id: p.id, name: p.canonicalName ?? p.name })); + } else { + products = await this.findUncategorized(); + } + + const results: { productId: number; productName: string; suggestion: object }[] = []; + for (const product of products) { + const suggestion = await this.aiService.suggestCategory(product.name, categories); + results.push({ productId: product.id, productName: product.name, suggestion }); + } + return results; + } + async findPending() { return this.prisma.product.findMany({ where: { status: 'pending' },